diff --git a/README.md b/README.md index ec8a157..7de928d 100644 --- a/README.md +++ b/README.md @@ -553,19 +553,12 @@ stages the mirror content removal and `.braids.json` update. ### Remotes, Cache, And Paths -Braid normally creates Git remotes as needed and removes them when the command -finishes. Use `setup` when you want Braid-managed remotes to exist in the -repository, for example before inspecting them with Git: - -```bash -braid setup -braid setup vendor/rails --force -``` +Braid creates Git remotes as needed and removes them when the command finishes. 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 +are implementation state and can be rebuilt by `braid add`, `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 @@ -590,7 +583,7 @@ lookup or storage. Absolute `local_path` inputs are accepted only when they are 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 +`braid pull`, and `braid sync`, operate on the repository-wide 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` @@ -622,7 +615,6 @@ includes it. | `push` | Push committed local mirror changes upstream. | | `sync [local_path...] [--pull-only] [--autostash] [--keep]` | Push changed branch mirrors, then pull selected mirrors. | | `remove [--no-commit]` | Remove mirrored content and config. | -| `setup` | Add or refresh Braid-managed Git remotes. | | `version` | Print the Braid version. | | `completion bash` | Print the Bash completion script. | diff --git a/docs/migration-from-ruby-braid.md b/docs/migration-from-ruby-braid.md index 0bf34d9..cde922f 100644 --- a/docs/migration-from-ruby-braid.md +++ b/docs/migration-from-ruby-braid.md @@ -15,7 +15,7 @@ Comparison baseline: The Go tool keeps the core modern Braid model: mirrors are recorded in `.braids.json`, mirror content is copied into the downstream repository, and the -main workflows are `add`, `status`, `diff`, `pull`, `push`, `setup`, and +main workflows are `add`, `status`, `diff`, `pull`, `push`, and `remove`. `pull` is the documented spelling for updating mirror content; `update` and `up` are accepted aliases. The differences below are the parts most likely to matter in day to day use or automation. @@ -51,7 +51,7 @@ worktree. When a command takes a mirror path, the path is resolved relative to the directory where Braid was invoked and stored in `.braids.json` as a repo-root-relative path. Commands without a path, such as `braid status`, -`braid diff`, `braid pull`, `braid sync`, and `braid setup`, still operate on +`braid diff`, `braid pull`, and `braid sync`, still operate on the repository-wide mirror set. Migration impact: @@ -150,7 +150,7 @@ Migration impact: | Area | Ruby Braid | Current Go Braid | Migration impact | | --- | --- | --- | --- | -| Commands | `add`, `update`, `remove`, `diff`, `push`, `setup`, `version`, `status`, `upgrade-config` | `pull` is the documented mirror-update command; `update` and `up` are aliases; same other core commands, plus `sync`; `upgrade-config` migrates versioned JSON config | Prefer `pull` in new docs and scripts. Use Go Braid's `upgrade-config` only for `.braids.json` version 1 to 2 migration. | +| Commands | `add`, `update`, `remove`, `diff`, `push`, `setup`, `version`, `status`, `upgrade-config` | `pull` is the documented mirror-update command; `update` and `up` are aliases; `setup` is removed; the other core commands remain, plus `sync`; `upgrade-config` migrates versioned JSON config | Remove `setup` from scripts, prefer `pull` in new docs and scripts, and use Go Braid's `upgrade-config` only for `.braids.json` version 1 to 2 migration. | | 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. | @@ -160,7 +160,6 @@ Migration impact: | `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. | | Strategy flags with no-path `update` | Accepted by the Ruby parser, with inconsistent all-mirror behavior | Rejected for no-path `pull` and its aliases | Pass a local path when changing branch, tag, or revision. | | `diff` pass-through | Arguments after `--` are passed to `git diff` | Same | Output formatting is not exact Ruby text parity. | -| `setup --force` and command `--keep` | Supported | Supported where relevant | Remote cleanup behavior is broadly preserved. | ## Configuration Compatibility @@ -241,8 +240,6 @@ Current controls: 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 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 @@ -287,7 +284,7 @@ Go Braid intentionally restores Ruby-like feedback for operations that may contact upstream repositories or the local cache. It prints semantic start and completion progress to `stderr` for cache updates, mirror fetches, pull checks, mirror updates, upstream pushes, status remote checks, diff remote hydration, -and setup remote changes. Interactive terminals append `.` about every five +and temporary remote changes. Interactive terminals append `.` about every five seconds during long-running operations; non-interactive output is line-based. The stream contract is explicit: diff --git a/integration/BUILD.bazel b/integration/BUILD.bazel index 8ff8ff7..41f7a16 100644 --- a/integration/BUILD.bazel +++ b/integration/BUILD.bazel @@ -43,15 +43,6 @@ go_test( timeout = "short", ) -go_test( - name = "configure_failure_command_test", - size = "medium", - srcs = ["setup_failure_test.go"], - data = ["//cmd/braid"], - embed = [":test_support"], - timeout = "short", -) - go_test( name = "scoped_state_test", size = "medium", diff --git a/integration/lifecycle_test.go b/integration/lifecycle_test.go index fe6e309..3adec04 100644 --- a/integration/lifecycle_test.go +++ b/integration/lifecycle_test.go @@ -49,31 +49,6 @@ func TestExecutablePrimaryLifecycle(t *testing.T) { assertPathExists(t, filepath.Join(cacheRemoteURL, "HEAD")) assertClean(t, env, downstream) - setup := runBraid(t, env, downstream, braid, "setup", localPath) - 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, - ) - assertRemoteURL(t, env, downstream, remote, cacheRemoteURL) - - gitOK(t, env, downstream, "remote", "set-url", remote, "manual-url") - setupReuse := runBraid(t, env, downstream, braid, "setup", localPath) - assertResult(t, setupReuse, 0, "", "") - assertRemoteURL(t, env, downstream, remote, "manual-url") - - setupForce := runBraid(t, env, downstream, braid, "setup", localPath, "--force") - 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, - ) - assertRemoteURL(t, env, downstream, remote, cacheRemoteURL) - status := runBraid(t, env, downstream, braid, "status", localPath) assertExit(t, status, 0) assertProgress(t, status.stderr, diff --git a/integration/no_commit_test.go b/integration/no_commit_test.go index bb7cda3..5afac7a 100644 --- a/integration/no_commit_test.go +++ b/integration/no_commit_test.go @@ -68,9 +68,8 @@ func TestExecutableNoCommitAddUpdateRemoveWorkflow(t *testing.T) { } commitAll(t, env, downstream, "combined update") - setup := runBraid(t, env, downstream, braid, "setup", "vendor/basic") - assertExit(t, setup, 0) remote := remoteName("main", "vendor/basic") + gitOK(t, env, downstream, "remote", "add", remote, repositoryCachePath(t, downstream, "vendor/basic", configMirror{URL: upstream, Branch: "main"})) writeFile(t, downstream, "remove-staged.txt", "remove staged\n") gitOK(t, env, downstream, "add", "remove-staged.txt") removeHead := gitOutput(t, env, downstream, "rev-parse", "HEAD") diff --git a/integration/setup_failure_test.go b/integration/setup_failure_test.go deleted file mode 100644 index 79a2276..0000000 --- a/integration/setup_failure_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package integration - -import ( - "os" - "path/filepath" - "testing" -) - -func TestExecutableSetupCacheModes(t *testing.T) { - root := t.TempDir() - env := newProcessEnv(t, root) - braid := braidBinary(t) - - upstream := filepath.Join(root, "upstream") - initRepo(t, env, upstream) - writeFile(t, upstream, "README.md", "cache modes\n") - revision := commitAll(t, env, upstream, "seed upstream") - - downstream := filepath.Join(root, "downstream") - initRepo(t, env, downstream) - writeFile(t, downstream, "README.md", "downstream\n") - writeConfig(t, downstream, map[string]configMirror{ - "vendor/repo": {URL: upstream, Branch: "main", Revision: revision}, - }) - commitAll(t, env, downstream, "configure mirror") - - remote := remoteName("main", "vendor/repo") - - setupDefault := runBraid(t, env, downstream, braid, "--quiet", "setup", "vendor/repo") - assertResult(t, setupDefault, 0, "", "") - 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, env, downstream, remote) - - 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", "--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)) - - gitOK(t, envDisabled, downstream, "remote", "rm", remote) - setupNoCache := runBraid(t, env, downstream, braid, "--quiet", "--no-cache", "setup", "vendor/repo") - assertResult(t, setupNoCache, 0, "", "") - assertRemoteURL(t, env, downstream, remote, upstream) - - 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 --global-cache-dir cannot be used together") -} - -func TestExecutableFailurePaths(t *testing.T) { - root := t.TempDir() - env := newProcessEnv(t, root) - braid := braidBinary(t) - - upstream := filepath.Join(root, "upstream") - initRepo(t, env, upstream) - writeFile(t, upstream, "README.md", "base\n") - commitAll(t, env, upstream, "seed upstream") - - missingConfigRepo := filepath.Join(root, "missing-config") - initRepo(t, env, missingConfigRepo) - writeFile(t, missingConfigRepo, "README.md", "downstream\n") - commitAll(t, env, missingConfigRepo, "seed downstream") - missingConfig := runBraid(t, env, missingConfigRepo, braid, "status") - assertExit(t, missingConfig, 1) - assertEmpty(t, "missing config stdout", missingConfig.stdout) - assertContains(t, missingConfig.stderr, "missing .braids.json") - - subdirRepo := filepath.Join(root, "subdir") - initRepo(t, env, subdirRepo) - writeFile(t, subdirRepo, "README.md", "downstream\n") - commitAll(t, env, subdirRepo, "seed downstream") - subdir := filepath.Join(subdirRepo, "nested") - if err := os.MkdirAll(subdir, 0o755); err != nil { - t.Fatalf("create subdir: %v", err) - } - subdirStatus := runBraid(t, env, subdir, braid, "status") - assertExit(t, subdirStatus, 1) - assertEmpty(t, "subdir missing config stdout", subdirStatus.stdout) - assertContains(t, subdirStatus.stderr, "missing .braids.json") - - outsideDir := filepath.Join(root, "outside-worktree") - if err := os.MkdirAll(outsideDir, 0o755); err != nil { - t.Fatalf("create outside dir: %v", err) - } - outsideWorktree := runBraid(t, env, outsideDir, braid, "status") - assertExit(t, outsideWorktree, 1) - assertEmpty(t, "outside worktree stdout", outsideWorktree.stdout) - assertContains(t, outsideWorktree.stderr, "inside a git working tree") - - rollbackRepo := filepath.Join(root, "rollback") - initRepo(t, env, rollbackRepo) - writeFile(t, rollbackRepo, "README.md", "downstream\n") - commitAll(t, env, rollbackRepo, "seed downstream") - head := gitOutput(t, env, rollbackRepo, "rev-parse", "HEAD") - failedAdd := runBraid(t, env, rollbackRepo, braid, "add", upstream, "vendor/missing", "--path", "does-not-exist") - assertExit(t, failedAdd, 1) - assertEmpty(t, "failed add stdout", failedAdd.stdout) - assertContains(t, failedAdd.stderr, "no tree item exists") - if got := gitOutput(t, env, rollbackRepo, "rev-parse", "HEAD"); got != head { - t.Fatalf("HEAD after failed add = %s, want %s", got, head) - } - assertPathMissing(t, rollbackRepo, ".braids.json") - assertNoRemote(t, env, rollbackRepo, remoteName("main", "vendor/missing")) - assertClean(t, env, rollbackRepo) -} diff --git a/integration/subdirectory_test.go b/integration/subdirectory_test.go index 93e6e9f..e01ad80 100644 --- a/integration/subdirectory_test.go +++ b/integration/subdirectory_test.go @@ -35,10 +35,6 @@ func TestExecutableSubdirectoryLifecycle(t *testing.T) { localPath: {URL: upstream, Branch: "main", Revision: baseRevision}, }) - setup := runBraid(t, env, workDir, braid, "--quiet", "setup", "vendor/basic") - assertResult(t, setup, 0, "", "") - 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) assertEmpty(t, "subdir status stderr", status.stderr) diff --git a/integration/support.go b/integration/support.go index 41482bf..1de5d49 100644 --- a/integration/support.go +++ b/integration/support.go @@ -317,11 +317,6 @@ func assertConfigRaw(t *testing.T, repo string, mirrors map[string]configMirror) } } -func cachePath(cacheDir, url string) string { - sum := sha256.Sum256([]byte(url)) - 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") diff --git a/internal/cli/cli.go b/internal/cli/cli.go index be9b068..07207da 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -17,7 +17,6 @@ const ( CommandDiff Command = "diff" CommandPush Command = "push" CommandSync Command = "sync" - CommandSetup Command = "setup" CommandVersion Command = "version" CommandStatus Command = "status" CommandCompletion Command = "completion" @@ -81,11 +80,6 @@ type SyncOptions struct { Keep bool } -type SetupOptions struct { - LocalPath string - Force bool -} - type StatusOptions struct { LocalPath string } @@ -110,7 +104,6 @@ type Invocation struct { Diff DiffOptions Push PushOptions Sync SyncOptions - Setup SetupOptions Status StatusOptions Completion CompletionOptions Complete CompleteOptions @@ -142,7 +135,6 @@ func New() App { CommandDiff: notImplemented(CommandDiff), CommandPush: notImplemented(CommandPush), CommandSync: notImplemented(CommandSync), - CommandSetup: notImplemented(CommandSetup), CommandStatus: notImplemented(CommandStatus), CommandUpgradeConfig: notImplemented(CommandUpgradeConfig), }, @@ -268,8 +260,6 @@ func Parse(args []string) (Invocation, error) { return inv, parsePush(commandArgs, &inv.Push) case CommandSync: return inv, parseSync(commandArgs, &inv.Sync) - case CommandSetup: - return inv, parseSetup(commandArgs, &inv.Setup) case CommandVersion: return inv, requireNoArgs("version", commandArgs) case CommandStatus: @@ -470,25 +460,6 @@ func parseSync(args []string, options *SyncOptions) error { return nil } -func parseSetup(args []string, options *SetupOptions) error { - var positionals []string - err := parseCommandArgs(CommandSetup, args, []flagSpec{ - boolFlag("--force", "-f", func() { options.Force = true }), - }, func(pos []string, _ []string) { - positionals = pos - }) - if err != nil { - return err - } - if err := requireArgRange("setup", positionals, 0, 1); err != nil { - return err - } - if len(positionals) == 1 { - options.LocalPath = normalizeLocalPathArg(positionals[0]) - } - return nil -} - func parseStatus(args []string, options *StatusOptions) error { var positionals []string err := parseCommandArgs(CommandStatus, args, nil, func(pos []string, _ []string) { @@ -635,8 +606,6 @@ func parseCommand(value string) (Command, bool) { return CommandPush, true case string(CommandSync): return CommandSync, true - case string(CommandSetup): - return CommandSetup, true case string(CommandVersion): return CommandVersion, true case string(CommandStatus): @@ -688,7 +657,6 @@ commands: diff Show local mirror changes push Push local mirror changes upstream sync Push local mirror changes, then pull mirrors - setup Set up mirror remotes status Show mirror status version Show braid version completion @@ -714,8 +682,6 @@ func CommandUsage(command Command) string { return "usage: braid push [--branch|-b ] [--message|-m ] [--keep]\n" case CommandSync: return "usage: braid sync [local_path...] [--pull-only] [--autostash] [--keep]\n" - case CommandSetup: - return "usage: braid setup [local_path] [--force|-f]\n" case CommandVersion: return "usage: braid version\n" case CommandStatus: diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 1087a7f..abf9101 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -122,11 +122,6 @@ func TestParseCommands(t *testing.T) { args: []string{"sync", "vendor/a", "--pull-only", "--autostash", "--keep"}, want: Invocation{Command: CommandSync, Sync: SyncOptions{LocalPaths: []string{"vendor/a"}, PullOnly: true, Autostash: true, Keep: true}}, }, - { - name: "setup", - args: []string{"--verbose", "setup", "vendor/repo", "--force"}, - want: Invocation{Global: GlobalOptions{Verbose: true}, Command: CommandSetup, Setup: SetupOptions{LocalPath: "vendor/repo", Force: true}}, - }, { name: "version", args: []string{"version"}, @@ -233,7 +228,6 @@ func TestParseNormalizesLocalPathArguments(t *testing.T) { {name: "diff selector", args: []string{"diff", `vendor\repo`}, want: "vendor/repo"}, {name: "push selector", args: []string{"push", `vendor\repo`}, want: "vendor/repo"}, {name: "sync selector", args: []string{"sync", `vendor\repo`}, want: "vendor/repo"}, - {name: "setup selector", args: []string{"setup", `vendor\repo`}, want: "vendor/repo"}, {name: "status selector", args: []string{"status", `vendor\repo`}, want: "vendor/repo"}, } @@ -267,8 +261,6 @@ func gotLocalPath(inv Invocation) string { return "" } return inv.Sync.LocalPaths[0] - case CommandSetup: - return inv.Setup.LocalPath case CommandStatus: return inv.Status.LocalPath default: @@ -353,7 +345,6 @@ func TestUsageDocumentsVerboseAsGlobalOnly(t *testing.T) { CommandDiff, CommandPush, CommandSync, - CommandSetup, CommandStatus, } { if usage := CommandUsage(command); strings.Contains(usage, "--verbose") || strings.Contains(usage, "|-v") || strings.Contains(usage, "--quiet") { diff --git a/internal/command/BUILD.bazel b/internal/command/BUILD.bazel index 2812c55..f92e119 100644 --- a/internal/command/BUILD.bazel +++ b/internal/command/BUILD.bazel @@ -15,7 +15,7 @@ go_library( "push_message.go", "push_provenance.go", "remove.go", - "setup.go", + "remote.go", "status.go", "sync.go", "update.go", @@ -128,14 +128,6 @@ go_test( deps = COMMAND_TEST_DEPS, ) -go_test( - name = "configure_command_test", - srcs = ["setup_test.go"], - embed = [":command_test_support"], - timeout = "short", - deps = COMMAND_TEST_DEPS, -) - go_test( name = "status_test", srcs = ["status_test.go"], diff --git a/internal/command/add.go b/internal/command/add.go index 882351d..416284c 100644 --- a/internal/command/add.go +++ b/internal/command/add.go @@ -106,7 +106,7 @@ func (h AddHandler) add(ctx context.Context, repo RepoContext, git AddGit, inv c } } - if err := setupOne(ctx, git, m, true, cache); err != nil { + if err := configureMirrorRemote(ctx, git, m, true, cache); err != nil { return err } remote := m.Remote() diff --git a/internal/command/cache_test.go b/internal/command/cache_test.go index d881e1a..e3dae50 100644 --- a/internal/command/cache_test.go +++ b/internal/command/cache_test.go @@ -280,8 +280,8 @@ func TestFetchMirrorFromRepositoryLocalCacheFetchesBraidRefs(t *testing.T) { 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 := configureMirrorRemote(ctx, git, m, true, cache); err != nil { + t.Fatalf("configureMirrorRemote returned error: %v", err) } if err := fetchMirror(ctx, git, cache, m, progressReporter{}); err != nil { t.Fatalf("fetchMirror returned error: %v", err) @@ -310,8 +310,8 @@ func TestFetchTagMirrorFromRepositoryLocalCacheUsesRemoteTrackingRef(t *testing. 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 := configureMirrorRemote(ctx, git, m, true, cache); err != nil { + t.Fatalf("configureMirrorRemote returned error: %v", err) } if err := fetchMirror(ctx, git, cache, m, progressReporter{}); err != nil { t.Fatalf("fetchMirror returned error: %v", err) diff --git a/internal/command/completion.go b/internal/command/completion.go index c3f8778..f7dca58 100644 --- a/internal/command/completion.go +++ b/internal/command/completion.go @@ -100,9 +100,6 @@ var commandCompletionFlags = map[string][]completionFlag{ {long: "--autostash"}, {long: "--keep"}, }, - string(cli.CommandSetup): { - {long: "--force", short: "-f"}, - }, string(cli.CommandStatus): {}, string(cli.CommandUpgradeConfig): {{long: "--no-commit"}}, } @@ -114,7 +111,6 @@ var rootCompletionCommands = []string{ string(cli.CommandDiff), string(cli.CommandPush), string(cli.CommandSync), - string(cli.CommandSetup), string(cli.CommandStatus), string(cli.CommandVersion), string(cli.CommandCompletion), @@ -230,7 +226,7 @@ func (h CompleteHandler) pathCandidatesForCommand(ctx context.Context, command s if len(state.positionals) == 1 { return pathCandidates(h.Options.WorkDir, line.current, false, "") } - case string(cli.CommandPull), string(cli.CommandRemove), string(cli.CommandDiff), string(cli.CommandPush), string(cli.CommandSetup), string(cli.CommandStatus): + case string(cli.CommandPull), string(cli.CommandRemove), string(cli.CommandDiff), string(cli.CommandPush), string(cli.CommandStatus): if len(state.positionals) == 0 { return h.mirrorPathCandidates(ctx, line.current, nil) } diff --git a/internal/command/diff.go b/internal/command/diff.go index 023b295..cdd8ea4 100644 --- a/internal/command/diff.go +++ b/internal/command/diff.go @@ -82,7 +82,7 @@ func (h DiffHandler) processDiffGit(repo RepoContext, inv cli.Invocation, trace } func (h DiffHandler) diffOne(ctx context.Context, git, processGit DiffGit, cache CacheConfig, m mirror.Mirror, options cli.DiffOptions, verbose bool, progress progressReporter, stdout, trace io.Writer) (err error) { - if err := setupOne(ctx, git, m, true, cache); err != nil { + if err := configureMirrorRemote(ctx, git, m, true, cache); err != nil { return err } if !options.Keep { diff --git a/internal/command/preflight.go b/internal/command/preflight.go index 57bd6d6..8b93d66 100644 --- a/internal/command/preflight.go +++ b/internal/command/preflight.go @@ -154,7 +154,6 @@ func NewAppWithOptions(options Options) cli.App { cli.CommandDiff: DiffHandler{Options: options}, cli.CommandPush: PushHandler{Options: options}, cli.CommandSync: SyncHandler{Options: options}, - cli.CommandSetup: SetupHandler{Options: options}, cli.CommandStatus: StatusHandler{Options: options}, cli.CommandCompletion: CompletionHandler{Options: options}, cli.CommandComplete: CompleteHandler{Options: options}, @@ -366,7 +365,7 @@ func RequirementsFor(command cli.Command) Requirements { switch command { case cli.CommandVersion, cli.CommandCompletion, cli.CommandComplete: return Requirements{} - case cli.CommandSetup, cli.CommandStatus, cli.CommandDiff: + case cli.CommandStatus, cli.CommandDiff: return Requirements{Git: true, Root: true, Config: true} case cli.CommandAdd: return Requirements{Git: true, Root: true, MayWrite: true} diff --git a/internal/command/preflight_test.go b/internal/command/preflight_test.go index 6a5dfb8..c821ba4 100644 --- a/internal/command/preflight_test.go +++ b/internal/command/preflight_test.go @@ -18,7 +18,6 @@ func TestRequirementsForPreflightMatrix(t *testing.T) { want Requirements }{ {command: cli.CommandVersion, want: Requirements{}}, - {command: cli.CommandSetup, want: Requirements{Git: true, Root: true, Config: true}}, {command: cli.CommandStatus, want: Requirements{Git: true, Root: true, Config: true}}, {command: cli.CommandDiff, want: Requirements{Git: true, Root: true, Config: true}}, {command: cli.CommandAdd, want: Requirements{Git: true, Root: true, MayWrite: true}}, diff --git a/internal/command/progress_integration_test.go b/internal/command/progress_integration_test.go index ca501dd..3f031df 100644 --- a/internal/command/progress_integration_test.go +++ b/internal/command/progress_integration_test.go @@ -8,7 +8,6 @@ import ( "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" "braid/internal/testutil" ) @@ -135,53 +134,6 @@ func TestProgressDiffHydrationKeepsDataOnStdoutAndQuietSuppresses(t *testing.T) assertNoProgressOutput(t, stderr) } -func TestProgressSetupReportsLocalRemoteChangesInPathOrder(t *testing.T) { - upstreamA := testutil.InitRepo(t) - testutil.WriteFile(t, upstreamA, "README.md", "a\n") - revisionA := testutil.CommitAll(t, upstreamA, "a") - upstreamZ := testutil.InitRepo(t) - testutil.WriteFile(t, upstreamZ, "README.md", "z\n") - revisionZ := testutil.CommitAll(t, upstreamZ, "z") - - repo := initDownstream(t) - cfg := config.Empty() - for _, m := range []mirror.Mirror{ - {Path: "vendor/z", URL: upstreamZ, Branch: "main", Revision: revisionZ}, - {Path: "vendor/a", URL: upstreamA, Branch: "main", Revision: revisionA}, - } { - if err := cfg.Add(m); err != nil { - t.Fatalf("add mirror config: %v", err) - } - } - if err := cfg.WriteFile(filepath.Join(repo, config.FileName)); err != nil { - t.Fatalf("write config: %v", err) - } - testutil.Git(t, repo, "add", config.FileName) - testutil.Git(t, repo, "commit", "-m", "configure mirrors") - - 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: fetching mirror") - - stdout, stderr = runCommandOKWithOutput(t, repo, []string{"setup"}) - assertEmptyOutput(t, "second setup stdout", stdout) - assertEmptyOutput(t, "second setup stderr", stderr) - - stdout, stderr = runCommandOKWithOutput(t, repo, []string{"--quiet", "setup", "--force"}) - assertEmptyOutput(t, "quiet force setup stdout", stdout) - assertNoProgressOutput(t, stderr) -} - func TestProgressPushReportsRemoteOperationsAndQuietPreservesResults(t *testing.T) { upstream := testutil.InitRepo(t) testutil.WriteFile(t, upstream, "README.md", "base\n") diff --git a/internal/command/push.go b/internal/command/push.go index e9379b6..5b447f3 100644 --- a/internal/command/push.go +++ b/internal/command/push.go @@ -93,7 +93,7 @@ func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m return pushResult{}, err } } - if err := setupOne(ctx, git, m, true, cache); err != nil { + if err := configureMirrorRemote(ctx, git, m, true, cache); err != nil { return pushResult{}, err } if !keep { diff --git a/internal/command/remote.go b/internal/command/remote.go new file mode 100644 index 0000000..c004272 --- /dev/null +++ b/internal/command/remote.go @@ -0,0 +1,79 @@ +package command + +import ( + "context" + "errors" + + "braid/internal/config" + "braid/internal/mirror" + "braid/internal/pathcheck" +) + +func configureMirrorRemote(ctx context.Context, git RemoteGit, m mirror.Mirror, force bool, cache CacheConfig) error { + return configureMirrorRemoteWithProgress(ctx, git, m, force, cache, progressReporter{}) +} + +func configureMirrorRemoteWithProgress(ctx context.Context, git RemoteGit, m mirror.Mirror, force bool, cache CacheConfig, progress progressReporter) error { + remote := m.Remote() + if _, ok, err := git.RemoteURL(ctx, remote); err != nil { + return err + } else if ok { + if !force { + return nil + } + return runProgress(progress, "Braid: setting up mirror remote "+m.Path, "Braid: set up mirror remote "+m.Path, func() error { + if err := git.RemoteRemove(ctx, remote); err != nil { + return err + } + return addMirrorRemote(ctx, git, m, cache) + }) + } + + return runProgress(progress, "Braid: setting up mirror remote "+m.Path, "Braid: set up mirror remote "+m.Path, func() error { + return addMirrorRemote(ctx, git, m, cache) + }) +} + +func addMirrorRemote(ctx context.Context, git RemoteGit, m mirror.Mirror, cache CacheConfig) error { + remote := m.Remote() + if err := git.RemoteAdd(ctx, remote, cache.RemoteURL(m)); err != nil { + return err + } + if m.PartialClone && cache.Enabled && cache.Mode == CacheModeRepositoryLocal { + if configurable, ok := git.(interface { + ConfigSet(context.Context, ...string) error + }); ok { + if err := configurable.ConfigSet(ctx, "remote."+remote+".promisor", "true"); err != nil { + return err + } + if err := configurable.ConfigSet(ctx, "remote."+remote+".partialclonefilter", "blob:none"); err != nil { + return err + } + } else { + return errors.New("git implementation cannot configure partial clone remote") + } + } + return nil +} + +func validateConfigPaths(cfg config.Config) error { + var existingPaths []string + var existingMirrors []mirror.Mirror + for _, localPath := range cfg.Paths() { + m := cfg.Mirrors[localPath] + if err := pathcheck.ValidateLocal(m.Path, existingPaths); err != nil { + return err + } + if m.RemotePath != "" { + if err := pathcheck.ValidateUpstream(m.RemotePath); err != nil { + return err + } + } + if err := pathcheck.CheckRemoteCollision(m, existingMirrors); err != nil { + return err + } + existingPaths = append(existingPaths, m.Path) + existingMirrors = append(existingMirrors, m) + } + return nil +} diff --git a/internal/command/remove_test.go b/internal/command/remove_test.go index bd7e57f..98fc449 100644 --- a/internal/command/remove_test.go +++ b/internal/command/remove_test.go @@ -21,8 +21,8 @@ func TestRemoveCommandDeletesContentConfigAndRemote(t *testing.T) { repo := initDownstream(t) runCommandOK(t, repo, []string{"add", upstream, "vendor/basic"}) - runCommandOK(t, repo, []string{"setup", "vendor/basic"}) remote := loadMirror(t, repo, "vendor/basic").Remote() + testutil.Git(t, repo, "remote", "add", remote, upstream) writeFailingPreCommitHook(t, repo) runCommandOK(t, repo, []string{"remove", "vendor/basic"}) @@ -111,8 +111,8 @@ func TestRemoveCommandKeepsRemote(t *testing.T) { repo := initDownstream(t) runCommandOK(t, repo, []string{"add", upstream, "vendor/basic"}) - runCommandOK(t, repo, []string{"setup", "vendor/basic"}) remote := loadMirror(t, repo, "vendor/basic").Remote() + testutil.Git(t, repo, "remote", "add", remote, upstream) runCommandOK(t, repo, []string{"remove", "vendor/basic", "--keep"}) remotes := testutil.Git(t, repo, "remote").Stdout @@ -128,8 +128,8 @@ func TestRemoveCommandNoCommitKeepKeepsRemoteOnly(t *testing.T) { repo := initDownstream(t) runCommandOK(t, repo, []string{"add", upstream, "vendor/basic"}) - runCommandOK(t, repo, []string{"setup", "vendor/basic"}) remote := loadMirror(t, repo, "vendor/basic").Remote() + testutil.Git(t, repo, "remote", "add", remote, upstream) runCommandOK(t, repo, []string{"remove", "vendor/basic", "--keep", "--no-commit"}) diff --git a/internal/command/setup.go b/internal/command/setup.go deleted file mode 100644 index a764cb1..0000000 --- a/internal/command/setup.go +++ /dev/null @@ -1,255 +0,0 @@ -package command - -import ( - "context" - "errors" - "io" - "os" - - "braid/internal/cli" - "braid/internal/config" - "braid/internal/gitexec" - "braid/internal/mirror" - "braid/internal/pathcheck" -) - -type SetupHandler struct { - Options Options -} - -func (h SetupHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { - ctx := context.Background() - repo, err := Preflight(ctx, cli.CommandSetup, inv, h.Options, stderr) - if err != nil { - return err - } - - git := h.remoteGit(repo, inv, stderr) - progress := newProgressReporter(stderr, inv.Global.Quiet) - cfg, err := config.Load(configRoot(h.Options, repo)) - if err != nil { - return err - } - if err := validateConfigPaths(cfg); err != nil { - return err - } - cache, err := runtimeCacheForRepo(ctx, repo, inv.Global, inv.Global.Verbose, stderr) - if err != nil { - return err - } - - if inv.Setup.LocalPath != "" { - localPath, err := normalizeLocalPath(repo, inv.Setup.LocalPath) - if err != nil { - return err - } - m, err := cfg.GetRequired(localPath) - 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() { - 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 - } - if git, ok := repo.rootGit(inv, h.Options, trace).(RemoteGit); ok { - return git - } - return gitexec.New(repo.GitWorkTreeRoot, inv.Global.Verbose, trace) -} - -func setupOne(ctx context.Context, git RemoteGit, m mirror.Mirror, force bool, cache CacheConfig) error { - return setupOneWithProgress(ctx, git, m, force, cache, progressReporter{}) -} - -func setupOneWithProgress(ctx context.Context, git RemoteGit, m mirror.Mirror, force bool, cache CacheConfig, progress progressReporter) error { - remote := m.Remote() - if _, ok, err := git.RemoteURL(ctx, remote); err != nil { - return err - } else if ok { - if !force { - return nil - } - return runProgress( - progress, - "Braid: setting up mirror remote "+m.Path, - "Braid: set up mirror remote "+m.Path, - func() error { - if err := git.RemoteRemove(ctx, remote); err != nil { - return err - } - return setupMirrorRemote(ctx, git, m, cache) - }, - ) - } - - return runProgress( - progress, - "Braid: setting up mirror remote "+m.Path, - "Braid: set up mirror remote "+m.Path, - func() error { - return setupMirrorRemote(ctx, git, m, cache) - }, - ) -} - -func setupMirrorRemote(ctx context.Context, git RemoteGit, m mirror.Mirror, cache CacheConfig) error { - remote := m.Remote() - if err := git.RemoteAdd(ctx, remote, cache.RemoteURL(m)); err != nil { - return err - } - if m.PartialClone && cache.Enabled && cache.Mode == CacheModeRepositoryLocal { - if configurable, ok := git.(interface { - ConfigSet(context.Context, ...string) error - }); ok { - if err := configurable.ConfigSet(ctx, "remote."+remote+".promisor", "true"); err != nil { - return err - } - if err := configurable.ConfigSet(ctx, "remote."+remote+".partialclonefilter", "blob:none"); err != nil { - return err - } - } else { - return errors.New("git implementation cannot configure partial clone remote") - } - } - return nil -} - -func validateConfigPaths(cfg config.Config) error { - var existingPaths []string - var existingMirrors []mirror.Mirror - for _, localPath := range cfg.Paths() { - m := cfg.Mirrors[localPath] - if err := pathcheck.ValidateLocal(m.Path, existingPaths); err != nil { - return err - } - if m.RemotePath != "" { - if err := pathcheck.ValidateUpstream(m.RemotePath); err != nil { - return err - } - } - if err := pathcheck.CheckRemoteCollision(m, existingMirrors); err != nil { - return err - } - existingPaths = append(existingPaths, m.Path) - existingMirrors = append(existingMirrors, m) - } - return nil -} diff --git a/internal/command/setup_test.go b/internal/command/setup_test.go deleted file mode 100644 index 17375e9..0000000 --- a/internal/command/setup_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package command - -import ( - "bytes" - "os" - "path/filepath" - "strings" - "testing" - - "braid/internal/cli" - "braid/internal/config" - "braid/internal/mirror" - "braid/internal/testutil" -) - -func TestResolveCacheContract(t *testing.T) { - env := func(values map[string]string) EnvLookup { - return func(key string) (string, bool) { - value, ok := values[key] - return value, ok - } - } - cwd := rootedTestPath("work") - home := rootedTestPath("home") - - tests := []struct { - name string - global cli.GlobalOptions - env map[string]string - want CacheConfig - wantErr string - }{ - {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 { - t.Run(test.name, func(t *testing.T) { - got, err := ResolveCache(test.global, env(test.env), cwd) - if test.wantErr != "" { - if err == nil || !strings.Contains(err.Error(), test.wantErr) { - t.Fatalf("ResolveCache error = %v, want containing %q", err, test.wantErr) - } - return - } - if err != nil { - t.Fatalf("ResolveCache returned error: %v", err) - } - if got != test.want { - t.Fatalf("ResolveCache = %#v, want %#v", got, test.want) - } - }) - } -} - -func rootedTestPath(elem ...string) string { - root := string(filepath.Separator) - if wd, err := os.Getwd(); err == nil { - root = filepath.VolumeName(wd) + root - } - parts := append([]string{root}, elem...) - return filepath.Join(parts...) -} - -func TestSetupCommandCreatesAllRemotes(t *testing.T) { - upstreamOne := testutil.InitRepo(t) - testutil.WriteFile(t, upstreamOne, "README.md", "one\n") - revisionOne := testutil.CommitAll(t, upstreamOne, "upstream one") - - upstreamTwo := testutil.InitRepo(t) - testutil.WriteFile(t, upstreamTwo, "README.md", "two\n") - revisionTwo := testutil.CommitAll(t, upstreamTwo, "upstream two") - - repo := testutil.InitRepo(t) - testutil.WriteFile(t, repo, "README.md", "downstream\n") - testutil.CommitAll(t, repo, "downstream") - - cfg := config.Empty() - for _, m := range []mirror.Mirror{ - {Path: "vendor/one", URL: upstreamOne, Branch: "main", Revision: revisionOne}, - {Path: "vendor/two", URL: upstreamTwo, Branch: "main", Revision: revisionTwo}, - } { - if err := cfg.Add(m); err != nil { - t.Fatalf("Add mirror config: %v", err) - } - } - if err := cfg.WriteFile(filepath.Join(repo, config.FileName)); err != nil { - t.Fatalf("Write config: %v", err) - } - - t.Setenv("HOME", t.TempDir()) - t.Chdir(repo) - - var stdout, stderr bytes.Buffer - code := NewAppWithOptions(Options{WorkDir: repo, ConfigRoot: repo}).Run([]string{"setup"}, &stdout, &stderr) - if code != 0 { - t.Fatalf("setup exit = %d, stderr = %q", code, stderr.String()) - } - for _, remote := range []string{"main_braid_vendor_one", "main_braid_vendor_two"} { - 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) - t.Setenv("HOME", t.TempDir()) - t.Chdir(repo) - - var stdout, stderr bytes.Buffer - code := NewAppWithOptions(Options{WorkDir: repo, ConfigRoot: repo}).Run([]string{"setup"}, &stdout, &stderr) - if code != 0 { - t.Fatalf("setup exit = %d, stderr = %q", code, stderr.String()) - } - - remote := "main_braid_vendor_repo" - firstURL := strings.TrimSpace(testutil.Git(t, repo, "remote", "get-url", remote).Stdout) - 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") - stderr.Reset() - code = NewAppWithOptions(Options{WorkDir: repo, ConfigRoot: repo}).Run([]string{"setup"}, &stdout, &stderr) - if code != 0 { - t.Fatalf("second setup exit = %d, stderr = %q", code, stderr.String()) - } - reusedURL := strings.TrimSpace(testutil.Git(t, repo, "remote", "get-url", remote).Stdout) - if reusedURL != "manually-kept" { - t.Fatalf("reused URL = %q, want manually-kept", reusedURL) - } - - stderr.Reset() - code = NewAppWithOptions(Options{WorkDir: repo, ConfigRoot: repo}).Run([]string{"setup", "--force"}, &stdout, &stderr) - if code != 0 { - t.Fatalf("force setup exit = %d, stderr = %q", code, stderr.String()) - } - forcedURL := strings.TrimSpace(testutil.Git(t, repo, "remote", "get-url", remote).Stdout) - if forcedURL == "manually-kept" { - t.Fatal("force setup did not replace existing remote") - } -} - -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) - - var stdout, stderr bytes.Buffer - app := NewAppWithOptions(Options{WorkDir: repo, ConfigRoot: repo}) - if code := app.Run([]string{"--no-cache", "setup", "vendor/repo"}, &stdout, &stderr); code != 0 { - t.Fatalf("setup --no-cache exit = %d, stderr = %q", code, stderr.String()) - } - remote := "main_braid_vendor_repo" - noCacheURL := strings.TrimSpace(testutil.Git(t, repo, "remote", "get-url", remote).Stdout) - cfg, err := config.Load(repo) - if err != nil { - t.Fatalf("Load config: %v", err) - } - if noCacheURL != cfg.Mirrors["vendor/repo"].URL { - t.Fatalf("remote URL = %q, want %q", noCacheURL, cfg.Mirrors["vendor/repo"].URL) - } - - testutil.Git(t, repo, "remote", "rm", remote) - stderr.Reset() - 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) - if err != nil { - t.Fatalf("canonicalize repo path: %v", err) - } - if !strings.HasPrefix(cacheURL, filepath.Join(canonicalRepo, "local-cache")) { - t.Fatalf("cache URL = %q, want under relative cache dir", cacheURL) - } -} - -func TestSetupCommandFromSubdirectoryUsesProcessRelativeGlobalCacheDir(t *testing.T) { - repo := setupRepoWithConfig(t) - workDir := filepath.Join(repo, "apps", "web") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("create workdir: %v", err) - } - t.Setenv("HOME", t.TempDir()) - t.Chdir(workDir) - - var stdout, stderr bytes.Buffer - 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" - cacheURL := strings.TrimSpace(testutil.Git(t, repo, "remote", "get-url", remote).Stdout) - canonicalWorkDir, err := filepath.EvalSymlinks(workDir) - if err != nil { - t.Fatalf("canonicalize workdir: %v", err) - } - if !strings.HasPrefix(cacheURL, filepath.Join(canonicalWorkDir, "local-cache")) { - t.Fatalf("cache URL = %q, want under process-relative cache dir", cacheURL) - } -} - -func setupRepoWithConfig(t *testing.T) string { - t.Helper() - upstream := testutil.InitRepo(t) - testutil.WriteFile(t, upstream, "README.md", "hello\n") - revision := testutil.CommitAll(t, upstream, "upstream") - - repo := testutil.InitRepo(t) - testutil.WriteFile(t, repo, "README.md", "downstream\n") - testutil.CommitAll(t, repo, "downstream") - - cfg := config.Empty() - if err := cfg.Add(mirror.Mirror{ - Path: "vendor/repo", - URL: upstream, - Branch: "main", - Revision: revision, - }); err != nil { - t.Fatalf("Add mirror config: %v", err) - } - if err := cfg.WriteFile(filepath.Join(repo, config.FileName)); err != nil { - t.Fatalf("Write config: %v", err) - } - return repo -} diff --git a/internal/command/status.go b/internal/command/status.go index fd66f3b..420b78c 100644 --- a/internal/command/status.go +++ b/internal/command/status.go @@ -73,7 +73,7 @@ func (h StatusHandler) statusOne(ctx context.Context, git StatusGit, cache Cache return err } } - if err := setupOne(ctx, git, m, true, cache); err != nil { + if err := configureMirrorRemote(ctx, git, m, true, cache); err != nil { return err } defer func() { diff --git a/internal/command/sync.go b/internal/command/sync.go index aad2710..af913fa 100644 --- a/internal/command/sync.go +++ b/internal/command/sync.go @@ -322,7 +322,7 @@ func (h SyncHandler) withFetchedMirrorForPlanning(ctx context.Context, git PushG return err } } - if err := setupOne(ctx, git, m, true, cache); err != nil { + if err := configureMirrorRemote(ctx, git, m, true, cache); err != nil { return err } if !keep { diff --git a/internal/command/update.go b/internal/command/update.go index aac748a..2a6a611 100644 --- a/internal/command/update.go +++ b/internal/command/update.go @@ -199,7 +199,7 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon return updateResult{}, err } } - if err := setupOne(ctx, git, m, true, cache); err != nil { + if err := configureMirrorRemote(ctx, git, m, true, cache); err != nil { return updateResult{}, err } cleanupRemote := func() error { diff --git a/internal/command/update_test.go b/internal/command/update_test.go index 4068b6e..a6d1d91 100644 --- a/internal/command/update_test.go +++ b/internal/command/update_test.go @@ -623,7 +623,7 @@ func TestUpdateCommandAllPrechecksEligibleMirrorsBeforeUpdating(t *testing.T) { t.Fatalf("vendor/b revision = %q, want unchanged %q", got, bBase) } if remotes := strings.TrimSpace(testutil.Git(t, repo, "remote").Stdout); remotes != "" { - t.Fatalf("remotes = %q, want no setup side effects", remotes) + t.Fatalf("remotes = %q, want no remote side effects", remotes) } } diff --git a/plans/partial-clone-cache/00-requirements.md b/plans/partial-clone-cache/00-requirements.md deleted file mode 100644 index 5e60541..0000000 --- a/plans/partial-clone-cache/00-requirements.md +++ /dev/null @@ -1,150 +0,0 @@ -# Partial-clone repository-local caches - -## Problem - -Repository-local mirror caches currently fetch the objects reachable from an -upstream revision. For mirrors that select one upstream subdirectory, large -blobs elsewhere in the repository add avoidable transfer and storage cost to -cache creation and refreshes. - -## Scope - -In scope: - -- Add an opt-in, per-mirror Git partial-clone mode backed specifically by - `--filter=blob:none`. -- Apply it only to repository-local caches and only to mirrors with an upstream - `path`. -- Upgrade `.braids.json` to config version 2. -- Add an explicit `upgrade-config` command for version 1 files. -- Cover CLI, config, cache lifecycle, command wiring, completion, docs, and - integration behavior. - -Out of scope: - -- Arbitrary Git object filters. -- Partial cloning for global caches or direct no-cache operation. -- Automatically choosing partial clone based on repository size. -- Enabling partial clone on an existing mirror through a command other than - editing config explicitly. -- Upgrading legacy formats without `config_version` or versions other than 1. - -## Interfaces - -### `braid add` - -- Add `--partial-clone` as a boolean flag. -- Reject it unless `--path ` is also specified. -- Persist `"partial_clone": true` on the new mirror. - -### `.braids.json` version 2 - -- Set `config_version` to `2` for all newly written configurations. -- Add optional mirror boolean `partial_clone`; omission means false. -- Reject `partial_clone: true` when the mirror has no non-empty `path`. -- Continue rejecting unknown fields and malformed mirror data. -- Ordinary commands reject version 1 with an actionable diagnostic directing - the user to `braid upgrade-config`. - -### `braid upgrade-config` - -- Accept optional `--no-commit` and no positional arguments. -- Fail when `.braids.json` is missing. -- Read an exact version 1 config, preserve its mirror semantics, and write the - canonical version 2 representation. The migration changes only the version - when no other canonical formatting difference exists. -- Require `.braids.json` to be clean in both the index and worktree; unrelated - changes are allowed. -- By default, create a commit containing only `.braids.json` with subject - `Upgrade Braid config to version 2`. -- With `--no-commit`, stage only `.braids.json` and do not commit. -- Preserve every unrelated index entry and worktree file byte-for-byte. On a - successful default upgrade, `.braids.json` matches the new `HEAD` and is clean - in both index and worktree. On a successful `--no-commit` upgrade, its v2 - content is staged and also present in the worktree. -- If writing, staging, tree construction, committing, or restoration fails, - restore the original `.braids.json` worktree bytes and index entry before - returning the primary error. If `HEAD` moved before a later synchronization - failure, compare-and-swap it back to its original OID before restoring the - config snapshots, without changing unrelated index/worktree state. Include a - rollback failure in the diagnostic. -- Treat an already-version-2 config as a successful no-op with concise output. -- Reject newer versions and unsupported older/missing-version layouts. - -## Cache behavior - -1. `partial_clone: true` is inactive when caching is disabled or the selected - cache is global; those modes keep their existing behavior without error. -2. Every command that hydrates a repository-local cache uses the mirror policy: - `add`, `pull`, `diff`, `push`, `sync`, `setup`, and `status`. -3. Upstream-to-cache hydration uses `--filter=blob:none`. The downstream fetch - also uses `--filter=blob:none` but connects directly to upstream: serving a - promisor cache through local upload-pack can recursively hydrate omitted - objects and hang. The cache remains responsible for revision/ref metadata. -4. The cache is configured as a promisor/partial-clone repository so missing - objects can be requested from upstream while Braid materializes the selected - subtree. -5. Cache validity includes the configured partial-clone mode. A mismatched - disposable cache is atomically rebuilt rather than reused, and changing mode - must not create a permanently orphaned cache path. -6. If upstream does not support object filtering, the operation fails clearly; - it must not silently fall back to a full fetch. -7. Existing full-cache behavior remains unchanged for mirrors where the field - is absent or false. -8. Downstream promisor configuration is operation-scoped: it is installed - before the filtered cache fetch, remains until the selected subtree is fully - materialized, and is removed with the temporary mirror remote. A later Braid - process must be able to recreate it and complete normally; ordinary Git use - must not retain a dead promisor remote. - -## Diagnostics - -- Version 1 on ordinary commands: state that an upgrade is required and name - `braid upgrade-config`. -- Invalid add/config combination: state that partial clone requires an upstream - path. -- Unsupported server filtering: state that the configured partial clone could - not be honored. -- Missing config during upgrade: state that `.braids.json` was not found. -- Preserve existing command cleanup semantics on fetch, commit, staging, and - remote-removal failures. - -## Acceptance criteria - -- [ ] Version 2 round-trips `partial_clone` and omits it when false. -- [ ] Version 1 is accepted only by the upgrade path and receives an actionable - error everywhere else. -- [ ] `add --partial-clone --path ...` persists the policy and rejects use - without `--path`. -- [ ] A partial repository-local cache and its downstream fetch omit unrelated - blobs while successfully materializing the configured subtree. -- [ ] A filter-incapable upstream fails rather than degrading to a full fetch. -- [ ] Cache mode mismatch triggers safe replacement at the same logical cache - location. -- [ ] Failed cache replacement leaves the last valid cache usable, including on - Windows directory-rename semantics, and interrupted swap artifacts are - recovered deterministically. -- [ ] Global and disabled caches ignore the persisted policy and retain current - behavior. -- [ ] `upgrade-config` commit, `--no-commit`, cleanliness, missing-file, no-op, - version-error, rollback, and unrelated-index-preservation behavior are - tested. -- [ ] CLI usage, Bash completion, README/config documentation, and migration - documentation describe the new contracts. -- [ ] Every required CI-parity check passes. - -## Decisions - -All design questions are resolved: - -- `partial_clone` / `--partial-clone` uses Git-native terminology while - intentionally supporting only `blob:none`. -- Partial clone requires an upstream path. -- Upgrade commits by default, supports `--no-commit`, isolates unrelated - changes, fails on a missing file, and is a no-op on v2. -- False is represented by omission. -- No compatibility fallback is provided for filter-incapable servers. - -## Open questions - -None. diff --git a/plans/partial-clone-cache/10-implementation-plan.md b/plans/partial-clone-cache/10-implementation-plan.md deleted file mode 100644 index fd0e697..0000000 --- a/plans/partial-clone-cache/10-implementation-plan.md +++ /dev/null @@ -1,176 +0,0 @@ -# Partial-clone cache implementation plan - -## Phase sequence - -1. Establish versioned config parsing and migration primitives. -2. Add CLI contracts and the `upgrade-config` handler. -3. Implement and verify partial-clone cache transport and validity. -4. Wire the mirror policy through add and every cache consumer. -5. Add end-to-end coverage and update user documentation. -6. Run exact CI-parity validation and inspect the final diff. - -## Implementation details - -### 1. Config model and version boundaries - -- Extend `mirror.Options` and `mirror.Mirror` with `PartialClone`. -- Set `config.CurrentVersion` to 2 and add the optional JSON field to v2 mirror - read/write types. -- Separate current loading from an exact v1 migration reader so ordinary config - loading never silently upgrades old data. -- Give v1 errors an actionable `upgrade-config` diagnostic and preserve the - distinct future-version error. -- Validate the invariant `PartialClone => RemotePath != ""` in both mirror - construction and config validation. -- Unit-test v1/v2 boundaries, stable JSON, false omission, unknown fields, and - invalid partial-clone combinations. - -### 2. CLI and upgrade command - -- Add `CommandUpgradeConfig`, `UpgradeConfigOptions`, parsing, main and command - usage, handler registration, and Bash completion entries. -- Add `--partial-clone` parsing and usage to `add`, including the `--path` - validation. -- Implement `UpgradeConfigHandler` using existing preflight, scoped cleanliness, - temporary-index commit, and no-commit staging patterns rather than adding a - second Git mutation path. -- Snapshot the original config worktree bytes and exact index entry before - mutation and snapshot the original `HEAD` OID. Default mode should hash - canonical v2 bytes, replace only the config item in `HEAD`'s tree, commit - through `CommitTreeWithTemporaryIndex`, then restore `.braids.json` from the - new `HEAD` so its real index and worktree are clean. `--no-commit` should write - the bytes and update only the real index entry for `.braids.json`. On a - failure before `HEAD` moves, restore both config snapshots. On a failure after - it moves, first compare-and-swap `HEAD` from the new OID to the original OID, - then restore both snapshots without touching unrelated state; combine any - rollback failure with the primary error. Force each boundary in tests, - including post-commit restoration failure, and assert original HEAD, config - index entry/worktree bytes, and unrelated changes are identical. -- Make upgrade locate the repository root, distinguish missing/v1/v2/future - files, render canonical v2 JSON, and commit only `.braids.json` by default. -- Test parsing, dispatch, output, clean/no-op/error cases, isolated commits, and - staged `--no-commit` results with unrelated changes present. - -### 3. Partial-clone cache mechanics - -- Keep `RepositoryCachePath` stable across policy changes. Record owned metadata - as `braid.cacheVersion=1` and `braid.partialClone=true|false`. Treat an - otherwise-valid existing cache with no Braid metadata as legacy full mode; - accept and annotate it when full mode is requested, rebuild it when partial - mode is requested, and rebuild corrupt/unknown metadata. -- Extend repository-local cache readiness/creation to detect a full-versus- - partial mismatch. While holding the existing per-mirror lock, retain the old - cache at a deterministic same-filesystem backup while configuring and - hydrating its replacement. Delete the backup only after hydration succeeds; - restore it after any failure. At lock acquisition, a backup always wins over - an incomplete stable replacement, so interruption deterministically restores - the last known-good cache. Never delete the last valid cache before the - replacement is usable. -- Configure the bare cache with the owned remote name `braid-upstream`, its URL, - `remote.braid-upstream.promisor=true`, and - `remote.braid-upstream.partialclonefilter=blob:none`; validate all four values - as part of partial-cache readiness. Issue filtered shallow/full fetches - through that named remote without a full-fetch fallback. -- For cache-to-downstream fetch, point the temporary mirror remote directly at - upstream, configure it with - `promisor=true` and `partialclonefilter=blob:none` and pass - `--filter=blob:none`. Keep that remote until tree construction and worktree - restoration have materialized the selected subtree, then remove it using the - existing cleanup path. A later command recreates the configuration rather - than leaving a dead promisor remote installed. -- Add a gitexec fetch-result method that returns captured stdout, stderr, exit - status, and error while retaining the simple `Fetch` wrapper for existing - callers. Partial hydration should reject both non-zero filter failures and - warning-success output indicating that filtering was not recognized. Verify - the postcondition with lazy fetching disabled (`GIT_NO_LAZY_FETCH=1`) and an - object enumeration such as `rev-list --objects --missing=print`; do not use an - object-existence command that can itself hydrate the known unrelated blob. - Add focused local smart-protocol fixtures with `uploadpack.allowFilter` - enabled and disabled, covering warning-success and hard-rejection results - without network remotes or global Git config. -- Ensure cache cleanup/replacement and temporary remote cleanup remain correct - after config, fetch, validation, candidate-promotion, backup-restoration, and - downstream-materialization failures. - -### 4. Command wiring and behavior tests - -- Persist the add flag through `mirror.NewFromOptions` and config serialization. -- Ensure shared hydration honors the policy for add, pull, diff, push, sync, - setup, and status while global/no-cache modes remain unchanged. -- Verify revision-, branch-, and tag-tracked mirrors continue resolving refs - correctly through a partial cache. -- Verify selected-path trees and blobs materialize, unrelated large blobs remain - absent from the cache/downstream object store with lazy fetching disabled, - and subsequent pulls in a new process recreate temporary promisor state and - reuse the valid cache after normal remote cleanup. -- Verify manually changing policy causes cache replacement rather than path - proliferation or silent full-cache reuse. - -### 5. Documentation and compatibility surface - -- Document config version 2, `partial_clone`, its upstream-path restriction, - cache-mode scope, server requirement, and the `add` flag in `README.md`. -- Update `docs/migration-from-ruby-braid.md` where it currently says the Go - implementation has no `upgrade-config`; keep the distinction from Ruby - legacy-format upgrades explicit. -- Update completion and usage snapshots/tests. - -## High-risk areas - -- Git may return success while warning that filtering was not recognized. - Mitigation: test the exact supported and unsupported local protocol behavior, - retain command stderr/result evidence where necessary, and assert that the - resulting repository is genuinely configured and operating as a promisor. -- Both transport operations must remain filtered even though the downstream - connects directly to upstream. Mitigation: assert absence of a known - unrelated blob after materialization. -- Lazy object retrieval can fail if the temporary remote is removed too early. - Mitigation: exercise add and pull through worktree restoration, verify the - selected subtree contents before cleanup, and retain existing cleanup tests. -- Raising `CurrentVersion` can accidentally make migration parsing unreachable. - Mitigation: keep explicit current and exact-v1 entry points with boundary - tests. -- Upgrade commits can absorb unrelated staged or working changes. - Mitigation: reuse temporary-index/scoped staging machinery and inspect commit - trees in tests. -- Cache policy changes can leave obsolete data or stale remote configuration. - Mitigation: stable cache identity, explicit validity metadata, recoverable rebuild, - and setup/readiness regression tests. - -## Validation strategy - -Run narrow checks after each task, such as: - -- `bazel test //internal/config:config_test //internal/mirror:mirror_test` -- `bazel test //internal/cli:cli_test //internal/command:command_test` -- focused `bazel test` filters for cache, add, pull, setup, completion, and - upgrade cases as targets permit -- `bazel test //integration/...` - -Before declaring implementation ready, run the repository-mandated checks in -this order: - -1. `bazel run @rules_go//go -- fmt ./...` -2. `git diff --exit-code` (after formatting, to prove formatting made no - unreviewed changes; inspect the intended feature diff separately) -3. `git --version` -4. `git merge-tree --write-tree "--merge-base=HEAD^{tree}" "HEAD^{tree}" "HEAD^{tree}"` -5. `bazel test --test_env=PATH //...` -6. `bazel run @rules_go//go -- vet ./...` -7. `bazel run @rules_go//go -- run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 run` -8. `bazel test --test_env=PATH //integration/...` - -Because the implementation itself will have an intentional diff, the CI -format check should be reproduced by recording the pre-format diff, running -format, and confirming no additional diff, or from a clean implementation -commit/worktree. Do not misreport `git diff --exit-code` against intentional -uncommitted changes as a formatting failure. - -## Completion criteria - -- All task-board acceptance criteria are met and evidence is recorded. -- No open requirements questions remain. -- Exact CI-parity checks pass, or any unavailable check and residual risk are - reported precisely. -- The final diff contains no scratch code or unrelated changes. -- No commit or pull request is created unless explicitly requested. diff --git a/plans/partial-clone-cache/20-task-board.yaml b/plans/partial-clone-cache/20-task-board.yaml deleted file mode 100644 index acba732..0000000 --- a/plans/partial-clone-cache/20-task-board.yaml +++ /dev/null @@ -1,157 +0,0 @@ -meta: - project: partial-clone-cache - last_updated: 2026-07-11 - required_full_gates: - - bazel run @rules_go//go -- fmt ./... - - git diff --exit-code - - git --version - - 'git merge-tree --write-tree "--merge-base=HEAD^{tree}" "HEAD^{tree}" "HEAD^{tree}"' - - bazel test --test_env=PATH //... - - bazel run @rules_go//go -- vet ./... - - bazel run @rules_go//go -- run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 run - - bazel test --test_env=PATH //integration/... - statuses: - - pending - - in_progress - - completed - - cancelled - -current_focus: complete - -tasks: - - id: T01 - title: Add config v2 model and exact v1 migration parsing - status: completed - priority: high - depends_on: [] - acceptance_criteria: - - Version 2 strictly reads and writes optional partial_clone - - partial_clone requires a non-empty upstream path - - Ordinary loading rejects v1 with an actionable upgrade diagnostic - - Exact v1 migration parsing preserves all mirror semantics - files_touched: - - internal/config/config.go - - internal/config/config_test.go - - internal/mirror/mirror.go - - internal/mirror/mirror_test.go - evidence: [] - commit: - hash: not_required - message: not_required - - - id: T02 - title: Add CLI contracts and upgrade-config behavior - status: completed - priority: high - depends_on: [T01] - acceptance_criteria: - - add accepts --partial-clone only with --path - - upgrade-config commits only .braids.json by default - - --no-commit stages only .braids.json - - Missing, dirty, v2 no-op, and unsupported-version cases are covered - - Failures restore original config worktree bytes and index entry - - Post-commit failure restores the original HEAD before config snapshots - - Unrelated staged and worktree changes remain byte-for-byte unchanged - files_touched: - - internal/cli/cli.go - - internal/cli/cli_test.go - - internal/command/upgrade_config.go - - internal/command/upgrade_config_test.go - - internal/command/no_commit.go - - internal/command/completion.go - - internal/command/completion_test.go - - internal/command/preflight.go - evidence: [] - commit: - hash: not_required - message: not_required - - - id: T03 - title: Implement repository-local partial-clone cache lifecycle - status: completed - priority: high - depends_on: [T01] - acceptance_criteria: - - Both cache transport legs use blob:none for configured mirrors - - Cache is a valid promisor repository that can lazily obtain selected blobs - - Filter-incapable upstream fails without full-fetch fallback - - Policy mismatch atomically rebuilds the stable cache path - - Interrupted and failed swaps preserve or recover the last valid cache - - Cache metadata and promisor remote configuration are validated explicitly - - Partial downstream fetch connects directly to upstream and remains filtered - files_touched: - - internal/command/cache.go - - internal/command/cache_test.go - - internal/command/setup.go - - internal/command/setup_test.go - - internal/gitexec/gitexec.go - - internal/gitexec/gitexec_test.go - evidence: [] - commit: - hash: not_required - message: not_required - - - id: T04 - title: Wire partial clone through commands and add behavioral coverage - status: completed - priority: high - depends_on: [T02, T03] - acceptance_criteria: - - add persists partial_clone and materializes only the configured subtree - - Pull and every shared cache hydration consumer honor the mirror policy - - Global and disabled cache modes retain existing full-fetch behavior - - Branch, tag, and revision tracking remain correct - - Temporary downstream promisor state is cleaned and recreated by a later process - files_touched: - - internal/command/add.go - - internal/command/add_test.go - - internal/command/update_test.go - - internal/command/diff_test.go - - internal/command/push_test.go - - internal/command/sync_test.go - - internal/command/status_test.go - - integration/lifecycle_test.go - - integration/support.go - evidence: [] - commit: - hash: not_required - message: not_required - - - id: T05 - title: Update user and migration documentation - status: completed - priority: medium - depends_on: [T02, T04] - acceptance_criteria: - - README documents config v2, add flag, cache scope, and server requirement - - Migration guide accurately distinguishes JSON v1 upgrade from Ruby legacy upgrades - - CLI usage and completion surfaces are synchronized - files_touched: - - README.md - - docs/migration-from-ruby-braid.md - evidence: [] - commit: - hash: not_required - message: not_required - - - id: T06 - title: Run CI parity and final implementation review - status: completed - priority: high - depends_on: [T05] - acceptance_criteria: - - All mandated CI-parity commands pass - - Final diff contains only intentional changes and no scratch artifacts - - Task evidence and plan alignment are current - files_touched: - - plans/partial-clone-cache/20-task-board.yaml - evidence: [] - commit: - hash: not_required - message: not_required - -history: - - timestamp: 2026-07-11 - task: T01-T06 - commit: not_required - note: Implementation and three-round alignment review completed; final CI-parity gates passed.