diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 5268572e2..b51ff79d5 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -403,9 +403,11 @@ Before merging, the stack is checked for branches whose base PR was already merged on the forge. Use --no-branch-check to skip this validation. -Before each merge, waits for CI checks to pass. -Use --build-timeout to configure the maximum wait -before failing if checks are not ready. +Before each merge, waits for merge readiness: +the forge must observe the pushed head +and report that the CR is ready to merge. +Use --ready-timeout to configure the maximum wait +before failing if merge readiness is not reached. By default, a branch failure skips that branch's upstack descendants, but independent sibling branches continue. @@ -414,12 +416,12 @@ Use --fail-fast to stop the queue after the first branch failure. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. * `--no-branch-check`: Skip stale base validation before merging. * `--fail-fast`: Stop the merge queue after the first branch failure. * `--branch=NAME`: Branch whose stack to merge -**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) +**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) ### git-spice stack restack {#gs-stack-restack} @@ -717,7 +719,7 @@ This command acts as a local merge queue: it merges one Change Request, waits for that merge to finish, restacks and updates the next Change Request, -waits for its CI checks to pass, +waits for merge readiness on the updated Change Request, and then repeats the process. For a stack like this: @@ -735,13 +737,15 @@ Before merging, the downstack is checked for branches whose base PR was already merged on the forge. Use --no-branch-check to skip this validation. -Before each merge, waits for CI checks to pass. -Use --build-timeout to configure the maximum wait +Before each merge, waits for merge readiness: +the forge must observe the pushed head +and report that the CR is ready to merge. +Use --ready-timeout to configure the maximum wait (default: 30m, 0 means fail immediately if not ready). Between merges, the command waits for each merge to complete, restacks and updates the next PR, -waits for CI checks on the updated PR, +waits for merge readiness on the updated PR, and syncs merged branch cleanup. Use --no-wait for single branch merging @@ -751,12 +755,12 @@ when you don't want to wait for the merge to propagate. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. * `--no-wait`: Skip polling for a single branch merge to propagate. * `--no-branch-check`: Skip stale base validation before merging. * `--branch=NAME`: Branch to start merging from -**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) +**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) ### git-spice downstack edit {#gs-downstack-edit} @@ -1226,16 +1230,18 @@ Use --branch to merge a different branch. The branch must be based directly on trunk. To merge a stacked branch, use 'gs downstack merge'. -Before merging, waits for CI checks to pass. -Use --build-timeout to configure the maximum wait. +Before merging, waits for merge readiness: +the forge must observe the pushed head +and report that the CR is ready to merge. +Use --ready-timeout to configure the maximum wait. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. * `--branch=NAME`: Branch to merge -**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) +**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) ### git-spice branch submit {#gs-branch-submit} diff --git a/doc/mkdocs.yml b/doc/mkdocs.yml index aa4285c58..4f049f63d 100644 --- a/doc/mkdocs.yml +++ b/doc/mkdocs.yml @@ -76,6 +76,7 @@ nav: - guide/concepts.md - guide/branch.md - guide/cr.md + - guide/worktrees.md - guide/limits.md - guide/troubleshooting.md - guide/internals.md diff --git a/doc/src/guide/worktrees.md b/doc/src/guide/worktrees.md new file mode 100644 index 000000000..45f141690 --- /dev/null +++ b/doc/src/guide/worktrees.md @@ -0,0 +1,178 @@ +--- +title: Parallel worktrees +icon: octicons/git-branch-16 +description: >- + Work on a stack from several worktrees at once with anchors, and take the + whole repository for yourself with exclusive mode. +--- + +# Parallel worktrees + + + +A single git-spice repository can be worked on from several +[Git worktrees](https://git-scm.com/docs/git-worktree) at once: +one per agent, one for CI, one for you. +The worktrees are the coordination mechanism. +Git already refuses to check out the same branch in two worktrees, +so each worktree owns the slice of the stack it has checked out, +and git-spice makes that ownership legible +instead of layering a separate lock on top. + +This page covers two halves of that model: + +- **[Anchors](#anchors)** let many processes share one stack in parallel, + each scoped to its own region. +- **[Exclusive mode](#exclusive-mode)** lets one process take the whole + repository to reorganize the stack without contention. + +## Anchors + +An **anchor** is the branch a worktree is anchored at: +the lower boundary of the region that worktree works on. +Stacks created in the worktree build on top of the anchor, +so `gs repo sync` and restacks in different worktrees +never contend on a single shared trunk checkout. + +There are two flavors: + +- A **root anchor** is a per-worktree pointer branch that tracks the same + remote trunk as the main checkout. + Because it is its own branch, each worktree can update its view of the + trunk independently. +- An **internal anchor** pins a worktree at an existing tracked branch + owned by another worktree: + a dependent worktree, for building on top of work that is still in flight. + +### Creating a worktree + +Use $$gs anchor create$$ to create a worktree and its anchor in one step. + +```freeze language="terminal" float="right" +{green}${reset} gs anchor create ../feat +{green}INF{reset} Created worktree at ../feat +{green}INF{reset} Created anchor feat tracking main +``` + +By default this creates a root anchor: +a pointer branch named after the worktree directory +(or set with `--name`) that tracks the remote trunk. + +```bash +gs anchor create ../feat +``` + +Pass `-b`/`--branch` to also create and check out a tracked branch +stacked on the anchor, ready to work on: + +```bash +gs anchor create ../feat -b feat-login +``` + +### Dependent worktrees + +To build on top of a branch that is still under review in another worktree, +anchor the new worktree on that branch with `--anchor`: + +```bash +gs anchor create ../feat-ui --anchor feat-login -b feat-ui +``` + +The branch named by `--anchor` must already be tracked by git-spice. +The new worktree's stack is based on it, +so syncing the dependency forward flows into the dependent work. + +!!! note + + Use `--no-anchor` to skip the anchor entirely and start the worktree + in detached `HEAD` at the current trunk commit, + matching plain `git worktree add` behavior. + +### Managing worktrees + +| Command | Purpose | +|---------|---------| +| $$gs anchor list$$ | List the registered anchors, the worktree that owns each, and whether it is a root or internal anchor. | +| $$gs anchor track$$ | Register an existing worktree's anchor branch with git-spice. | +| $$gs anchor rm$$ | Remove a worktree and dissolve its anchor. | + +$$gs anchor rm$$ refuses to remove a worktree with uncommitted changes +unless `--force` is given. +Run it from a different worktree (for example, the primary checkout): +a worktree cannot remove itself. +A worktree may own only one anchor, +so $$gs anchor track$$ refuses to register a second anchor for it. + +## Exclusive mode + +Anchors keep parallel processes out of each other's way, +but sometimes you need to step back and reorganize the whole stack: +re-order branches, fold several together, or run an interactive rebase +across the trunk. +Those operations touch branches that other worktrees have checked out, +which Git will refuse. + +**Exclusive mode** hands the entire repository to a single process. +$$gs repo park$$ records every linked worktree in a durable manifest +and removes its directory; the branches themselves are left untouched, +so the whole graph stays reachable from the primary checkout. +You reorganize freely, then $$gs repo restore$$ re-creates the worktrees +at their branches' current tips. + +```freeze language="terminal" +{green}${reset} gs repo park +{green}INF{reset} Parked worktree ../feat +{green}INF{reset} Parked worktree ../feat-ui +{green}INF{reset} Parked 2 worktree(s); repository is in exclusive mode +{gray}# reorganize the stack here{reset} +{green}${reset} gs repo restore +{green}INF{reset} Restored worktree ../feat +{green}INF{reset} Restored worktree ../feat-ui +{green}INF{reset} Restored 2 worktree(s); exclusive mode cleared +``` + +The manifest is written **before** any worktree is removed, +and records each worktree's path, branch, `HEAD` commit, and anchor. +If a park or restore is interrupted partway through, +re-running the same command finishes the job: +park resumes from where it stopped, +and restore skips worktrees that already exist. +Nothing is lost to a `Ctrl-C` mid-reorganization. + +A reorganization may delete or rename a parked branch — +an interactive rebase that drops it, for example. +git-spice cannot know where that work landed in the rewritten graph, +so $$gs repo restore$$ does not guess: +it restores every other worktree, +stays in exclusive mode, +and reports the missing branch. +Recover by re-creating the branch and re-running $$gs repo restore$$, +or discard that worktree with `gs repo restore --forget `. +Each parked commit is preserved under `refs/gs-park/` +so it is not lost to garbage collection before you recover it. +A worktree parked in detached `HEAD` is restored at its recorded commit. + +While the repository is parked, $$gs anchor create$$ is refused — +new worktrees would race with the reorganization in progress. +Run $$gs repo restore$$ first. + +!!! warning "Uncommitted changes" + + $$gs repo park$$ refuses to park a worktree with uncommitted changes. + Commit them first, or pass `--force` to discard them. + +### Wrapping a single command + +To take exclusive mode for just one command, +use $$gs repo exclusive$$. +It parks the worktrees, runs the command, and always restores them +afterward — even if the command fails. + +Separate the command from git-spice's own flags with `--`: + +```bash +gs repo exclusive -- git rebase -i main +``` + +This is the safe way to run a one-off reorganization +without leaving the repository parked if something goes wrong. diff --git a/internal/handler/sync/handler_test.go b/internal/handler/sync/handler_test.go index c103e149b..04d54cc0c 100644 --- a/internal/handler/sync/handler_test.go +++ b/internal/handler/sync/handler_test.go @@ -768,7 +768,7 @@ func TestHandler_SyncTrunk_deletesCurrentBranchInAnchorWorktree(t *testing.T) { mockRestack := NewMockRestackHandler(ctrl) mockRestack.EXPECT(). - RestackBranch(gomock.Any(), "child"). + RestackBranch(gomock.Any(), "child", gomock.Nil()). Return(nil) mockAutostash := NewMockAutostashHandler(ctrl) diff --git a/internal/handler/sync/mocks_test.go b/internal/handler/sync/mocks_test.go index bc8f1b22f..5092e04f4 100644 --- a/internal/handler/sync/mocks_test.go +++ b/internal/handler/sync/mocks_test.go @@ -820,17 +820,17 @@ func (m *MockRestackHandler) EXPECT() *MockRestackHandlerMockRecorder { } // RestackBranch mocks base method. -func (m *MockRestackHandler) RestackBranch(ctx context.Context, branch string) error { +func (m *MockRestackHandler) RestackBranch(ctx context.Context, branch string, opts *restack.Options) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RestackBranch", ctx, branch) + ret := m.ctrl.Call(m, "RestackBranch", ctx, branch, opts) ret0, _ := ret[0].(error) return ret0 } // RestackBranch indicates an expected call of RestackBranch. -func (mr *MockRestackHandlerMockRecorder) RestackBranch(ctx, branch any) *MockRestackHandlerRestackBranchCall { +func (mr *MockRestackHandlerMockRecorder) RestackBranch(ctx, branch, opts any) *MockRestackHandlerRestackBranchCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestackBranch", reflect.TypeOf((*MockRestackHandler)(nil).RestackBranch), ctx, branch) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestackBranch", reflect.TypeOf((*MockRestackHandler)(nil).RestackBranch), ctx, branch, opts) return &MockRestackHandlerRestackBranchCall{Call: call} } @@ -846,13 +846,13 @@ func (c *MockRestackHandlerRestackBranchCall) Return(arg0 error) *MockRestackHan } // Do rewrite *gomock.Call.Do -func (c *MockRestackHandlerRestackBranchCall) Do(f func(context.Context, string) error) *MockRestackHandlerRestackBranchCall { +func (c *MockRestackHandlerRestackBranchCall) Do(f func(context.Context, string, *restack.Options) error) *MockRestackHandlerRestackBranchCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRestackHandlerRestackBranchCall) DoAndReturn(f func(context.Context, string) error) *MockRestackHandlerRestackBranchCall { +func (c *MockRestackHandlerRestackBranchCall) DoAndReturn(f func(context.Context, string, *restack.Options) error) *MockRestackHandlerRestackBranchCall { c.Call = c.Call.DoAndReturn(f) return c }