From 3a327c0800c56b72a26fa47fcf72d503a4b63a47 Mon Sep 17 00:00:00 2001 From: Peter Donald Date: Sat, 11 Jul 2026 12:55:17 +1000 Subject: [PATCH 1/4] feat(cache): use repository-local mirror caches --- README.md | 29 +- docs/migration-from-ruby-braid.md | 30 +- integration/completion_test.go | 2 +- integration/lifecycle_test.go | 4 +- integration/no_commit_test.go | 2 +- integration/setup_failure_test.go | 19 +- integration/subdirectory_test.go | 2 +- integration/support.go | 82 ++-- internal/cli/cli.go | 38 +- internal/cli/cli_test.go | 14 +- internal/command/add.go | 56 +-- internal/command/add_test.go | 2 +- internal/command/cache.go | 449 +++++++++++++++++- internal/command/cache_test.go | 271 +++++++++-- internal/command/completion.go | 14 +- internal/command/completion_test.go | 12 +- internal/command/diff.go | 4 +- internal/command/progress_integration_test.go | 5 +- internal/command/push.go | 4 +- internal/command/setup.go | 119 ++++- internal/command/setup_test.go | 112 ++++- internal/command/status.go | 4 +- internal/command/sync.go | 4 +- internal/command/sync_test.go | 4 +- internal/command/test_support_test.go | 16 - internal/command/update.go | 16 +- internal/command/update_test.go | 2 +- internal/gitexec/gitexec.go | 5 + internal/gitexec/gitexec_test.go | 17 + 29 files changed, 1053 insertions(+), 285 deletions(-) diff --git a/README.md b/README.md index 4df1ce6..dade669 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ in your next `git commit` unless you unstage them first. Global flags must appear before the command name: ```bash -braid [--verbose|-v | --quiet] [--no-cache | --cache-dir ] [options] +braid [--verbose|-v | --quiet] [--no-cache | --global-cache-dir ] [options] ``` `--verbose` prints Git command tracing. `--quiet` suppresses progress and other @@ -551,10 +551,25 @@ braid setup braid setup vendor/rails --force ``` -The local cache is enabled by default. Without overrides, Braid stores it under -the OS user cache directory with a `braid` child directory. Use -`BRAID_LOCAL_CACHE_DIR` or `--cache-dir` to choose a location, and use -`BRAID_USE_LOCAL_CACHE=false` or `--no-cache` to disable it. +The local cache is enabled by default. Without overrides, Braid stores +repository-local per-mirror bare caches under `.git/braid/cache`. These caches +are implementation state and can be rebuilt by `braid add`, `braid setup`, +`braid status`, `braid pull`, `braid diff`, `braid push`, or `braid sync` while +the upstream still serves the recorded revisions from `.braids.json`. + +Repository-local caches are shallow for common branch, tag, and full-SHA +revision workflows. Fetching from a shallow cache can make the downstream Git +repository report as shallow because Git records the shallow mirror commits in +`.git/shallow`; those commits are Braid mirror objects, not the downstream +branch history. If an upstream has removed a recorded revision and the +repository-local cache was deleted, Braid fails instead of guessing a base. + +Use `BRAID_GLOBAL_CACHE_DIR` or `--global-cache-dir` to choose a shared full-cache +location, and use `BRAID_USE_LOCAL_CACHE=false` or `--no-cache` to disable +caching. + +The old `BRAID_LOCAL_CACHE_DIR` environment variable and `--cache-dir` flag have +been replaced by `BRAID_GLOBAL_CACHE_DIR` and `--global-cache-dir`. Mirror paths stored in `.braids.json` always use repo-root-relative `/` separators, and ordinary Braid output uses those same repo-root-relative paths. @@ -565,8 +580,8 @@ inside the Git working tree, and stored config paths remain relative. Commands without a `local_path`, such as `braid status`, `braid diff`, `braid setup`, `braid pull`, and `braid sync`, operate on the repository-wide -mirror set from any subdirectory. Relative `--cache-dir` values and -`BRAID_LOCAL_CACHE_DIR` values remain relative to the process directory. Git +mirror set from any subdirectory. Relative `--global-cache-dir` values and +`BRAID_GLOBAL_CACHE_DIR` values remain relative to the process directory. Git diff arguments after `braid diff ... --` are passed through as raw `git diff` arguments from the process directory; Braid only anchors its own internal mirror pathspecs. diff --git a/docs/migration-from-ruby-braid.md b/docs/migration-from-ruby-braid.md index 1ef730b..2be0058 100644 --- a/docs/migration-from-ruby-braid.md +++ b/docs/migration-from-ruby-braid.md @@ -154,7 +154,7 @@ Migration impact: | Help form | `braid help`, `braid add help`, `braid add --help`; the old README also advertised `braid help add`, but the `v1.1.10` gem does not provide command-specific help through that form | `braid help`, `braid add help`, `braid add --help` | Prefer `braid help` or `braid --help`. | | Verbose flag | Per-command `--verbose`/`-v` | Global `--verbose`/`-v` before the command | Use `braid -v pull ...`, not `braid pull -v ...`. | | Quiet flag | No global quiet flag | Global `--quiet` before the command; incompatible with `--verbose` | Use `braid --quiet ...` in automation that wants data, warnings, errors, and recovery output without progress or informational chatter. | -| Cache flags | Environment variables only | Global `--no-cache` or `--cache-dir ` before the command, plus environment variables | Put cache flags before the command name. | +| Cache flags | Environment variables only | Global `--no-cache` or `--global-cache-dir ` before the command, plus environment variables | Put cache flags before the command name. | | Bash completion | No documented generated completion command | `braid completion bash` prints a Bash completion script | Load it from shell startup or install it in the Bash completion directory to complete global options, commands, command options, and configured mirror paths. | | `update --head` | Accepted as an option, then errors with a deprecation message | Unknown flag for `pull` and its aliases | Remove it; use `--branch`, `--tag`, or `--revision` with an explicit mirror path. | | `update` without path | Updates all configured mirrors through Ruby's all-update flow | `pull` without a path updates branch/tag mirrors in lexicographic path order, skips revision-locked mirrors, and reports skipped paths; `update` and `up` behave the same way | Locked mirrors are no longer touched by all-update. | @@ -222,21 +222,30 @@ Migration impact: Both tools enable a local mirror cache by default, but the cache layout changed. Ruby Braid defaults to `~/.braid/cache` and derives cache child paths by -sanitizing the upstream URL. Current Go Braid defaults to the operating system -user cache directory with a `braid` child directory and derives cache child paths -from a SHA-256 hash of the upstream URL. +sanitizing the upstream URL. Current Go Braid defaults to repository-local +per-mirror bare caches under `.git/braid/cache` and derives cache child paths +from a SHA-256 hash of the upstream URL, local path, upstream path, and tracking +mode. Current controls: - `BRAID_USE_LOCAL_CACHE=false` or `--no-cache` disables the cache. -- `BRAID_LOCAL_CACHE_DIR=` or `--cache-dir ` selects the cache - directory. -- `--no-cache` and `--cache-dir` cannot be used together. +- `BRAID_GLOBAL_CACHE_DIR=` or `--global-cache-dir ` selects a shared + full-cache directory. +- `--no-cache` and `--global-cache-dir` cannot be used together. +- `BRAID_LOCAL_CACHE_DIR` and `--cache-dir` have been replaced by + `BRAID_GLOBAL_CACHE_DIR` and `--global-cache-dir`. Migration impact: - Do not depend on the old `~/.braid/cache` layout or old cache path names. -- Braid-managed remotes created by `setup` may point at different cache paths. +- Braid-managed remotes created by `setup` may point at repository-local cache + paths under `.git/braid/cache`. +- Repository-local caches are disposable. If one is deleted, Braid can rebuild it + only while upstream still serves the recorded revisions from `.braids.json`. + Shallow repository-local caches can also make Git report the downstream + repository as shallow because Braid mirror commits are recorded in + `.git/shallow`. - Tag mirrors work when the Go cache is disabled. Ruby Braid could not retrieve tag revisions with the cache disabled. @@ -313,8 +322,9 @@ Known output differences include: result with expected mirror state. 6. Review `.braids.json` for unknown fields, unsafe paths, and remote-name collisions. -7. Decide whether the new default cache location is acceptable; set - `BRAID_LOCAL_CACHE_DIR` or use `--cache-dir` if you need a controlled path. +7. Decide whether repository-local caches are acceptable; set + `BRAID_GLOBAL_CACHE_DIR` or use `--global-cache-dir` if you need a controlled + shared full-cache path. 8. For push workflows, make sure local mirror edits are committed downstream before `braid push` or `braid sync`. 9. Prefer `braid sync` for the push-then-pull workflow once the team has diff --git a/integration/completion_test.go b/integration/completion_test.go index c8f6292..1fae318 100644 --- a/integration/completion_test.go +++ b/integration/completion_test.go @@ -32,7 +32,7 @@ func TestExecutableBashCompletion(t *testing.T) { globalFlags := completeExecutable(t, env, root, braid, "--") assertCompletionCandidate(t, globalFlags, "--verbose") assertCompletionCandidate(t, globalFlags, "--quiet") - assertCompletionCandidate(t, globalFlags, "--cache-dir") + assertCompletionCandidate(t, globalFlags, "--global-cache-dir") noRepoMirrors := runBraid(t, env, root, braid, "__complete", "bash", "--", "status", "") assertResult(t, noRepoMirrors, 0, "", "") diff --git a/integration/lifecycle_test.go b/integration/lifecycle_test.go index ad89734..6d7cea5 100644 --- a/integration/lifecycle_test.go +++ b/integration/lifecycle_test.go @@ -28,7 +28,7 @@ func TestExecutablePrimaryLifecycle(t *testing.T) { localPath := "vendor/lib with spaces" remote := remoteName("main", localPath) - cacheRemoteURL := cachePath(env.braidCacheDir(), upstream) + cacheRemoteURL := repositoryCachePath(t, downstream, localPath, configMirror{URL: upstream, Branch: "main", Path: "lib dir"}) add := runBraid(t, env, downstream, braid, "add", upstream, localPath, "--path", "lib dir") assertExit(t, add, 0) @@ -52,6 +52,7 @@ func TestExecutablePrimaryLifecycle(t *testing.T) { assertExit(t, setup, 0) assertEmpty(t, "setup stdout", setup.stdout) assertProgress(t, setup.stderr, + "Braid: updated cache for mirror "+localPath, "Braid: setting up mirror remote "+localPath, "Braid: set up mirror remote "+localPath, ) @@ -66,6 +67,7 @@ func TestExecutablePrimaryLifecycle(t *testing.T) { assertExit(t, setupForce, 0) assertEmpty(t, "setup force stdout", setupForce.stdout) assertProgress(t, setupForce.stderr, + "Braid: updated cache for mirror "+localPath, "Braid: setting up mirror remote "+localPath, "Braid: set up mirror remote "+localPath, ) diff --git a/integration/no_commit_test.go b/integration/no_commit_test.go index b0eead1..bb7cda3 100644 --- a/integration/no_commit_test.go +++ b/integration/no_commit_test.go @@ -80,7 +80,7 @@ func TestExecutableNoCommitAddUpdateRemoveWorkflow(t *testing.T) { assertContains(t, remove.stdout, "Braid: staged removal of mirror 'vendor/basic'") assertPathMissing(t, downstream, "vendor/basic") assertConfigRaw(t, downstream, map[string]configMirror{}) - assertRemoteURL(t, env, downstream, remote, cachePath(env.braidCacheDir(), upstream)) + assertRemoteURL(t, env, downstream, remote, repositoryCachePath(t, downstream, "vendor/basic", configMirror{URL: upstream, Branch: "main"})) assertCachedNames(t, env, downstream, ".braids.json", "remove-staged.txt", "vendor/basic/README.md") if got := gitOutput(t, env, downstream, "rev-parse", "HEAD"); got != removeHead { t.Fatalf("HEAD = %s, want unchanged %s", got, removeHead) diff --git a/integration/setup_failure_test.go b/integration/setup_failure_test.go index e7b7587..79a2276 100644 --- a/integration/setup_failure_test.go +++ b/integration/setup_failure_test.go @@ -26,25 +26,24 @@ func TestExecutableSetupCacheModes(t *testing.T) { remote := remoteName("main", "vendor/repo") - defaultEnv := env.without("BRAID_LOCAL_CACHE_DIR") - setupDefault := runBraid(t, defaultEnv, downstream, braid, "--quiet", "setup", "vendor/repo") + setupDefault := runBraid(t, env, downstream, braid, "--quiet", "setup", "vendor/repo") assertResult(t, setupDefault, 0, "", "") - defaultCacheURL := cachePath(defaultEnv.defaultBraidCacheDir(), upstream) - assertRemoteURL(t, defaultEnv, downstream, remote, defaultCacheURL) - statusDefault := runBraid(t, defaultEnv, downstream, braid, "--quiet", "status", "vendor/repo") + defaultCacheURL := repositoryCachePath(t, downstream, "vendor/repo", configMirror{URL: upstream, Branch: "main"}) + assertRemoteURL(t, env, downstream, remote, defaultCacheURL) + statusDefault := runBraid(t, env, downstream, braid, "--quiet", "status", "vendor/repo") assertExit(t, statusDefault, 0) assertEmpty(t, "default cache status stderr", statusDefault.stderr) assertContains(t, statusDefault.stdout, "(Removed Locally)") assertPathExists(t, filepath.Join(defaultCacheURL, "HEAD")) - assertNoRemote(t, defaultEnv, downstream, remote) + assertNoRemote(t, env, downstream, remote) - envDisabled := defaultEnv.with("BRAID_USE_LOCAL_CACHE", "false") + envDisabled := env.with("BRAID_USE_LOCAL_CACHE", "false") setupDisabled := runBraid(t, envDisabled, downstream, braid, "--quiet", "setup", "vendor/repo") assertResult(t, setupDisabled, 0, "", "") assertRemoteURL(t, envDisabled, downstream, remote, upstream) gitOK(t, envDisabled, downstream, "remote", "rm", remote) - setupCacheDir := runBraid(t, envDisabled, downstream, braid, "--quiet", "--cache-dir", "explicit-cache", "setup", "vendor/repo") + setupCacheDir := runBraid(t, envDisabled, downstream, braid, "--quiet", "--global-cache-dir", "explicit-cache", "setup", "vendor/repo") assertResult(t, setupCacheDir, 0, "", "") assertRemoteURL(t, envDisabled, downstream, remote, cachePath(filepath.Join(processWorkingDir(t, downstream), "explicit-cache"), upstream)) @@ -53,10 +52,10 @@ func TestExecutableSetupCacheModes(t *testing.T) { assertResult(t, setupNoCache, 0, "", "") assertRemoteURL(t, env, downstream, remote, upstream) - invalid := runBraid(t, env, downstream, braid, "--no-cache", "--cache-dir", "cache", "setup") + invalid := runBraid(t, env, downstream, braid, "--no-cache", "--global-cache-dir", "cache", "setup") assertExit(t, invalid, 2) assertEmpty(t, "invalid cache stdout", invalid.stdout) - assertContains(t, invalid.stderr, "--no-cache and --cache-dir cannot be used together") + assertContains(t, invalid.stderr, "--no-cache and --global-cache-dir cannot be used together") } func TestExecutableFailurePaths(t *testing.T) { diff --git a/integration/subdirectory_test.go b/integration/subdirectory_test.go index d32bdba..93e6e9f 100644 --- a/integration/subdirectory_test.go +++ b/integration/subdirectory_test.go @@ -37,7 +37,7 @@ func TestExecutableSubdirectoryLifecycle(t *testing.T) { setup := runBraid(t, env, workDir, braid, "--quiet", "setup", "vendor/basic") assertResult(t, setup, 0, "", "") - assertRemoteURL(t, env, downstream, remote, cachePath(env.braidCacheDir(), upstream)) + assertRemoteURL(t, env, downstream, remote, repositoryCachePath(t, downstream, localPath, configMirror{URL: upstream, Branch: "main"})) status := runBraid(t, env, workDir, braid, "--quiet", "status", "vendor/basic") assertExit(t, status, 0) diff --git a/integration/support.go b/integration/support.go index 6f5f2e6..d077686 100644 --- a/integration/support.go +++ b/integration/support.go @@ -31,6 +31,7 @@ func expectedBraidVersion() string { type processEnv struct { values map[string]string + root string } func newProcessEnv(t *testing.T, root string) processEnv { @@ -56,23 +57,22 @@ func newProcessEnv(t *testing.T, root string) processEnv { } values := map[string]string{ - "APPDATA": filepath.Join(root, "appdata"), - "BRAID_LOCAL_CACHE_DIR": filepath.Join(root, "braid-cache"), - "GIT_AUTHOR_DATE": "2001-02-03T04:05:06Z", - "GIT_COMMITTER_DATE": "2001-02-03T04:05:06Z", - "GIT_CONFIG_GLOBAL": globalConfig, - "GIT_CONFIG_NOSYSTEM": "1", - "GIT_TERMINAL_PROMPT": "0", - "HOME": filepath.Join(root, "home"), - "LANG": "C", - "LC_ALL": "C", - "LOCALAPPDATA": filepath.Join(root, "local-appdata"), - "TEMP": filepath.Join(root, "tmp"), - "TMP": filepath.Join(root, "tmp"), - "TMPDIR": filepath.Join(root, "tmp"), - "USERPROFILE": filepath.Join(root, "userprofile"), - "XDG_CACHE_HOME": filepath.Join(root, "xdg-cache"), - "XDG_CONFIG_HOME": filepath.Join(root, "xdg-config"), + "APPDATA": filepath.Join(root, "appdata"), + "GIT_AUTHOR_DATE": "2001-02-03T04:05:06Z", + "GIT_COMMITTER_DATE": "2001-02-03T04:05:06Z", + "GIT_CONFIG_GLOBAL": globalConfig, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TERMINAL_PROMPT": "0", + "HOME": filepath.Join(root, "home"), + "LANG": "C", + "LC_ALL": "C", + "LOCALAPPDATA": filepath.Join(root, "local-appdata"), + "TEMP": filepath.Join(root, "tmp"), + "TMP": filepath.Join(root, "tmp"), + "TMPDIR": filepath.Join(root, "tmp"), + "USERPROFILE": filepath.Join(root, "userprofile"), + "XDG_CACHE_HOME": filepath.Join(root, "xdg-cache"), + "XDG_CONFIG_HOME": filepath.Join(root, "xdg-config"), } if runtime.GOOS == "windows" { if value, ok := os.LookupEnv("Path"); ok { @@ -88,7 +88,7 @@ func newProcessEnv(t *testing.T, root string) processEnv { } else if value, ok := os.LookupEnv("PATH"); ok { values["PATH"] = value } - return processEnv{values: values} + return processEnv{values: values, root: root} } func (e processEnv) list() []string { @@ -110,33 +110,7 @@ func (e processEnv) with(key, value string) processEnv { values[existingKey] = existingValue } values[key] = value - return processEnv{values: values} -} - -func (e processEnv) without(keys ...string) processEnv { - values := make(map[string]string, len(e.values)) - for existingKey, existingValue := range e.values { - values[existingKey] = existingValue - } - for _, key := range keys { - delete(values, key) - } - return processEnv{values: values} -} - -func (e processEnv) braidCacheDir() string { - return e.values["BRAID_LOCAL_CACHE_DIR"] -} - -func (e processEnv) defaultBraidCacheDir() string { - switch runtime.GOOS { - case "darwin": - return filepath.Join(e.values["HOME"], "Library", "Caches", "braid") - case "windows": - return filepath.Join(e.values["LOCALAPPDATA"], "braid") - default: - return filepath.Join(e.values["XDG_CACHE_HOME"], "braid") - } + return processEnv{values: values, root: e.root} } type commandResult struct { @@ -346,6 +320,24 @@ func cachePath(cacheDir, url string) string { return filepath.Join(cacheDir, hex.EncodeToString(sum[:])) } +func repositoryCachePath(t *testing.T, repo, localPath string, m configMirror) string { + t.Helper() + cacheDir := filepath.Join(processWorkingDir(t, repo), ".git", "braid", "cache") + return filepath.Join(cacheDir, mirrorCacheID(localPath, m)+".git") +} + +func mirrorCacheID(localPath string, m configMirror) string { + tracking := "revision" + if m.Branch != "" { + tracking = m.Branch + } else if m.Tag != "" { + tracking = m.Tag + } + parts := []string{m.URL, localPath, m.Path, tracking, m.Branch, m.Tag} + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + return hex.EncodeToString(sum[:]) +} + func remoteName(tracking, localPath string) string { var b strings.Builder for _, r := range tracking + "_braid_" + localPath { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index bb9b062..6ae2a13 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -25,11 +25,11 @@ const ( ) type GlobalOptions struct { - NoCache bool - CacheDir string - CacheDirSet bool - Verbose bool - Quiet bool + NoCache bool + GlobalCacheDir string + GlobalCacheDirSet bool + Verbose bool + Quiet bool } type AddOptions struct { @@ -221,8 +221,8 @@ func Parse(args []string) (Invocation, error) { if len(rest) == 0 { return inv, usageError("missing command") } - if inv.Global.NoCache && inv.Global.CacheDirSet { - return inv, usageError("--no-cache and --cache-dir cannot be used together") + if inv.Global.NoCache && inv.Global.GlobalCacheDirSet { + return inv, usageError("--no-cache and --global-cache-dir cannot be used together") } if inv.Global.Verbose && inv.Global.Quiet { return inv, usageError("--quiet and --verbose cannot be used together") @@ -291,23 +291,25 @@ func parseGlobal(args []string, global *GlobalOptions) ([]string, error) { case arg == "--quiet": global.Quiet = true i++ - case arg == "--cache-dir": + case arg == "--cache-dir" || strings.HasPrefix(arg, "--cache-dir="): + return nil, usageError("--cache-dir has been replaced by --global-cache-dir") + case arg == "--global-cache-dir": if i+1 >= len(args) { - return nil, usageError("--cache-dir requires a value") + return nil, usageError("--global-cache-dir requires a value") } if args[i+1] == "" { - return nil, usageError("--cache-dir requires a non-empty value") + return nil, usageError("--global-cache-dir requires a non-empty value") } - global.CacheDir = args[i+1] - global.CacheDirSet = true + global.GlobalCacheDir = args[i+1] + global.GlobalCacheDirSet = true i += 2 - case strings.HasPrefix(arg, "--cache-dir="): - value := strings.TrimPrefix(arg, "--cache-dir=") + case strings.HasPrefix(arg, "--global-cache-dir="): + value := strings.TrimPrefix(arg, "--global-cache-dir=") if value == "" { - return nil, usageError("--cache-dir requires a non-empty value") + return nil, usageError("--global-cache-dir requires a non-empty value") } - global.CacheDir = value - global.CacheDirSet = true + global.GlobalCacheDir = value + global.GlobalCacheDirSet = true i++ default: return args[i:], nil @@ -650,7 +652,7 @@ func usageError(format string, args ...interface{}) error { func Usage() string { return strings.TrimLeft(` -usage: braid [--verbose|-v | --quiet] [--no-cache | --cache-dir ] [options] +usage: braid [--verbose|-v | --quiet] [--no-cache | --global-cache-dir ] [options] commands: add Add a new mirror diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index f8a170e..62934bd 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -17,9 +17,9 @@ func TestParseCommands(t *testing.T) { }{ { name: "add branch mirror", - args: []string{"--verbose", "--cache-dir", ".cache", "add", "https://example.test/repo.git", "vendor/repo", "--branch", "main", "--path", "lib", "--no-commit"}, + args: []string{"--verbose", "--global-cache-dir", ".cache", "add", "https://example.test/repo.git", "vendor/repo", "--branch", "main", "--path", "lib", "--no-commit"}, want: Invocation{ - Global: GlobalOptions{CacheDir: ".cache", CacheDirSet: true, Verbose: true}, + Global: GlobalOptions{GlobalCacheDir: ".cache", GlobalCacheDirSet: true, Verbose: true}, Command: CommandAdd, Add: AddOptions{ URL: "https://example.test/repo.git", @@ -185,10 +185,12 @@ func TestParseUsageErrors(t *testing.T) { {name: "global verbose after command", args: []string{"add", "url", "--verbose"}, want: "unknown flag for add: --verbose"}, {name: "global verbose short after command", args: []string{"pull", "vendor/repo", "-v"}, want: "unknown flag for pull: -v"}, {name: "global quiet after command", args: []string{"add", "url", "--quiet"}, want: "unknown flag for add: --quiet"}, - {name: "cache flags conflict", args: []string{"--no-cache", "--cache-dir", "cache", "version"}, want: "--no-cache and --cache-dir cannot be used together"}, + {name: "global cache flags conflict", args: []string{"--no-cache", "--global-cache-dir", "cache", "version"}, want: "--no-cache and --global-cache-dir cannot be used together"}, + {name: "old cache dir flag", args: []string{"--cache-dir", "cache", "version"}, want: "--cache-dir has been replaced by --global-cache-dir"}, + {name: "old cache dir equals flag", args: []string{"--cache-dir=cache", "version"}, want: "--cache-dir has been replaced by --global-cache-dir"}, {name: "quiet verbose conflict", args: []string{"--quiet", "--verbose", "version"}, want: "--quiet and --verbose cannot be used together"}, {name: "verbose quiet conflict", args: []string{"--verbose", "--quiet", "version"}, want: "--quiet and --verbose cannot be used together"}, - {name: "empty cache dir", args: []string{"--cache-dir=", "version"}, want: "--cache-dir requires a non-empty value"}, + {name: "empty global cache dir", args: []string{"--global-cache-dir=", "version"}, want: "--global-cache-dir requires a non-empty value"}, {name: "add extra args", args: []string{"add", "url", "path", "extra"}, want: "add received extra argument(s)"}, {name: "tag branch conflict", args: []string{"add", "url", "--tag", "v1", "--branch", "main"}, want: "add cannot combine --tag and --branch"}, {name: "pull all strategy flag", args: []string{"pull", "--branch", "main"}, want: "pull without local_path cannot use --branch, --tag, or --revision"}, @@ -305,10 +307,10 @@ func TestHelpParsing(t *testing.T) { } func TestUsageDocumentsVerboseAsGlobalOnly(t *testing.T) { - if strings.Contains(Usage(), "usage: braid [--no-cache | --cache-dir ] [options]") { + if strings.Contains(Usage(), "usage: braid [--no-cache | --global-cache-dir ] [options]") { t.Fatalf("top-level usage still contains old global syntax:\n%s", Usage()) } - if !strings.Contains(Usage(), "usage: braid [--verbose|-v | --quiet] [--no-cache | --cache-dir ] [options]") { + if !strings.Contains(Usage(), "usage: braid [--verbose|-v | --quiet] [--no-cache | --global-cache-dir ] [options]") { t.Fatalf("top-level usage missing global output flag syntax:\n%s", Usage()) } if !strings.Contains(Usage(), " pull Pull one mirror or every eligible mirror") { diff --git a/internal/command/add.go b/internal/command/add.go index 85572ca..60f1492 100644 --- a/internal/command/add.go +++ b/internal/command/add.go @@ -95,7 +95,7 @@ func (h AddHandler) add(ctx context.Context, repo RepoContext, git AddGit, inv c return err } - cache, err := runtimeCache(inv.Global) + cache, err := runtimeCacheForRepo(ctx, repo, inv.Global, inv.Global.Verbose, trace) if err != nil { return err } @@ -119,11 +119,11 @@ func (h AddHandler) add(ctx context.Context, repo RepoContext, git AddGit, inv c return cause } - if err := fetchMirror(ctx, git, m, progress); err != nil { + if err := fetchMirror(ctx, git, cache, m, progress); err != nil { return cleanupRemote(err, "") } - revision, err := resolveAddRevision(ctx, git, m, addOptions.Revision) + revision, err := resolveAddRevision(ctx, git, m, cacheResolveRecordedRevision(cache, m, addOptions.Revision)) if err != nil { return cleanupRemote(err, "") } @@ -286,56 +286,6 @@ func lsFilesContainsExactPath(output, path string) bool { return false } -func fetchCache(ctx context.Context, cache CacheConfig, m mirror.Mirror, verbose bool, progress progressReporter, trace io.Writer) error { - return runProgress( - progress, - fmt.Sprintf("Braid: updating cache for mirror %s", m.Path), - fmt.Sprintf("Braid: updated cache for mirror %s", m.Path), - func() error { - cachePath := CachePath(cache.Dir, m.URL) - if _, err := os.Stat(filepath.Join(cachePath, ".git")); err == nil { - if err := os.RemoveAll(cachePath); err != nil { - return err - } - } else if err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - - if _, err := os.Stat(cachePath); err == nil { - return gitexec.New(cachePath, verbose, trace).Fetch(ctx) - } else if !errors.Is(err, os.ErrNotExist) { - return err - } - - if err := os.MkdirAll(cache.Dir, 0o755); err != nil { - return err - } - return gitexec.New(".", verbose, trace).CloneMirror(ctx, m.URL, cachePath) - }, - ) -} - -type fetchGit interface { - Fetch(context.Context, ...string) error -} - -func fetchMirror(ctx context.Context, git fetchGit, m mirror.Mirror, progress progressReporter) error { - return runProgress( - progress, - fmt.Sprintf("Braid: fetching mirror %s", m.Path), - fmt.Sprintf("Braid: fetched mirror %s", m.Path), - func() error { - if err := git.Fetch(ctx, "-n", m.Remote()); err != nil { - return err - } - if m.Tag != "" { - return git.Fetch(ctx, "-n", m.Remote(), "+refs/tags/"+m.Tag+":refs/tags/"+m.Tag) - } - return nil - }, - ) -} - type revParseGit interface { RevParse(context.Context, string) (string, error) } diff --git a/internal/command/add_test.go b/internal/command/add_test.go index 0262f5c..ec07196 100644 --- a/internal/command/add_test.go +++ b/internal/command/add_test.go @@ -178,7 +178,7 @@ func TestAddCommandGlobalVerboseTracesWorktreeAndCacheGit(t *testing.T) { repo := initDownstream(t) t.Setenv("HOME", t.TempDir()) - t.Setenv("BRAID_LOCAL_CACHE_DIR", filepath.Join(t.TempDir(), "braid-cache")) + t.Setenv("BRAID_GLOBAL_CACHE_DIR", filepath.Join(t.TempDir(), "braid-cache")) t.Chdir(repo) var stdout, stderr bytes.Buffer diff --git a/internal/command/cache.go b/internal/command/cache.go index 2a20fa8..9715fbf 100644 --- a/internal/command/cache.go +++ b/internal/command/cache.go @@ -1,38 +1,85 @@ package command import ( + "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "os" "path/filepath" "strings" + "time" "braid/internal/cli" + "braid/internal/gitexec" + "braid/internal/mirror" +) + +type CacheMode string + +const ( + CacheModeDisabled CacheMode = "disabled" + CacheModeRepositoryLocal CacheMode = "repository-local" + CacheModeGlobal CacheMode = "global" ) type CacheConfig struct { Enabled bool + Mode CacheMode Dir string } type EnvLookup func(string) (string, bool) -var userCacheDir = os.UserCacheDir +type MirrorObjectCache struct { + Config CacheConfig + Verbose bool + Trace ioWriter +} + +// Repository-local caches are per-mirror bare repositories owned by Braid. They +// are intentionally disposable: a fresh clone or a deleted .git/braid/cache tree +// can rebuild them only while the upstream can still serve the recorded commit. +// Full 40-hex revisions are fetched directly at depth 1; short or symbolic +// requested revisions force that mirror cache to a full fetch so resolution +// happens in the upstream/cache namespace rather than in downstream refs. +// +// A shallow bare cache cannot be fetched into the downstream repository unless +// Git is allowed to update downstream shallow metadata, so repo-local mirror +// fetches use --update-shallow. This keeps the cache shallow but can make the +// downstream repository report as shallow even though the shallow roots are +// Braid-owned mirror commits rather than the downstream branch history. + +type fetchGit interface { + Fetch(context.Context, ...string) error +} + +type ioWriter interface { + Write([]byte) (int, error) +} + +var ( + cacheLockTimeout = 30 * time.Second + cacheLockRetry = 50 * time.Millisecond +) func ResolveCache(global cli.GlobalOptions, lookup EnvLookup, cwd string) (CacheConfig, error) { - if global.NoCache && global.CacheDirSet { - return CacheConfig{}, fmt.Errorf("--no-cache and --cache-dir cannot be used together") + if _, ok := lookup("BRAID_LOCAL_CACHE_DIR"); ok { + return CacheConfig{}, fmt.Errorf("BRAID_LOCAL_CACHE_DIR has been replaced by BRAID_GLOBAL_CACHE_DIR") + } + if global.NoCache && global.GlobalCacheDirSet { + return CacheConfig{}, fmt.Errorf("--no-cache and --global-cache-dir cannot be used together") } if global.NoCache { - return CacheConfig{Enabled: false}, nil + return CacheConfig{Enabled: false, Mode: CacheModeDisabled}, nil } - if global.CacheDirSet { - dir, err := absolutePath(global.CacheDir, cwd) + if global.GlobalCacheDirSet { + dir, err := absolutePath(global.GlobalCacheDir, cwd) if err != nil { return CacheConfig{}, err } - return CacheConfig{Enabled: true, Dir: dir}, nil + return CacheConfig{Enabled: true, Mode: CacheModeGlobal, Dir: dir}, nil } enabled := true @@ -40,24 +87,38 @@ func ResolveCache(global cli.GlobalOptions, lookup EnvLookup, cwd string) (Cache enabled = false } if !enabled { - return CacheConfig{Enabled: false}, nil + return CacheConfig{Enabled: false, Mode: CacheModeDisabled}, nil } - var dir string - if value, ok := lookup("BRAID_LOCAL_CACHE_DIR"); ok { - dir = value - } else if cacheRoot, err := userCacheDir(); err == nil && cacheRoot != "" { - dir = filepath.Join(cacheRoot, "braid") - } else if home, ok := homeDir(lookup); ok { - dir = filepath.Join(home, ".braid", "cache") - } else { - dir = filepath.Join(".braid", "cache") + if value, ok := lookup("BRAID_GLOBAL_CACHE_DIR"); ok { + expanded, err := absolutePath(expandHome(value, lookup), cwd) + if err != nil { + return CacheConfig{}, err + } + return CacheConfig{Enabled: true, Mode: CacheModeGlobal, Dir: expanded}, nil } - expanded, err := absolutePath(expandHome(dir, lookup), cwd) + + return CacheConfig{Enabled: true, Mode: CacheModeRepositoryLocal}, nil +} + +func ResolveRepositoryCache(ctx context.Context, repo RepoContext, global cli.GlobalOptions, lookup EnvLookup, cwd string, verbose bool, trace ioWriter) (CacheConfig, error) { + cache, err := ResolveCache(global, lookup, cwd) + if err != nil { + return CacheConfig{}, err + } + if !cache.Enabled || cache.Mode != CacheModeRepositoryLocal { + return cache, nil + } + gitPath, err := gitexec.New(repo.GitWorkTreeRoot, verbose, trace).RepoFilePath(ctx, "braid/cache") + if err != nil { + return CacheConfig{}, err + } + dir, err := gitRepoOSPath(gitPath, repo.GitWorkTreeRoot) if err != nil { return CacheConfig{}, err } - return CacheConfig{Enabled: true, Dir: expanded}, nil + cache.Dir = dir + return cache, nil } func CachePath(cacheDir, url string) string { @@ -65,6 +126,23 @@ func CachePath(cacheDir, url string) string { return filepath.Join(cacheDir, hex.EncodeToString(sum[:])) } +func RepositoryCachePath(cacheDir string, m mirror.Mirror) string { + return filepath.Join(cacheDir, MirrorCacheID(m)+".git") +} + +func MirrorCacheID(m mirror.Mirror) string { + parts := []string{ + m.URL, + m.Path, + m.RemotePath, + m.TrackingName(), + m.Branch, + m.Tag, + } + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + return hex.EncodeToString(sum[:]) +} + func runtimeCache(global cli.GlobalOptions) (CacheConfig, error) { cwd, err := currentWorkingDir() if err != nil { @@ -73,6 +151,339 @@ func runtimeCache(global cli.GlobalOptions) (CacheConfig, error) { return ResolveCache(global, os.LookupEnv, cwd) } +func runtimeCacheForRepo(ctx context.Context, repo RepoContext, global cli.GlobalOptions, verbose bool, trace ioWriter) (CacheConfig, error) { + cwd, err := currentWorkingDir() + if err != nil { + return CacheConfig{}, err + } + return ResolveRepositoryCache(ctx, repo, global, os.LookupEnv, cwd, verbose, trace) +} + +func (cache CacheConfig) RemoteURL(m mirror.Mirror) string { + if !cache.Enabled { + return m.URL + } + switch cache.Mode { + case CacheModeGlobal: + return CachePath(cache.Dir, m.URL) + case CacheModeRepositoryLocal: + return RepositoryCachePath(cache.Dir, m) + default: + return m.URL + } +} + +func (cache CacheConfig) RecordedRef(m mirror.Mirror) string { + return "refs/braid/recorded/" + MirrorCacheID(m) +} + +func (cache CacheConfig) RequestedRef(m mirror.Mirror) string { + return "refs/braid/requested/" + MirrorCacheID(m) +} + +func (cache CacheConfig) TipRef(m mirror.Mirror) string { + return "refs/braid/tip/" + MirrorCacheID(m) +} + +func cacheResolveRecordedRevision(cache CacheConfig, m mirror.Mirror, requested string) string { + if requested != "" && cache.Enabled && cache.Mode == CacheModeRepositoryLocal { + return cache.RecordedRef(m) + } + return requested +} + +func cacheResolveRequestedRevision(cache CacheConfig, m mirror.Mirror, requested string) string { + if requested != "" && cache.Enabled && cache.Mode == CacheModeRepositoryLocal { + return cache.RequestedRef(m) + } + return requested +} + +func (cache MirrorObjectCache) Hydrate(ctx context.Context, m mirror.Mirror, extraRevisions ...string) error { + if !cache.Config.Enabled { + return nil + } + switch cache.Config.Mode { + case CacheModeGlobal: + return cache.hydrateGlobal(ctx, m) + case CacheModeRepositoryLocal: + return cache.hydrateRepositoryLocal(ctx, m, extraRevisions...) + default: + return nil + } +} + +func fetchCache(ctx context.Context, cache CacheConfig, m mirror.Mirror, verbose bool, progress progressReporter, trace ioWriter, extraRevisions ...string) error { + if !cache.Enabled { + return nil + } + return runProgress( + progress, + fmt.Sprintf("Braid: updating cache for mirror %s", m.Path), + fmt.Sprintf("Braid: updated cache for mirror %s", m.Path), + func() error { + return MirrorObjectCache{Config: cache, Verbose: verbose, Trace: trace}.Hydrate(ctx, m, extraRevisions...) + }, + ) +} + +func fetchMirror(ctx context.Context, git fetchGit, cache CacheConfig, m mirror.Mirror, progress progressReporter) error { + return runProgress( + progress, + fmt.Sprintf("Braid: fetching mirror %s", m.Path), + fmt.Sprintf("Braid: fetched mirror %s", m.Path), + func() error { + if cache.Enabled && cache.Mode == CacheModeRepositoryLocal { + args := []string{"--update-shallow", "-n", m.Remote()} + if m.Branch != "" { + args = append(args, "+refs/heads/"+m.Branch+":refs/remotes/"+m.Remote()+"/"+m.Branch) + } + if m.Tag != "" { + args = append(args, "+refs/tags/"+m.Tag+":refs/tags/"+m.Tag) + } + args = append(args, "+refs/braid/*:refs/braid/*") + return git.Fetch(ctx, args...) + } + if err := git.Fetch(ctx, "-n", m.Remote()); err != nil { + return err + } + if m.Tag != "" { + return git.Fetch(ctx, "-n", m.Remote(), "+refs/tags/"+m.Tag+":refs/tags/"+m.Tag) + } + return nil + }, + ) +} + +func (cache MirrorObjectCache) hydrateGlobal(ctx context.Context, m mirror.Mirror) error { + cachePath := CachePath(cache.Config.Dir, m.URL) + if _, err := os.Stat(filepath.Join(cachePath, ".git")); err == nil { + if err := os.RemoveAll(cachePath); err != nil { + return err + } + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + if _, err := os.Stat(cachePath); err == nil { + return gitexec.New(cachePath, cache.Verbose, cache.Trace).Fetch(ctx) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if err := os.MkdirAll(cache.Config.Dir, 0o755); err != nil { + return err + } + return gitexec.New(".", cache.Verbose, cache.Trace).CloneMirror(ctx, m.URL, cachePath) +} + +func (cache MirrorObjectCache) hydrateRepositoryLocal(ctx context.Context, m mirror.Mirror, extraRevisions ...string) error { + cachePath := RepositoryCachePath(cache.Config.Dir, m) + lockPath := cachePath + ".lock" + release, err := acquireCacheLock(ctx, lockPath, m.Path) + if err != nil { + return err + } + defer release() + + if err := cache.ensureRepositoryLocalCache(ctx, cachePath); err != nil { + return err + } + cacheGit := gitexec.New(cachePath, cache.Verbose, cache.Trace) + full := false + + recordedRevision := strings.TrimSpace(m.Revision) + if recordedRevision != "" { + if isFullObjectID(recordedRevision) { + if err := cache.fetchFullObjectID(ctx, cacheGit, m.URL, recordedRevision, cache.Config.RecordedRef(m)); err != nil { + full = true + if err := cache.fetchFullMirror(ctx, cachePath, cacheGit, m); err != nil { + return err + } + } + } else { + full = true + if err := cache.fetchFullMirror(ctx, cachePath, cacheGit, m); err != nil { + return err + } + } + resolved, err := cacheGit.RevParse(ctx, recordedRevision+"^{commit}") + if err != nil { + return unavailableRecordedRevisionError(m, recordedRevision) + } + if err := cacheGit.UpdateRef(ctx, cache.Config.RecordedRef(m), resolved); err != nil { + return err + } + } + + for _, revision := range extraRevisions { + revision = strings.TrimSpace(revision) + if revision == "" { + continue + } + if isFullObjectID(revision) { + if err := cache.fetchFullObjectID(ctx, cacheGit, m.URL, revision, cache.Config.RequestedRef(m)); err != nil { + full = true + if err := cache.fetchFullMirror(ctx, cachePath, cacheGit, m); err != nil { + return err + } + } + } else if !full { + full = true + if err := cache.fetchFullMirror(ctx, cachePath, cacheGit, m); err != nil { + return err + } + } + resolved, err := cacheGit.RevParse(ctx, revision+"^{commit}") + if err != nil { + return unavailableRecordedRevisionError(m, revision) + } + if err := cacheGit.UpdateRef(ctx, cache.Config.RequestedRef(m), resolved); err != nil { + return err + } + } + + switch { + case m.Branch != "": + if !full { + if err := cacheGit.Fetch(ctx, "--depth=1", m.URL, "+refs/heads/"+m.Branch+":refs/heads/"+m.Branch); err != nil { + return err + } + } + resolved, err := cacheGit.RevParse(ctx, "refs/heads/"+m.Branch+"^{commit}") + if err != nil { + return err + } + return cacheGit.UpdateRef(ctx, cache.Config.TipRef(m), resolved) + case m.Tag != "": + if !full { + if err := cacheGit.Fetch(ctx, "--depth=1", m.URL, "+refs/tags/"+m.Tag+":refs/tags/"+m.Tag); err != nil { + return err + } + } + resolved, err := cacheGit.RevParse(ctx, "refs/tags/"+m.Tag+"^{commit}") + if err != nil { + return err + } + return cacheGit.UpdateRef(ctx, cache.Config.TipRef(m), resolved) + } + return nil +} + +func (cache MirrorObjectCache) ensureRepositoryLocalCache(ctx context.Context, cachePath string) error { + if info, err := os.Stat(cachePath); err == nil { + replace := !info.IsDir() + if !replace { + if _, err := os.Stat(filepath.Join(cachePath, ".git")); err == nil { + replace = true + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + if !replace { + bare, err := isBareRepository(ctx, cachePath, cache.Verbose, cache.Trace) + if err == nil && bare { + return nil + } + replace = true + } + if replace { + if err := os.RemoveAll(cachePath); err != nil { + return err + } + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + return err + } + tempPath, err := os.MkdirTemp(filepath.Dir(cachePath), ".tmp-"+filepath.Base(cachePath)+"-") + if err != nil { + return err + } + cleanup := true + defer func() { + if cleanup { + _ = os.RemoveAll(tempPath) + } + }() + if err := gitexec.New(".", cache.Verbose, cache.Trace).InitBare(ctx, tempPath); err != nil { + return err + } + if err := os.Rename(tempPath, cachePath); err != nil { + return err + } + cleanup = false + return nil +} + +func isBareRepository(ctx context.Context, path string, verbose bool, trace ioWriter) (bool, error) { + out, err := gitexec.New(path, verbose, trace).Output(ctx, "rev-parse", "--is-bare-repository") + if err != nil { + return false, err + } + return out == "true", nil +} + +func (cache MirrorObjectCache) fetchFullObjectID(ctx context.Context, git gitexec.Git, url, objectID, ref string) error { + return git.Fetch(ctx, "--depth=1", url, objectID+":"+ref) +} + +func (cache MirrorObjectCache) fetchFullMirror(ctx context.Context, cachePath string, git gitexec.Git, m mirror.Mirror) error { + args := []string{"--prune"} + if _, err := os.Stat(filepath.Join(cachePath, "shallow")); err == nil { + args = append(args, "--unshallow") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + args = append(args, m.URL, "+refs/*:refs/*") + return git.Fetch(ctx, args...) +} + +func acquireCacheLock(ctx context.Context, lockPath, mirrorPath string) (func(), error) { + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return nil, err + } + deadline := time.NewTimer(cacheLockTimeout) + defer deadline.Stop() + ticker := time.NewTicker(cacheLockRetry) + defer ticker.Stop() + + for { + if err := os.Mkdir(lockPath, 0o700); err == nil { + return func() { _ = os.RemoveAll(lockPath) }, nil + } else if !errors.Is(err, os.ErrExist) { + return nil, err + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-deadline.C: + return nil, fmt.Errorf("timed out waiting for cache lock for mirror %s at %s; another Braid process may be updating the cache", mirrorPath, lockPath) + case <-ticker.C: + } + } +} + +func unavailableRecordedRevisionError(m mirror.Mirror, revision string) error { + return fmt.Errorf("recorded revision %s for mirror %s is unavailable from upstream %s; the repository-local cache may have been deleted or the upstream history may have been rewritten", revision, m.Path, m.URL) +} + +func isFullObjectID(value string) bool { + if len(value) != 40 { + return false + } + for _, r := range value { + if r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F' { + continue + } + return false + } + return true +} + func currentWorkingDir() (string, error) { cwd, err := os.Getwd() if err != nil { diff --git a/internal/command/cache_test.go b/internal/command/cache_test.go index 5c2a809..cf11405 100644 --- a/internal/command/cache_test.go +++ b/internal/command/cache_test.go @@ -1,35 +1,32 @@ package command import ( - "errors" + "context" "os" "path/filepath" "strings" "testing" + "time" "braid/internal/cli" + "braid/internal/gitexec" + "braid/internal/mirror" + "braid/internal/testutil" ) -func TestResolveCacheUsesUserCacheDirByDefault(t *testing.T) { - cacheRoot := filepath.Join(t.TempDir(), "user-cache") - withUserCacheDir(t, cacheRoot, nil) - +func TestResolveCacheUsesRepositoryLocalModeByDefault(t *testing.T) { got, err := ResolveCache(cli.GlobalOptions{}, envLookup(nil), t.TempDir()) if err != nil { t.Fatalf("ResolveCache returned error: %v", err) } - if !got.Enabled { - t.Fatal("cache Enabled = false, want true") - } - if want := filepath.Join(cacheRoot, "braid"); got.Dir != want { - t.Fatalf("cache Dir = %q, want %q", got.Dir, want) + want := CacheConfig{Enabled: true, Mode: CacheModeRepositoryLocal} + if got != want { + t.Fatalf("ResolveCache = %#v, want %#v", got, want) } } func TestResolveCachePrecedence(t *testing.T) { cwd := t.TempDir() - cacheRoot := filepath.Join(t.TempDir(), "user-cache") - withUserCacheDir(t, cacheRoot, nil) tests := []struct { name string @@ -41,34 +38,50 @@ func TestResolveCachePrecedence(t *testing.T) { { name: "no cache disables cache", global: cli.GlobalOptions{NoCache: true}, - env: map[string]string{"BRAID_LOCAL_CACHE_DIR": "ignored"}, - want: CacheConfig{Enabled: false}, + env: map[string]string{"BRAID_GLOBAL_CACHE_DIR": "ignored"}, + want: CacheConfig{Enabled: false, Mode: CacheModeDisabled}, }, { - name: "no cache conflicts with cache dir", - global: cli.GlobalOptions{NoCache: true, CacheDir: "cache", CacheDirSet: true}, - wantErr: "--no-cache and --cache-dir cannot be used together", + name: "no cache conflicts with global cache dir", + global: cli.GlobalOptions{NoCache: true, GlobalCacheDir: "cache", GlobalCacheDirSet: true}, + wantErr: "--no-cache and --global-cache-dir cannot be used together", }, { - name: "cache dir flag overrides disabling env", - global: cli.GlobalOptions{CacheDir: "flag-cache", CacheDirSet: true}, - env: map[string]string{"BRAID_USE_LOCAL_CACHE": "false", "BRAID_LOCAL_CACHE_DIR": "ignored"}, - want: CacheConfig{Enabled: true, Dir: filepath.Join(cwd, "flag-cache")}, + name: "global cache dir flag overrides disabling env", + global: cli.GlobalOptions{GlobalCacheDir: "flag-cache", GlobalCacheDirSet: true}, + env: map[string]string{"BRAID_USE_LOCAL_CACHE": "false"}, + want: CacheConfig{Enabled: true, Mode: CacheModeGlobal, Dir: filepath.Join(cwd, "flag-cache")}, }, { name: "use local cache false disables cache", env: map[string]string{"BRAID_USE_LOCAL_CACHE": "false"}, - want: CacheConfig{Enabled: false}, + want: CacheConfig{Enabled: false, Mode: CacheModeDisabled}, }, { - name: "env cache dir overrides user cache dir", - env: map[string]string{"BRAID_LOCAL_CACHE_DIR": "env-cache"}, - want: CacheConfig{Enabled: true, Dir: filepath.Join(cwd, "env-cache")}, + name: "global env cache dir overrides user cache dir", + env: map[string]string{"BRAID_GLOBAL_CACHE_DIR": "env-cache"}, + want: CacheConfig{Enabled: true, Mode: CacheModeGlobal, Dir: filepath.Join(cwd, "env-cache")}, }, { name: "backslash home expansion", - env: map[string]string{"HOME": filepath.Join(cwd, "home"), "BRAID_LOCAL_CACHE_DIR": `~\braid-cache`}, - want: CacheConfig{Enabled: true, Dir: filepath.Join(cwd, "home", "braid-cache")}, + env: map[string]string{"HOME": filepath.Join(cwd, "home"), "BRAID_GLOBAL_CACHE_DIR": `~\braid-cache`}, + want: CacheConfig{Enabled: true, Mode: CacheModeGlobal, Dir: filepath.Join(cwd, "home", "braid-cache")}, + }, + { + name: "old local cache env errors", + env: map[string]string{"BRAID_LOCAL_CACHE_DIR": "old-cache"}, + wantErr: "BRAID_LOCAL_CACHE_DIR has been replaced by BRAID_GLOBAL_CACHE_DIR", + }, + { + name: "old local cache env errors with no cache", + global: cli.GlobalOptions{NoCache: true}, + env: map[string]string{"BRAID_LOCAL_CACHE_DIR": "old-cache"}, + wantErr: "BRAID_LOCAL_CACHE_DIR has been replaced by BRAID_GLOBAL_CACHE_DIR", + }, + { + name: "global env cache dir disabled by use local cache false", + env: map[string]string{"BRAID_USE_LOCAL_CACHE": "false", "BRAID_GLOBAL_CACHE_DIR": "env-cache"}, + want: CacheConfig{Enabled: false, Mode: CacheModeDisabled}, }, } @@ -91,17 +104,16 @@ func TestResolveCachePrecedence(t *testing.T) { } } -func TestResolveCacheFallsBackWhenUserCacheDirUnavailable(t *testing.T) { - withUserCacheDir(t, "", errors.New("unavailable")) - cwd := t.TempDir() - home := filepath.Join(cwd, "home") +func TestResolveRepositoryCacheUsesGitPath(t *testing.T) { + repo := testutil.InitRepo(t) - got, err := ResolveCache(cli.GlobalOptions{}, envLookup(map[string]string{"USERPROFILE": home}), cwd) + got, err := ResolveRepositoryCache(context.Background(), testRepoContext(repo, nil), cli.GlobalOptions{}, envLookup(nil), t.TempDir(), false, nil) if err != nil { - t.Fatalf("ResolveCache returned error: %v", err) + t.Fatalf("ResolveRepositoryCache returned error: %v", err) } - if want := filepath.Join(home, ".braid", "cache"); got.Dir != want { - t.Fatalf("cache Dir = %q, want %q", got.Dir, want) + want := CacheConfig{Enabled: true, Mode: CacheModeRepositoryLocal, Dir: filepath.Join(repo, ".git", "braid", "cache")} + if got != want { + t.Fatalf("ResolveRepositoryCache = %#v, want %#v", got, want) } } @@ -117,7 +129,7 @@ func TestRuntimeCacheUsesCanonicalWorkingDirForRelativeCacheDir(t *testing.T) { } t.Chdir(linkDir) - got, err := runtimeCache(cli.GlobalOptions{CacheDir: "cache", CacheDirSet: true}) + got, err := runtimeCache(cli.GlobalOptions{GlobalCacheDir: "cache", GlobalCacheDirSet: true}) if err != nil { t.Fatalf("runtimeCache returned error: %v", err) } @@ -125,7 +137,7 @@ func TestRuntimeCacheUsesCanonicalWorkingDirForRelativeCacheDir(t *testing.T) { if err != nil { t.Fatalf("canonicalize real dir: %v", err) } - want := CacheConfig{Enabled: true, Dir: filepath.Join(canonicalRealDir, "cache")} + want := CacheConfig{Enabled: true, Mode: CacheModeGlobal, Dir: filepath.Join(canonicalRealDir, "cache")} if got != want { t.Fatalf("runtimeCache = %#v, want %#v", got, want) } @@ -149,3 +161,188 @@ func TestCachePathUsesStableWindowsSafeHashKey(t *testing.T) { t.Fatalf("cache key %q contains Windows-invalid filename characters", key) } } + +func TestMirrorCacheIDUsesStableWindowsSafeKey(t *testing.T) { + m := mirror.Mirror{ + URL: `https://example.test/repo?name=bad*chars|"<>@\windows\path`, + Path: `vendor\repo`, + RemotePath: `pkg/lib`, + Branch: "main", + } + + got := MirrorCacheID(m) + gotAgain := MirrorCacheID(m) + + if got != gotAgain { + t.Fatalf("MirrorCacheID is not stable: %q then %q", got, gotAgain) + } + if len(got) != 64 { + t.Fatalf("cache id length = %d, want 64", len(got)) + } + if strings.ContainsAny(got, "<>:\"/\\|?*") { + t.Fatalf("cache id %q contains Windows-invalid filename characters", got) + } + + changedPath := m + changedPath.Path = "vendor/other" + if other := MirrorCacheID(changedPath); other == got { + t.Fatalf("MirrorCacheID did not change when mirror path changed") + } + + changedRemotePath := m + changedRemotePath.RemotePath = "cmd/app" + if other := MirrorCacheID(changedRemotePath); other == got { + t.Fatalf("MirrorCacheID did not change when remote path changed") + } +} + +func TestRepositoryLocalHydrateBranchPinsRefs(t *testing.T) { + ctx := context.Background() + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "README.md", "base\n") + revision := testutil.CommitAll(t, upstream, "base") + repo := testutil.InitRepo(t) + cache := repositoryLocalCacheForTest(t, ctx, repo) + m := mirror.Mirror{Path: "vendor/basic", URL: upstream, Branch: "main", Revision: revision} + + if err := (MirrorObjectCache{Config: cache}).Hydrate(ctx, m); err != nil { + t.Fatalf("Hydrate returned error: %v", err) + } + + cachePath := RepositoryCachePath(cache.Dir, m) + assertPathIsDir(t, cachePath) + cacheGit := gitexec.New(cachePath, false, nil) + assertRefCommit(t, ctx, cacheGit, "refs/heads/main", revision) + assertRefCommit(t, ctx, cacheGit, cache.RecordedRef(m), revision) + assertRefCommit(t, ctx, cacheGit, cache.TipRef(m), revision) +} + +func TestRepositoryLocalHydrateShortRevisionUsesFullFallback(t *testing.T) { + ctx := context.Background() + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "README.md", "base\n") + revision := testutil.CommitAll(t, upstream, "base") + repo := testutil.InitRepo(t) + cache := repositoryLocalCacheForTest(t, ctx, repo) + m := mirror.Mirror{Path: "vendor/revision", URL: upstream, Revision: revision[:12]} + + if err := (MirrorObjectCache{Config: cache}).Hydrate(ctx, m); err != nil { + t.Fatalf("Hydrate returned error: %v", err) + } + + cachePath := RepositoryCachePath(cache.Dir, m) + cacheGit := gitexec.New(cachePath, false, nil) + assertRefCommit(t, ctx, cacheGit, cache.RecordedRef(m), revision) + if _, err := os.Stat(filepath.Join(cachePath, "shallow")); err == nil { + t.Fatalf("repository-local cache stayed shallow after short revision hydration") + } else if err != nil && !os.IsNotExist(err) { + t.Fatalf("stat shallow file: %v", err) + } +} + +func TestRepositoryLocalHydrateReplacesFileCachePath(t *testing.T) { + ctx := context.Background() + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "README.md", "base\n") + revision := testutil.CommitAll(t, upstream, "base") + repo := testutil.InitRepo(t) + cache := repositoryLocalCacheForTest(t, ctx, repo) + m := mirror.Mirror{Path: "vendor/basic", URL: upstream, Branch: "main", Revision: revision} + cachePath := RepositoryCachePath(cache.Dir, m) + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + t.Fatalf("create cache parent: %v", err) + } + if err := os.WriteFile(cachePath, []byte("not a repository\n"), 0o644); err != nil { + t.Fatalf("write cache path file: %v", err) + } + + if err := (MirrorObjectCache{Config: cache}).Hydrate(ctx, m); err != nil { + t.Fatalf("Hydrate returned error: %v", err) + } + + assertPathIsDir(t, cachePath) + cacheGit := gitexec.New(cachePath, false, nil) + assertRefCommit(t, ctx, cacheGit, "refs/heads/main", revision) + assertRefCommit(t, ctx, cacheGit, cache.RecordedRef(m), revision) + assertRefCommit(t, ctx, cacheGit, cache.TipRef(m), revision) +} + +func TestFetchMirrorFromRepositoryLocalCacheFetchesBraidRefs(t *testing.T) { + ctx := context.Background() + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "README.md", "base\n") + revision := testutil.CommitAll(t, upstream, "base") + repo := testutil.InitRepo(t) + cache := repositoryLocalCacheForTest(t, ctx, repo) + m := mirror.Mirror{Path: "vendor/basic", URL: upstream, Branch: "main", Revision: revision} + + if err := (MirrorObjectCache{Config: cache}).Hydrate(ctx, m); err != nil { + t.Fatalf("Hydrate returned error: %v", err) + } + git := gitexec.New(repo, false, nil) + if err := setupOne(ctx, git, m, true, cache); err != nil { + t.Fatalf("setupOne returned error: %v", err) + } + if err := fetchMirror(ctx, git, cache, m, progressReporter{}); err != nil { + t.Fatalf("fetchMirror returned error: %v", err) + } + + assertRefCommit(t, ctx, git, m.Remote()+"/main", revision) + assertRefCommit(t, ctx, git, cache.RecordedRef(m), revision) + if shallow, err := git.Output(ctx, "rev-parse", "--is-shallow-repository"); err != nil { + t.Fatalf("check shallow repository: %v", err) + } else if shallow != "true" { + t.Fatalf("downstream shallow state = %q, want true after fetching from shallow cache", shallow) + } +} + +func TestAcquireCacheLockTimesOut(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "mirror.git.lock") + if err := os.Mkdir(lockPath, 0o700); err != nil { + t.Fatalf("create lock: %v", err) + } + oldTimeout := cacheLockTimeout + oldRetry := cacheLockRetry + cacheLockTimeout = 10 * time.Millisecond + cacheLockRetry = time.Millisecond + t.Cleanup(func() { + cacheLockTimeout = oldTimeout + cacheLockRetry = oldRetry + }) + + _, err := acquireCacheLock(context.Background(), lockPath, "vendor/basic") + if err == nil || !strings.Contains(err.Error(), "timed out waiting for cache lock for mirror vendor/basic") { + t.Fatalf("acquireCacheLock error = %v, want timeout diagnostic", err) + } +} + +func repositoryLocalCacheForTest(t *testing.T, ctx context.Context, repo string) CacheConfig { + t.Helper() + cache, err := ResolveRepositoryCache(ctx, testRepoContext(repo, nil), cli.GlobalOptions{}, envLookup(nil), t.TempDir(), false, nil) + if err != nil { + t.Fatalf("ResolveRepositoryCache returned error: %v", err) + } + return cache +} + +func assertRefCommit(t *testing.T, ctx context.Context, git gitexec.Git, ref, want string) { + t.Helper() + got, err := git.RevParse(ctx, ref+"^{commit}") + if err != nil { + t.Fatalf("rev-parse %s: %v", ref, err) + } + if got != want { + t.Fatalf("%s = %s, want %s", ref, got, want) + } +} + +func assertPathIsDir(t *testing.T, path string) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + if !info.IsDir() { + t.Fatalf("%s is not a directory", path) + } +} diff --git a/internal/command/completion.go b/internal/command/completion.go index 4b4aa0b..7e7cc12 100644 --- a/internal/command/completion.go +++ b/internal/command/completion.go @@ -63,7 +63,7 @@ var globalCompletionFlags = []completionFlag{ {long: "--verbose", short: "-v"}, {long: "--quiet"}, {long: "--no-cache"}, - {long: "--cache-dir", value: true}, + {long: "--global-cache-dir", value: true}, } var commandCompletionFlags = map[string][]completionFlag{ @@ -153,14 +153,14 @@ func (h CompleteHandler) completeBash(ctx context.Context, words []string) []str func (h CompleteHandler) completeRoot(line completionLine) []string { if flag, ok := valueFlagAwaitingCurrent(line.globalArgs, globalCompletionFlags); ok { - if flag.long == "--cache-dir" { + if flag.long == "--global-cache-dir" { return pathCandidates(h.Options.WorkDir, line.current, true, "") } return nil } - if strings.HasPrefix(line.current, "--cache-dir=") { - value := strings.TrimPrefix(line.current, "--cache-dir=") - return pathCandidates(h.Options.WorkDir, value, true, "--cache-dir=") + if strings.HasPrefix(line.current, "--global-cache-dir=") { + value := strings.TrimPrefix(line.current, "--global-cache-dir=") + return pathCandidates(h.Options.WorkDir, value, true, "--global-cache-dir=") } var candidates []string @@ -328,8 +328,8 @@ func suppressFlag(flag completionFlag, args []string, global bool) bool { case "--verbose": return flagPresent(completionFlag{long: "--quiet"}, args) case "--no-cache": - return flagPresent(completionFlag{long: "--cache-dir", value: true}, args) - case "--cache-dir": + return flagPresent(completionFlag{long: "--global-cache-dir", value: true}, args) + case "--global-cache-dir": return flagPresent(completionFlag{long: "--no-cache"}, args) default: return false diff --git a/internal/command/completion_test.go b/internal/command/completion_test.go index 61a7cc5..3d1643a 100644 --- a/internal/command/completion_test.go +++ b/internal/command/completion_test.go @@ -40,7 +40,7 @@ func TestCompleteRootCommandsAndGlobalOptions(t *testing.T) { assertCandidate(t, candidates, "--verbose") assertCandidate(t, candidates, "--quiet") assertCandidate(t, candidates, "--no-cache") - assertCandidate(t, candidates, "--cache-dir") + assertCandidate(t, candidates, "--global-cache-dir") candidates = completeCandidates(t, dir, "--verbose", "") assertNoCandidate(t, candidates, "--verbose") @@ -48,9 +48,9 @@ func TestCompleteRootCommandsAndGlobalOptions(t *testing.T) { assertNoCandidate(t, candidates, "--quiet") assertCandidate(t, candidates, "--no-cache") - candidates = completeCandidates(t, dir, "--cache-dir", "cache", "") + candidates = completeCandidates(t, dir, "--global-cache-dir", "cache", "") assertNoCandidate(t, candidates, "--no-cache") - assertCandidate(t, candidates, "--cache-dir") + assertCandidate(t, candidates, "--global-cache-dir") } func TestCompleteCommandOptions(t *testing.T) { @@ -169,11 +169,11 @@ func TestCompleteFilesystemContexts(t *testing.T) { t.Fatalf("write README: %v", err) } - candidates := completeCandidates(t, dir, "--cache-dir", "ca") + candidates := completeCandidates(t, dir, "--global-cache-dir", "ca") assertCandidate(t, candidates, "cache/") - candidates = completeCandidates(t, dir, "--cache-dir=ca") - assertCandidate(t, candidates, "--cache-dir=cache/") + candidates = completeCandidates(t, dir, "--global-cache-dir=ca") + assertCandidate(t, candidates, "--global-cache-dir=cache/") candidates = completeCandidates(t, dir, "add", "https://example.test/repo.git", "ven") assertCandidate(t, candidates, "vendor/") diff --git a/internal/command/diff.go b/internal/command/diff.go index 2bb5000..023b295 100644 --- a/internal/command/diff.go +++ b/internal/command/diff.go @@ -33,7 +33,7 @@ func (h DiffHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { if err := validateConfigPaths(cfg); err != nil { return err } - cache, err := runtimeCache(inv.Global) + cache, err := runtimeCacheForRepo(ctx, repo, inv.Global, inv.Global.Verbose, stderr) if err != nil { return err } @@ -118,7 +118,7 @@ func fetchBaseRevisionIfMissing(ctx context.Context, git DiffGit, cache CacheCon return err } } - return fetchMirror(ctx, git, m, progress) + return fetchMirror(ctx, git, cache, m, progress) } func buildDiffArgs(ctx context.Context, git DiffGit, m mirror.Mirror, userArgs []string) ([]string, error) { diff --git a/internal/command/progress_integration_test.go b/internal/command/progress_integration_test.go index 87f6cc5..ca501dd 100644 --- a/internal/command/progress_integration_test.go +++ b/internal/command/progress_integration_test.go @@ -162,12 +162,15 @@ func TestProgressSetupReportsLocalRemoteChangesInPathOrder(t *testing.T) { stdout, stderr := runCommandOKWithOutput(t, repo, []string{"setup"}) assertEmptyOutput(t, "setup stdout", stdout) assertInOrder(t, stderr, + "Braid: updating cache for mirror vendor/a", + "Braid: updated cache for mirror vendor/a", "Braid: setting up mirror remote vendor/a", "Braid: set up mirror remote vendor/a", + "Braid: updating cache for mirror vendor/z", + "Braid: updated cache for mirror vendor/z", "Braid: setting up mirror remote vendor/z", "Braid: set up mirror remote vendor/z", ) - assertNotContains(t, stderr, "Braid: updating cache") assertNotContains(t, stderr, "Braid: fetching mirror") stdout, stderr = runCommandOKWithOutput(t, repo, []string{"setup"}) diff --git a/internal/command/push.go b/internal/command/push.go index 2e38751..e9379b6 100644 --- a/internal/command/push.go +++ b/internal/command/push.go @@ -84,7 +84,7 @@ func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m } progress := newProgressReporter(stderr, global.Quiet) - cache, err := runtimeCache(global) + cache, err := runtimeCacheForRepo(ctx, repo, global, global.Verbose, stderr) if err != nil { return pushResult{}, err } @@ -104,7 +104,7 @@ func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m } }() } - if err := fetchMirror(ctx, git, m, progress); err != nil { + if err := fetchMirror(ctx, git, cache, m, progress); err != nil { return pushResult{}, err } diff --git a/internal/command/setup.go b/internal/command/setup.go index d2a94cd..691af19 100644 --- a/internal/command/setup.go +++ b/internal/command/setup.go @@ -2,7 +2,9 @@ package command import ( "context" + "errors" "io" + "os" "braid/internal/cli" "braid/internal/config" @@ -31,7 +33,7 @@ func (h SetupHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { if err := validateConfigPaths(cfg); err != nil { return err } - cache, err := runtimeCache(inv.Global) + cache, err := runtimeCacheForRepo(ctx, repo, inv.Global, inv.Global.Verbose, stderr) if err != nil { return err } @@ -45,17 +47,124 @@ func (h SetupHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { if err != nil { return err } + hydrate, err := shouldHydrateSetupCache(ctx, git, m, inv.Setup.Force, cache) + if err != nil { + return err + } + if hydrate { + if err := fetchCache(ctx, cache, m, inv.Global.Verbose, progress, stderr); err != nil { + return err + } + } return setupOneWithProgress(ctx, git, m, inv.Setup.Force, cache, progress) } for _, localPath := range cfg.Paths() { - if err := setupOneWithProgress(ctx, git, cfg.Mirrors[localPath], inv.Setup.Force, cache, progress); err != nil { + m := cfg.Mirrors[localPath] + hydrate, err := shouldHydrateSetupCache(ctx, git, m, inv.Setup.Force, cache) + if err != nil { + return err + } + if hydrate { + if err := fetchCache(ctx, cache, m, inv.Global.Verbose, progress, stderr); err != nil { + return err + } + } + if err := setupOneWithProgress(ctx, git, m, inv.Setup.Force, cache, progress); err != nil { return err } } return nil } +func shouldHydrateSetupCache(ctx context.Context, git RemoteGit, m mirror.Mirror, force bool, cache CacheConfig) (bool, error) { + if !cache.Enabled { + return false, nil + } + cacheURL := cache.RemoteURL(m) + remoteURL, ok, err := git.RemoteURL(ctx, m.Remote()) + if err != nil { + return false, err + } + if !ok || force { + return true, nil + } + if remoteURL != cacheURL { + return false, nil + } + if cache.Mode == CacheModeRepositoryLocal { + ready, err := repositoryLocalSetupCacheReady(ctx, cache, m) + if err != nil { + return false, err + } + return !ready, nil + } + if _, err := os.Stat(cacheURL); err == nil { + return false, nil + } else if errors.Is(err, os.ErrNotExist) { + return true, nil + } + return false, err +} + +func repositoryLocalSetupCacheReady(ctx context.Context, cache CacheConfig, m mirror.Mirror) (bool, error) { + cachePath := cache.RemoteURL(m) + info, err := os.Stat(cachePath) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if !info.IsDir() { + return false, nil + } + + cacheGit := gitexec.New(cachePath, false, nil) + if !repositoryLocalBareReady(ctx, cachePath) { + return false, nil + } + if m.Revision != "" { + recorded, ok := repositoryLocalCommit(ctx, cacheGit, cache.RecordedRef(m)+"^{commit}") + if !ok { + return false, nil + } + configured, ok := repositoryLocalCommit(ctx, cacheGit, m.Revision+"^{commit}") + if !ok || configured != recorded { + return false, nil + } + } + switch { + case m.Branch != "": + if !repositoryLocalRefReady(ctx, cacheGit, "refs/heads/"+m.Branch+"^{commit}") { + return false, nil + } + case m.Tag != "": + if !repositoryLocalRefReady(ctx, cacheGit, "refs/tags/"+m.Tag+"^{commit}") { + return false, nil + } + } + return true, nil +} + +func repositoryLocalBareReady(ctx context.Context, cachePath string) bool { + bare, err := isBareRepository(ctx, cachePath, false, nil) + return err == nil && bare +} + +func repositoryLocalRefReady(ctx context.Context, git gitexec.Git, rev string) bool { + _, ok := repositoryLocalCommit(ctx, git, rev) + return ok +} + +func repositoryLocalCommit(ctx context.Context, git gitexec.Git, rev string) (string, bool) { + commit, err := git.RevParse(ctx, rev) + if err != nil { + return "", false + } + return commit, true +} + func (h SetupHandler) remoteGit(repo RepoContext, inv cli.Invocation, trace io.Writer) RemoteGit { if git, ok := h.Options.Git.(RemoteGit); ok { return git @@ -103,11 +212,7 @@ func setupOneWithProgress(ctx context.Context, git RemoteGit, m mirror.Mirror, f func setupMirrorRemote(ctx context.Context, git RemoteGit, m mirror.Mirror, cache CacheConfig) error { remote := m.Remote() - url := m.URL - if cache.Enabled { - url = CachePath(cache.Dir, m.URL) - } - return git.RemoteAdd(ctx, remote, url) + return git.RemoteAdd(ctx, remote, cache.RemoteURL(m)) } func validateConfigPaths(cfg config.Config) error { diff --git a/internal/command/setup_test.go b/internal/command/setup_test.go index 5d7ebe1..17375e9 100644 --- a/internal/command/setup_test.go +++ b/internal/command/setup_test.go @@ -22,8 +22,6 @@ func TestResolveCacheContract(t *testing.T) { } cwd := rootedTestPath("work") home := rootedTestPath("home") - userCache := rootedTestPath("user-cache") - withUserCacheDir(t, userCache, nil) tests := []struct { name string @@ -32,15 +30,15 @@ func TestResolveCacheContract(t *testing.T) { want CacheConfig wantErr string }{ - {name: "default enabled", env: map[string]string{"HOME": home}, want: CacheConfig{Enabled: true, Dir: filepath.Join(userCache, "braid")}}, - {name: "env true", env: map[string]string{"HOME": home, "BRAID_USE_LOCAL_CACHE": "true"}, want: CacheConfig{Enabled: true, Dir: filepath.Join(userCache, "braid")}}, - {name: "env one", env: map[string]string{"HOME": home, "BRAID_USE_LOCAL_CACHE": "1"}, want: CacheConfig{Enabled: true, Dir: filepath.Join(userCache, "braid")}}, - {name: "env disabled", env: map[string]string{"HOME": home, "BRAID_USE_LOCAL_CACHE": "false"}, want: CacheConfig{Enabled: false}}, - {name: "env cache dir", env: map[string]string{"HOME": home, "BRAID_LOCAL_CACHE_DIR": "~/custom"}, want: CacheConfig{Enabled: true, Dir: filepath.Join(home, "custom")}}, - {name: "flag no cache", global: cli.GlobalOptions{NoCache: true}, env: map[string]string{"HOME": home}, want: CacheConfig{Enabled: false}}, - {name: "flag cache dir", global: cli.GlobalOptions{CacheDir: "rel-cache", CacheDirSet: true}, env: map[string]string{"HOME": home, "BRAID_USE_LOCAL_CACHE": "false"}, want: CacheConfig{Enabled: true, Dir: filepath.Join(cwd, "rel-cache")}}, - {name: "invalid both flags", global: cli.GlobalOptions{NoCache: true, CacheDir: "cache", CacheDirSet: true}, env: map[string]string{"HOME": home}, wantErr: "--no-cache and --cache-dir cannot be used together"}, - {name: "invalid empty env cache dir", env: map[string]string{"HOME": home, "BRAID_LOCAL_CACHE_DIR": ""}, wantErr: "cache directory cannot be empty"}, + {name: "default enabled", env: map[string]string{"HOME": home}, want: CacheConfig{Enabled: true, Mode: CacheModeRepositoryLocal}}, + {name: "env true", env: map[string]string{"HOME": home, "BRAID_USE_LOCAL_CACHE": "true"}, want: CacheConfig{Enabled: true, Mode: CacheModeRepositoryLocal}}, + {name: "env one", env: map[string]string{"HOME": home, "BRAID_USE_LOCAL_CACHE": "1"}, want: CacheConfig{Enabled: true, Mode: CacheModeRepositoryLocal}}, + {name: "env disabled", env: map[string]string{"HOME": home, "BRAID_USE_LOCAL_CACHE": "false"}, want: CacheConfig{Enabled: false, Mode: CacheModeDisabled}}, + {name: "env global cache dir", env: map[string]string{"HOME": home, "BRAID_GLOBAL_CACHE_DIR": "~/custom"}, want: CacheConfig{Enabled: true, Mode: CacheModeGlobal, Dir: filepath.Join(home, "custom")}}, + {name: "flag no cache", global: cli.GlobalOptions{NoCache: true}, env: map[string]string{"HOME": home}, want: CacheConfig{Enabled: false, Mode: CacheModeDisabled}}, + {name: "flag global cache dir", global: cli.GlobalOptions{GlobalCacheDir: "rel-cache", GlobalCacheDirSet: true}, env: map[string]string{"HOME": home, "BRAID_USE_LOCAL_CACHE": "false"}, want: CacheConfig{Enabled: true, Mode: CacheModeGlobal, Dir: filepath.Join(cwd, "rel-cache")}}, + {name: "invalid both flags", global: cli.GlobalOptions{NoCache: true, GlobalCacheDir: "cache", GlobalCacheDirSet: true}, env: map[string]string{"HOME": home}, wantErr: "--no-cache and --global-cache-dir cannot be used together"}, + {name: "old local cache dir env", env: map[string]string{"HOME": home, "BRAID_LOCAL_CACHE_DIR": ""}, wantErr: "BRAID_LOCAL_CACHE_DIR has been replaced by BRAID_GLOBAL_CACHE_DIR"}, } for _, test := range tests { @@ -97,7 +95,6 @@ func TestSetupCommandCreatesAllRemotes(t *testing.T) { t.Fatalf("Write config: %v", err) } - withUserCacheDir(t, filepath.Join(t.TempDir(), "user-cache"), nil) t.Setenv("HOME", t.TempDir()) t.Chdir(repo) @@ -110,13 +107,14 @@ func TestSetupCommandCreatesAllRemotes(t *testing.T) { if result := testutil.Git(t, repo, "remote", "get-url", remote); strings.TrimSpace(result.Stdout) == "" { t.Fatalf("remote %q was not created", remote) } + if result := testutil.Git(t, repo, "ls-remote", remote, "refs/heads/main"); strings.TrimSpace(result.Stdout) == "" { + t.Fatalf("remote %q was not hydrated", remote) + } } } func TestSetupCommandCreatesAndReusesRemotes(t *testing.T) { repo := setupRepoWithConfig(t) - cacheRoot := filepath.Join(t.TempDir(), "user-cache") - withUserCacheDir(t, cacheRoot, nil) t.Setenv("HOME", t.TempDir()) t.Chdir(repo) @@ -128,8 +126,15 @@ func TestSetupCommandCreatesAndReusesRemotes(t *testing.T) { remote := "main_braid_vendor_repo" firstURL := strings.TrimSpace(testutil.Git(t, repo, "remote", "get-url", remote).Stdout) - if !strings.HasPrefix(firstURL, filepath.Join(cacheRoot, "braid")) { - t.Fatalf("remote URL = %q, want default cache path", firstURL) + canonicalRepo, err := filepath.EvalSymlinks(repo) + if err != nil { + t.Fatalf("canonicalize repo path: %v", err) + } + if !strings.HasPrefix(firstURL, filepath.Join(canonicalRepo, ".git", "braid", "cache")) { + t.Fatalf("remote URL = %q, want repository-local cache path", firstURL) + } + if result := testutil.Git(t, repo, "ls-remote", remote, "refs/heads/main"); strings.TrimSpace(result.Stdout) == "" { + t.Fatalf("remote %q was not hydrated", remote) } testutil.Git(t, repo, "remote", "set-url", remote, "manually-kept") @@ -154,7 +159,72 @@ func TestSetupCommandCreatesAndReusesRemotes(t *testing.T) { } } -func TestSetupCommandHonorsNoCacheAndCacheDir(t *testing.T) { +func TestSetupCommandRehydratesExistingRepositoryLocalCachePath(t *testing.T) { + repo := setupRepoWithConfig(t) + t.Setenv("HOME", t.TempDir()) + t.Chdir(repo) + + var stdout, stderr bytes.Buffer + if code := NewAppWithOptions(Options{WorkDir: repo, ConfigRoot: repo}).Run([]string{"setup", "vendor/repo"}, &stdout, &stderr); code != 0 { + t.Fatalf("setup exit = %d, stderr = %q", code, stderr.String()) + } + + remote := "main_braid_vendor_repo" + cacheURL := strings.TrimSpace(testutil.Git(t, repo, "remote", "get-url", remote).Stdout) + if err := os.RemoveAll(cacheURL); err != nil { + t.Fatalf("remove cache: %v", err) + } + if err := os.MkdirAll(cacheURL, 0o755); err != nil { + t.Fatalf("create unusable cache dir: %v", err) + } + + stderr.Reset() + if code := NewAppWithOptions(Options{WorkDir: repo, ConfigRoot: repo}).Run([]string{"setup", "vendor/repo"}, &stdout, &stderr); code != 0 { + t.Fatalf("second setup exit = %d, stderr = %q", code, stderr.String()) + } + if result := testutil.Git(t, repo, "ls-remote", remote, "refs/heads/main"); strings.TrimSpace(result.Stdout) == "" { + t.Fatalf("remote %q was not rehydrated", remote) + } +} + +func TestSetupCommandRehydratesStaleRepositoryLocalRevision(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "README.md", "base\n") + baseRevision := testutil.CommitAll(t, upstream, "base") + + repo := testutil.InitRepo(t) + testutil.WriteFile(t, repo, "README.md", "downstream\n") + testutil.CommitAll(t, repo, "downstream") + + m := mirror.Mirror{Path: "vendor/repo", URL: upstream, Branch: "main", Revision: baseRevision} + writeSingleMirrorConfig(t, repo, m) + t.Setenv("HOME", t.TempDir()) + t.Chdir(repo) + + var stdout, stderr bytes.Buffer + if code := NewAppWithOptions(Options{WorkDir: repo, ConfigRoot: repo}).Run([]string{"setup", "vendor/repo"}, &stdout, &stderr); code != 0 { + t.Fatalf("setup exit = %d, stderr = %q", code, stderr.String()) + } + remote := "main_braid_vendor_repo" + cacheURL := strings.TrimSpace(testutil.Git(t, repo, "remote", "get-url", remote).Stdout) + + testutil.WriteFile(t, upstream, "README.md", "updated\n") + updatedRevision := testutil.CommitAll(t, upstream, "updated") + m.Revision = updatedRevision + writeSingleMirrorConfig(t, repo, m) + + stderr.Reset() + if code := NewAppWithOptions(Options{WorkDir: repo, ConfigRoot: repo}).Run([]string{"setup", "vendor/repo"}, &stdout, &stderr); code != 0 { + t.Fatalf("second setup exit = %d, stderr = %q", code, stderr.String()) + } + recordedRef := (CacheConfig{}).RecordedRef(m) + got := strings.TrimSpace(testutil.Git(t, cacheURL, "rev-parse", recordedRef+"^{commit}").Stdout) + if got != updatedRevision { + t.Fatalf("%s = %s, want %s", recordedRef, got, updatedRevision) + } +} + +func TestSetupCommandHonorsNoCacheAndGlobalCacheDir(t *testing.T) { repo := setupRepoWithConfig(t) t.Setenv("HOME", t.TempDir()) t.Chdir(repo) @@ -176,8 +246,8 @@ func TestSetupCommandHonorsNoCacheAndCacheDir(t *testing.T) { testutil.Git(t, repo, "remote", "rm", remote) stderr.Reset() - if code := app.Run([]string{"--cache-dir", "local-cache", "setup", "vendor/repo"}, &stdout, &stderr); code != 0 { - t.Fatalf("setup --cache-dir exit = %d, stderr = %q", code, stderr.String()) + if code := app.Run([]string{"--global-cache-dir", "local-cache", "setup", "vendor/repo"}, &stdout, &stderr); code != 0 { + t.Fatalf("setup --global-cache-dir exit = %d, stderr = %q", code, stderr.String()) } cacheURL := strings.TrimSpace(testutil.Git(t, repo, "remote", "get-url", remote).Stdout) canonicalRepo, err := filepath.EvalSymlinks(repo) @@ -189,7 +259,7 @@ func TestSetupCommandHonorsNoCacheAndCacheDir(t *testing.T) { } } -func TestSetupCommandFromSubdirectoryUsesProcessRelativeCacheDir(t *testing.T) { +func TestSetupCommandFromSubdirectoryUsesProcessRelativeGlobalCacheDir(t *testing.T) { repo := setupRepoWithConfig(t) workDir := filepath.Join(repo, "apps", "web") if err := os.MkdirAll(workDir, 0o755); err != nil { @@ -199,7 +269,7 @@ func TestSetupCommandFromSubdirectoryUsesProcessRelativeCacheDir(t *testing.T) { t.Chdir(workDir) var stdout, stderr bytes.Buffer - if code := NewAppWithOptions(Options{WorkDir: workDir}).Run([]string{"--cache-dir", "local-cache", "setup", "../../vendor/repo"}, &stdout, &stderr); code != 0 { + if code := NewAppWithOptions(Options{WorkDir: workDir}).Run([]string{"--global-cache-dir", "local-cache", "setup", "../../vendor/repo"}, &stdout, &stderr); code != 0 { t.Fatalf("setup from subdir exit = %d, stderr = %q", code, stderr.String()) } remote := "main_braid_vendor_repo" diff --git a/internal/command/status.go b/internal/command/status.go index 4aba049..fd66f3b 100644 --- a/internal/command/status.go +++ b/internal/command/status.go @@ -32,7 +32,7 @@ func (h StatusHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { if err := validateConfigPaths(cfg); err != nil { return err } - cache, err := runtimeCache(inv.Global) + cache, err := runtimeCacheForRepo(ctx, repo, inv.Global, inv.Global.Verbose, stderr) if err != nil { return err } @@ -83,7 +83,7 @@ func (h StatusHandler) statusOne(ctx context.Context, git StatusGit, cache Cache } }() - if err := fetchMirror(ctx, git, m, progress); err != nil { + if err := fetchMirror(ctx, git, cache, m, progress); err != nil { return err } baseRevision, err := git.RevParse(ctx, m.Revision+"^{commit}") diff --git a/internal/command/sync.go b/internal/command/sync.go index 067bca2..aad2710 100644 --- a/internal/command/sync.go +++ b/internal/command/sync.go @@ -71,7 +71,7 @@ func (h SyncHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { if err != nil { return err } - cache, err := runtimeCache(inv.Global) + cache, err := runtimeCacheForRepo(ctx, repo, inv.Global, inv.Global.Verbose, stderr) if err != nil { return err } @@ -333,7 +333,7 @@ func (h SyncHandler) withFetchedMirrorForPlanning(ctx context.Context, git PushG } }() } - if err := fetchMirror(ctx, git, m, progress); err != nil { + if err := fetchMirror(ctx, git, cache, m, progress); err != nil { return err } return fn() diff --git a/internal/command/sync_test.go b/internal/command/sync_test.go index 5de9140..28e872d 100644 --- a/internal/command/sync_test.go +++ b/internal/command/sync_test.go @@ -522,7 +522,7 @@ func TestSyncCommandAutostashRestoresAfterOperationalFailure(t *testing.T) { stderr := runCommandError(t, repo, []string{"sync", "--autostash", "vendor/basic"}) - assertContains(t, stderr, "recorded revision "+bogusRevision+" for vendor/basic is unavailable after hydration") + assertContains(t, stderr, "recorded revision "+bogusRevision+" for mirror vendor/basic is unavailable from upstream "+upstream+"; the repository-local cache may have been deleted or the upstream history may have been rewritten") assertFile(t, repo, "vendor/basic/README.md", "dirty\n") if stashList := strings.TrimSpace(testutil.Git(t, repo, "stash", "list").Stdout); stashList != "" { t.Fatalf("stash list = %q, want autostash restored and dropped", stashList) @@ -1091,7 +1091,7 @@ func TestSyncCommandReportsUnavailableRecordedRevisionAfterHydration(t *testing. stderr := runCommandError(t, repo, []string{"sync", "vendor/basic"}) - assertContains(t, stderr, "recorded revision "+bogusRevision+" for vendor/basic is unavailable after hydration") + assertContains(t, stderr, "recorded revision "+bogusRevision+" for mirror vendor/basic is unavailable from upstream "+upstream+"; the repository-local cache may have been deleted or the upstream history may have been rewritten") } func TestSyncCommandKeepRetainsTemporaryRemote(t *testing.T) { diff --git a/internal/command/test_support_test.go b/internal/command/test_support_test.go index 2f08855..038e63a 100644 --- a/internal/command/test_support_test.go +++ b/internal/command/test_support_test.go @@ -32,7 +32,6 @@ func runCommandOK(t *testing.T, repo string, args []string) string { func runCommandOKInDir(t *testing.T, repo, dir string, args []string) string { t.Helper() t.Setenv("HOME", t.TempDir()) - t.Setenv("BRAID_LOCAL_CACHE_DIR", filepath.Join(t.TempDir(), "braid-cache")) t.Chdir(dir) var stdout, stderr bytes.Buffer code := NewAppWithOptions(Options{WorkDir: dir}).Run(args, &stdout, &stderr) @@ -45,7 +44,6 @@ func runCommandOKInDir(t *testing.T, repo, dir string, args []string) string { func runCommandOKInDirWithOptions(t *testing.T, repo, dir string, options Options, args []string) string { t.Helper() t.Setenv("HOME", t.TempDir()) - t.Setenv("BRAID_LOCAL_CACHE_DIR", filepath.Join(t.TempDir(), "braid-cache")) t.Chdir(dir) options.WorkDir = dir var stdout, stderr bytes.Buffer @@ -59,7 +57,6 @@ func runCommandOKInDirWithOptions(t *testing.T, repo, dir string, options Option func runCommandOKWithOutput(t *testing.T, repo string, args []string) (string, string) { t.Helper() t.Setenv("HOME", t.TempDir()) - t.Setenv("BRAID_LOCAL_CACHE_DIR", filepath.Join(t.TempDir(), "braid-cache")) t.Chdir(repo) var stdout, stderr bytes.Buffer code := NewAppWithOptions(Options{WorkDir: repo}).Run(args, &stdout, &stderr) @@ -77,7 +74,6 @@ func runCommandError(t *testing.T, repo string, args []string) string { func runCommandErrorInDir(t *testing.T, repo, dir string, args []string) string { t.Helper() t.Setenv("HOME", t.TempDir()) - t.Setenv("BRAID_LOCAL_CACHE_DIR", filepath.Join(t.TempDir(), "braid-cache")) t.Chdir(dir) var stdout, stderr bytes.Buffer code := NewAppWithOptions(Options{WorkDir: dir}).Run(args, &stdout, &stderr) @@ -90,7 +86,6 @@ func runCommandErrorInDir(t *testing.T, repo, dir string, args []string) string func runCommandErrorWithOutput(t *testing.T, repo string, args []string) (string, string) { t.Helper() t.Setenv("HOME", t.TempDir()) - t.Setenv("BRAID_LOCAL_CACHE_DIR", filepath.Join(t.TempDir(), "braid-cache")) t.Chdir(repo) var stdout, stderr bytes.Buffer code := NewAppWithOptions(Options{WorkDir: repo}).Run(args, &stdout, &stderr) @@ -221,17 +216,6 @@ func writePostCommitHook(t *testing.T, repo string) { } } -func withUserCacheDir(t *testing.T, dir string, err error) { - t.Helper() - previous := userCacheDir - userCacheDir = func() (string, error) { - return dir, err - } - t.Cleanup(func() { - userCacheDir = previous - }) -} - func envLookup(values map[string]string) EnvLookup { return func(key string) (string, bool) { value, ok := values[key] diff --git a/internal/command/update.go b/internal/command/update.go index 9d365ac..aac748a 100644 --- a/internal/command/update.go +++ b/internal/command/update.go @@ -64,7 +64,7 @@ func (h UpdateHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { git := h.updateGit(repo, inv, stderr) processGit := h.processRepoPathGit(repo, inv, stderr) progress := newProgressReporter(stderr, inv.Global.Quiet) - cache, err := runtimeCache(inv.Global) + cache, err := runtimeCacheForRepo(ctx, repo, inv.Global, inv.Global.Verbose, stderr) if err != nil { return err } @@ -191,7 +191,11 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon } if cache.Enabled { - if err := fetchCache(ctx, cache, m, verbose, progress, trace); err != nil { + var requestedRevisions []string + if options.Revision != "" { + requestedRevisions = append(requestedRevisions, options.Revision) + } + if err := fetchCache(ctx, cache, m, verbose, progress, trace, requestedRevisions...); err != nil { return updateResult{}, err } } @@ -205,7 +209,7 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon return git.RemoteRemove(ctx, m.Remote()) } - if err := fetchMirror(ctx, git, m, progress); err != nil { + if err := fetchMirror(ctx, git, cache, m, progress); err != nil { _ = cleanupRemote() return updateResult{}, err } @@ -221,7 +225,7 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon _ = cleanupRemote() return updateResult{}, err } - newRevision, err := resolveUpdateRevision(ctx, git, m, options.Revision) + newRevision, err := resolveUpdateRevision(ctx, git, cache, m, options.Revision) if err != nil { _ = checkProgress.Abort() _ = cleanupRemote() @@ -424,9 +428,9 @@ func applyUpdateStrategy(m *mirror.Mirror, options cli.UpdateOptions) { } } -func resolveUpdateRevision(ctx context.Context, git UpdateGit, m mirror.Mirror, requested string) (string, error) { +func resolveUpdateRevision(ctx context.Context, git UpdateGit, cache CacheConfig, m mirror.Mirror, requested string) (string, error) { if requested != "" { - return git.RevParse(ctx, requested+"^{commit}") + return git.RevParse(ctx, cacheResolveRequestedRevision(cache, m, requested)+"^{commit}") } return resolveAddRevision(ctx, git, m, "") } diff --git a/internal/command/update_test.go b/internal/command/update_test.go index f5f5c5c..e206ef5 100644 --- a/internal/command/update_test.go +++ b/internal/command/update_test.go @@ -1232,7 +1232,7 @@ func TestUpdateCommandMergeTreeCommandFailureDoesNotWriteConflictState(t *testin testutil.CommitAll(t, upstream, "remote change") t.Setenv("HOME", t.TempDir()) - t.Setenv("BRAID_LOCAL_CACHE_DIR", filepath.Join(t.TempDir(), "braid-cache")) + t.Setenv("BRAID_GLOBAL_CACHE_DIR", filepath.Join(t.TempDir(), "braid-cache")) t.Chdir(repo) var stdout, stderr bytes.Buffer code := NewAppWithOptions(Options{ diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index 1cb3883..68f3cbf 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -597,6 +597,11 @@ func (g Git) Init(ctx context.Context) error { return err } +func (g Git) InitBare(ctx context.Context, dir string) error { + _, err := g.RunOK(ctx, "init", "--bare", dir) + return err +} + func (g Git) UpdateRef(ctx context.Context, args ...string) error { gitArgs := append([]string{"update-ref"}, args...) _, err := g.RunOK(ctx, gitArgs...) diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go index 077f65e..9366796 100644 --- a/internal/gitexec/gitexec_test.go +++ b/internal/gitexec/gitexec_test.go @@ -249,6 +249,23 @@ func TestIsInsideWorkTreeTreatsNonRepositoryAsFalse(t *testing.T) { } } +func TestInitBareCreatesBareRepository(t *testing.T) { + ctx := context.Background() + dir := filepath.Join(t.TempDir(), "cache.git") + + if err := New(t.TempDir(), false, nil).InitBare(ctx, dir); err != nil { + t.Fatalf("InitBare returned error: %v", err) + } + + got, err := New(dir, false, nil).Output(ctx, "rev-parse", "--is-bare-repository") + if err != nil { + t.Fatalf("rev-parse bare repository: %v", err) + } + if got != "true" { + t.Fatalf("bare repository = %q, want true", got) + } +} + func TestCommitVerboseAndMessageFileUseExpectedCleanup(t *testing.T) { git := Git{Runner: helperRunner(t, nil)} From 2ca546ca50d683a49239da69dac84f563f5dc714 Mon Sep 17 00:00:00 2001 From: Peter Donald Date: Sat, 11 Jul 2026 13:20:41 +1000 Subject: [PATCH 2/4] fix(cache): shorten repository-local cache IDs --- docs/migration-from-ruby-braid.md | 4 ++-- integration/support.go | 2 +- internal/command/cache.go | 5 ++++- internal/command/cache_test.go | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/migration-from-ruby-braid.md b/docs/migration-from-ruby-braid.md index 2be0058..5e4bc36 100644 --- a/docs/migration-from-ruby-braid.md +++ b/docs/migration-from-ruby-braid.md @@ -224,8 +224,8 @@ Both tools enable a local mirror cache by default, but the cache layout changed. Ruby Braid defaults to `~/.braid/cache` and derives cache child paths by sanitizing the upstream URL. Current Go Braid defaults to repository-local per-mirror bare caches under `.git/braid/cache` and derives cache child paths -from a SHA-256 hash of the upstream URL, local path, upstream path, and tracking -mode. +from a SHA-256-derived key covering the upstream URL, local path, upstream path, +and tracking mode. Current controls: diff --git a/integration/support.go b/integration/support.go index d077686..622bf3d 100644 --- a/integration/support.go +++ b/integration/support.go @@ -335,7 +335,7 @@ func mirrorCacheID(localPath string, m configMirror) string { } parts := []string{m.URL, localPath, m.Path, tracking, m.Branch, m.Tag} sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) - return hex.EncodeToString(sum[:]) + return hex.EncodeToString(sum[:16]) } func remoteName(tracking, localPath string) string { diff --git a/internal/command/cache.go b/internal/command/cache.go index 9715fbf..6916677 100644 --- a/internal/command/cache.go +++ b/internal/command/cache.go @@ -22,6 +22,7 @@ const ( CacheModeDisabled CacheMode = "disabled" CacheModeRepositoryLocal CacheMode = "repository-local" CacheModeGlobal CacheMode = "global" + mirrorCacheIDBytes = 16 ) type CacheConfig struct { @@ -140,7 +141,9 @@ func MirrorCacheID(m mirror.Mirror) string { m.Tag, } sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) - return hex.EncodeToString(sum[:]) + // The ID appears in both the cache directory and Braid refs; keep it short + // enough for default Windows path limits while retaining 128 bits of hash. + return hex.EncodeToString(sum[:mirrorCacheIDBytes]) } func runtimeCache(global cli.GlobalOptions) (CacheConfig, error) { diff --git a/internal/command/cache_test.go b/internal/command/cache_test.go index cf11405..f21d32c 100644 --- a/internal/command/cache_test.go +++ b/internal/command/cache_test.go @@ -176,8 +176,8 @@ func TestMirrorCacheIDUsesStableWindowsSafeKey(t *testing.T) { if got != gotAgain { t.Fatalf("MirrorCacheID is not stable: %q then %q", got, gotAgain) } - if len(got) != 64 { - t.Fatalf("cache id length = %d, want 64", len(got)) + if len(got) != 32 { + t.Fatalf("cache id length = %d, want 32", len(got)) } if strings.ContainsAny(got, "<>:\"/\\|?*") { t.Fatalf("cache id %q contains Windows-invalid filename characters", got) From 09f43d8e9512a0664835d22b37ec38fd7f377c6a Mon Sep 17 00:00:00 2001 From: Peter Donald Date: Sat, 11 Jul 2026 03:30:16 +1000 Subject: [PATCH 3/4] test(integration): disable Git auto maintenance --- integration/support.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integration/support.go b/integration/support.go index 622bf3d..1f87cb1 100644 --- a/integration/support.go +++ b/integration/support.go @@ -52,7 +52,8 @@ func newProcessEnv(t *testing.T, root string) processEnv { } } globalConfig := filepath.Join(root, "xdg-config", "gitconfig") - if err := os.WriteFile(globalConfig, nil, 0o644); err != nil { + const globalConfigContents = "[gc]\n\tauto = 0\n[maintenance]\n\tauto = false\n" + if err := os.WriteFile(globalConfig, []byte(globalConfigContents), 0o644); err != nil { t.Fatalf("write global gitconfig: %v", err) } From c74447d7a11626a66a8c3e9fb56562d33aab54f7 Mon Sep 17 00:00:00 2001 From: Peter Donald Date: Sat, 11 Jul 2026 03:31:25 +1000 Subject: [PATCH 4/4] build: add Bazelisk wrapper --- bazelw | 5 +++++ 1 file changed, 5 insertions(+) create mode 100755 bazelw diff --git a/bazelw b/bazelw new file mode 100755 index 0000000..4712e73 --- /dev/null +++ b/bazelw @@ -0,0 +1,5 @@ +#!/usr/bin/env sh + +set -eu + +exec bazelisk "$@"