diff --git a/.changes/unreleased/Added-20260619-114007.yaml b/.changes/unreleased/Added-20260619-114007.yaml new file mode 100644 index 000000000..eb9ede7d8 --- /dev/null +++ b/.changes/unreleased/Added-20260619-114007.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'anchor: Restack and log commands are anchor-aware: ''gs repo restack'' also restacks stacks rooted at worktree anchors, ''gs ll'' shows another worktree''s anchor-rooted stack tagged by anchor kind (with ''anchor'' and ''anchorKind'' JSON fields), and checking out a branch already in use by another worktree fails with a clear message.' +time: 2026-06-19T11:40:07.895977-04:00 diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index ea768de3e..442c81b0b 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -316,9 +316,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. @@ -327,12 +329,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} @@ -630,7 +632,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: @@ -648,13 +650,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 @@ -664,12 +668,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} @@ -1139,16 +1143,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/internal/git/branch_wt.go b/internal/git/branch_wt.go index 276067974..318908b3b 100644 --- a/internal/git/branch_wt.go +++ b/internal/git/branch_wt.go @@ -12,6 +12,17 @@ import ( // unexpectedly in detached HEAD state. var ErrDetachedHead = errors.New("in detached HEAD state") +// BranchInUseError indicates that a branch could not be checked out +// because it is already checked out in another worktree. +type BranchInUseError struct { + Branch string // branch that is in use + Worktree string // path to the worktree that holds it +} + +func (e *BranchInUseError) Error() string { + return fmt.Sprintf("branch %v is in use by worktree %v", e.Branch, e.Worktree) +} + // CurrentBranch reports the current branch name. // It returns [ErrDetachedHead] if the repository is in detached HEAD state. func (w *Worktree) CurrentBranch(ctx context.Context) (string, error) { @@ -73,6 +84,18 @@ func (w *Worktree) DetachHead(ctx context.Context, commitish string) error { func (w *Worktree) CheckoutBranch(ctx context.Context, branch string) error { w.log.Debug("Checking out branch", "name", branch) + // Git refuses to check out a branch already checked out in another + // worktree. Detect that up front and return a legible error naming + // the worktree, rather than git's raw 'already used by worktree' fatal. + for wt, err := range w.repo.Worktrees(ctx) { + if err != nil { + return fmt.Errorf("list worktrees: %w", err) + } + if wt.Branch == branch && wt.Path != w.rootDir { + return &BranchInUseError{Branch: branch, Worktree: wt.Path} + } + } + if err := w.runGitWithIndexLockRetry(ctx, func() *gitCmd { return w.gitCmd(ctx, "checkout", branch) }); err != nil { diff --git a/internal/git/checkout_wt_test.go b/internal/git/checkout_wt_test.go index 7db5816e8..efa97e284 100644 --- a/internal/git/checkout_wt_test.go +++ b/internal/git/checkout_wt_test.go @@ -71,3 +71,38 @@ func TestWorktree_CheckoutFiles(t *testing.T) { assert.Equal(t, "Feature content", strings.TrimSpace(string(content))) }) } + +func TestWorktree_CheckoutBranch_inUseByOtherWorktree(t *testing.T) { + t.Parallel() + + fixture, err := gittest.LoadFixtureScript([]byte(text.Dedent(` + git init + git add init.txt + git commit -m 'Initial commit' + git checkout -b feature + git checkout main + + -- init.txt -- + Initial content + `))) + require.NoError(t, err) + + wt, err := git.OpenWorktree(t.Context(), fixture.Dir(), git.OpenOptions{ + Log: silogtest.New(t), + }) + require.NoError(t, err) + + // Check out 'feature' in a second worktree so it is in use there. + otherDir := filepath.Join(t.TempDir(), "other") + require.NoError(t, wt.Repository().WorktreeAdd(t.Context(), git.WorktreeAddRequest{ + Path: otherDir, + Head: "feature", + })) + + // Checking out 'feature' from the main worktree is refused legibly. + err = wt.CheckoutBranch(t.Context(), "feature") + var inUse *git.BranchInUseError + require.ErrorAs(t, err, &inUse) + assert.Equal(t, "feature", inUse.Branch) + assert.Equal(t, "other", filepath.Base(inUse.Worktree)) +} diff --git a/internal/handler/list/handler.go b/internal/handler/list/handler.go index 8e5991e8f..0110b6576 100644 --- a/internal/handler/list/handler.go +++ b/internal/handler/list/handler.go @@ -39,6 +39,9 @@ type Store interface { // TrunkFor returns the trunk branch for the given worktree root: // the worktree's registered trunk if any, else the canonical trunk. TrunkFor(worktreePath string) string + + // Anchors returns all registered anchors (per-worktree trunks). + Anchors() []state.Anchor } var _ Store = (*state.Store)(nil) @@ -160,6 +163,17 @@ type BranchItem struct { // NeedsRestack indicates whether this branch needs to be restacked // on top of its base branch. NeedsRestack bool + + // Anchor reports whether this item is an anchor branch: + // a per-worktree trunk that roots another worktree's stack. + // Anchor items are injected as display nodes; they are not + // tracked stack branches. + Anchor bool + + // AnchorBase is the branch an internal anchor is pinned at. + // Empty for a root anchor (which tracks the remote trunk). + // Only meaningful when Anchor is true. + AnchorBase string } // PushStatus contains push-related information @@ -376,6 +390,47 @@ func (h *Handler) ListBranches(ctx context.Context, req *BranchesRequest) (*Bran items = append(items, trunkItem) itemByName[trunkItem.Name] = trunkItem + // Inject anchor nodes for any displayed branch rooted at an anchor. + // Anchors are per-worktree trunks: graph roots that are not tracked + // stack branches, so they are absent from the listing. Show each one + // as an intermediate node under its base (the canonical trunk for a + // root anchor, or the pinned branch for an internal anchor) so the + // owning worktree's stack renders beneath it. + anchorByBranch := make(map[string]state.Anchor) + for _, a := range h.Store.Anchors() { + anchorByBranch[a.Branch] = a + } + for _, item := range items { + anchor, ok := anchorByBranch[item.Base] + if !ok { + continue // base is a normal branch or the trunk + } + if _, shown := itemByName[anchor.Branch]; shown { + continue // already injected + } + + // An internal anchor is normally rooted at its pinned base, but + // that base may be absent from a filtered view (e.g. it lives + // downstack in another worktree). Falling through to the linking + // step below would drop the anchor's edge and silently hide its + // whole subtree, so root it at the display trunk instead. The + // pinned base is always a tracked branch, so if it is displayed + // it is already in itemByName at this point. + base := cmp.Or(anchor.Base, displayTrunk) + if _, shown := itemByName[base]; !shown { + base = displayTrunk + } + anchorItem := &BranchItem{ + Name: anchor.Branch, + Base: base, + Worktree: anchor.Worktree, + Anchor: true, + AnchorBase: anchor.Base, + } + items = append(items, anchorItem) + itemByName[anchor.Branch] = anchorItem + } + slices.SortFunc(items, func(a, b *BranchItem) int { return strings.Compare(a.Name, b.Name) }) diff --git a/internal/handler/restack/handler.go b/internal/handler/restack/handler.go index 748c50d91..bae76e80d 100644 --- a/internal/handler/restack/handler.go +++ b/internal/handler/restack/handler.go @@ -31,6 +31,10 @@ var _ GitWorktree = (*git.Worktree)(nil) // Store is a subset of the state.Store interface. type Store interface { Trunk() string + + // TrunkFor returns the trunk in effect for a worktree: its anchor + // if it has one, else the canonical trunk. + TrunkFor(worktree string) string } // Service is a subset of the spice.Service interface. @@ -106,6 +110,16 @@ type Request struct { // checked out in the given worktree. WorktreeFilter string + // WholeRepo also adds every stack rooted at an anchor + // (a per-worktree trunk) to the restack set, + // regardless of req.Branch. + // Anchors are graph roots disconnected from the canonical trunk, + // so an upstack-from-trunk traversal never reaches them. + // + // Combine with WorktreeFilter to restack the anchor-rooted stack + // of a specific worktree. + WholeRepo bool + // SkipCheckout skips checking out req.Branch // after restacking completes. // Use this when the caller handles checkout itself. @@ -189,6 +203,21 @@ func (h *Handler) Restack(ctx context.Context, req *Request) (int, error) { } } + // A whole-repo restack also covers stacks rooted at anchors + // (per-worktree trunks). Anchors are graph roots disconnected + // from the canonical trunk, so an upstack-from-trunk traversal + // never reaches them; walk each anchor's upstack explicitly. + if req.WholeRepo { + for anchor := range branchGraph.Anchors() { + for branch := range branchGraph.Upstack(anchor) { + if branch == anchor { + continue // anchor is a root; never restacked + } + branchesToRestack = append(branchesToRestack, branch) + } + } + } + // If a worktree filter is active, // keep only branches belonging to stacks // with at least one branch in the target worktree. @@ -202,6 +231,18 @@ func (h *Handler) Restack(ctx context.Context, req *Request) (int, error) { } } + // A worktree owns the stack rooted at its anchor even when no + // tracked branch from that stack is currently checked out here + // (e.g. the anchor itself is checked out). StacksInWorktree only + // sees checked-out tracked branches, so add the anchor's upstack + // explicitly; otherwise '-w' from an anchor worktree restacks + // nothing. + if anchor := h.Store.TrunkFor(req.WorktreeFilter); anchor != h.Store.Trunk() { + for branch := range branchGraph.Upstack(anchor) { + allowed[branch] = struct{}{} + } + } + filtered := branchesToRestack[:0] for _, branch := range branchesToRestack { if _, ok := allowed[branch]; ok { diff --git a/internal/handler/restack/handler_test.go b/internal/handler/restack/handler_test.go index 3d5b1c194..c4a0841d7 100644 --- a/internal/handler/restack/handler_test.go +++ b/internal/handler/restack/handler_test.go @@ -602,6 +602,64 @@ func TestHandler_Restack_trunk(t *testing.T) { }) } +func TestHandler_Restack_wholeRepo(t *testing.T) { + // A whole-repo restack must reach stacks rooted at an anchor, + // not just those reachable from the canonical trunk. + // + // main ---> other + // wt-a ---> feat1 --> feat2 (anchor-rooted; disconnected from main) + t.Run("ReachesAnchorStacks", func(t *testing.T) { + var logBuffer bytes.Buffer + log := silog.New(&logBuffer, nil) + ctrl := gomock.NewController(t) + + mockService := NewMockService(ctrl) + mockService.EXPECT(). + BranchGraph(gomock.Any(), gomock.Any()). + Return(newBranchGraphBuilder("main"). + Anchor("wt-a"). + Branch("other", "main"). + Branch("feat1", "wt-a"). + Branch("feat2", "feat1"). + Build(t), nil) + mockService.EXPECT(). + Restack(gomock.Any(), "other"). + Return(&spice.RestackResponse{Base: "main"}, nil) + mockService.EXPECT(). + Restack(gomock.Any(), "feat1"). + Return(&spice.RestackResponse{Base: "wt-a"}, nil) + mockService.EXPECT(). + Restack(gomock.Any(), "feat2"). + Return(&spice.RestackResponse{Base: "feat1"}, nil) + + mockWorktree := NewMockGitWorktree(ctrl) + mockWorktree.EXPECT(). + RootDir(). + Return(t.TempDir()) + + handler := &Handler{ + Log: log, + Worktree: mockWorktree, + Store: statetest.NewMemoryStore(t, "main", "", log), + Service: mockService, + } + + count, err := handler.Restack(t.Context(), &Request{ + Branch: "main", + Scope: ScopeUpstackExclusive, + ContinueCommand: []string{"repo", "restack"}, + SkipCheckout: true, + WholeRepo: true, + }) + + require.NoError(t, err) + assert.Equal(t, 3, count) + assert.Contains(t, logBuffer.String(), "other: restacked on main") + assert.Contains(t, logBuffer.String(), "feat1: restacked on wt-a") + assert.Contains(t, logBuffer.String(), "feat2: restacked on feat1") + }) +} + func TestHandler_Restack_skipCheckedOut(t *testing.T) { t.Run("Branch", func(t *testing.T) { // Branch-scoped restack operation, @@ -848,6 +906,7 @@ func TestHandler_Restack_errors(t *testing.T) { type branchGraphBuilder struct { trunk string + anchors []string items []spice.BranchGraphItem worktrees map[string]string } @@ -867,6 +926,13 @@ func (b *branchGraphBuilder) Branch(name, base string) *branchGraphBuilder { return b } +// Anchor registers an anchor branch: a per-worktree graph root. +// Branches based on it are rooted there, disconnected from trunk. +func (b *branchGraphBuilder) Anchor(name string) *branchGraphBuilder { + b.anchors = append(b.anchors, name) + return b +} + func (b *branchGraphBuilder) Worktree(branch, wt string) *branchGraphBuilder { b.worktrees[branch] = wt return b @@ -875,6 +941,7 @@ func (b *branchGraphBuilder) Worktree(branch, wt string) *branchGraphBuilder { func (b *branchGraphBuilder) Build(t testing.TB) *spice.BranchGraph { graph, err := spice.NewBranchGraph(t.Context(), &branchLoaderStub{ trunk: b.trunk, + anchors: b.anchors, items: b.items, worktrees: b.worktrees, }, &spice.BranchGraphOptions{IncludeWorktrees: true}) @@ -884,6 +951,7 @@ func (b *branchGraphBuilder) Build(t testing.TB) *spice.BranchGraph { type branchLoaderStub struct { trunk string + anchors []string items []spice.LoadBranchItem worktrees map[string]string } @@ -895,7 +963,7 @@ func (b *branchLoaderStub) Trunk() string { } func (b *branchLoaderStub) AnchorBranches() []string { - return nil + return b.anchors } func (b *branchLoaderStub) LoadBranches(context.Context) ([]spice.LoadBranchItem, error) { 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 } diff --git a/internal/spice/branch_graph.go b/internal/spice/branch_graph.go index 466f561f6..8e863a54a 100644 --- a/internal/spice/branch_graph.go +++ b/internal/spice/branch_graph.go @@ -14,6 +14,7 @@ import ( type BranchGraph struct { trunk string trunks map[string]struct{} // all trunk-equivalents (canonical + worktree trunks) + anchors []string // anchor branches (per-worktree trunks), in load order branches []BranchGraphItem // all tracked branches byName map[string]int // name -> index in branches byBase map[string][]int // name -> [indices in branches] @@ -82,14 +83,16 @@ func NewBranchGraph(ctx context.Context, loader BranchLoader, opts *BranchGraphO } trunk := loader.Trunk() + anchors := loader.AnchorBranches() trunks := map[string]struct{}{trunk: {}} - for _, t := range loader.AnchorBranches() { + for _, t := range anchors { trunks[t] = struct{}{} } return &BranchGraph{ trunk: trunk, trunks: trunks, + anchors: anchors, branches: branches, byName: byName, byBase: byBase, @@ -110,6 +113,15 @@ func (g *BranchGraph) isTrunk(name string) bool { return ok } +// Anchors returns an iterator over the anchor branches in the graph: +// per-worktree trunks that root their own stacks, +// disconnected from the canonical trunk. +// +// The canonical trunk is not included. +func (g *BranchGraph) Anchors() iter.Seq[string] { + return slices.Values(g.anchors) +} + // All returns an iterator over all branches in the graph // with detailed per-branch information. func (g *BranchGraph) All() iter.Seq[LoadBranchItem] { diff --git a/internal/spice/branch_graph_test.go b/internal/spice/branch_graph_test.go index 90d44bff7..4cc32aaa4 100644 --- a/internal/spice/branch_graph_test.go +++ b/internal/spice/branch_graph_test.go @@ -306,6 +306,37 @@ func TestBranchGraph_anchorIsRoot(t *testing.T) { slices.Collect(graph.Downstack("feat2"))) assert.Equal(t, "other", graph.Bottom("other")) assert.Empty(t, slices.Collect(graph.Downstack("wt-a"))) + + // Anchors are reported as graph roots, excluding the canonical trunk. + assert.Equal(t, []string{"wt-a"}, slices.Collect(graph.Anchors())) +} + +// An internal anchor (one based on a branch owned by another worktree) +// severs the upstack: an operation scoped to the lower worktree's region +// must not reach into the upper worktree's region. +// +// main ---> wt-a ---> feat1 ---> wt-b ---> featb1 +// +// wt-a and wt-b are anchors. feat1 belongs to worktree A; +// featb1 belongs to worktree B, whose anchor wt-b is based on feat1. +func TestBranchGraph_internalAnchorSeversUpstack(t *testing.T) { + graph := spicetest.NewBranchGraph(t, spicetest.BranchGraphConfig{ + Trunk: "main", + Anchors: []string{"wt-a", "wt-b"}, + Branches: []spice.LoadBranchItem{ + {Name: "feat1", Base: "wt-a"}, + {Name: "featb1", Base: "wt-b"}, + }, + }) + + // From worktree A's region, the upstack stops at feat1: + // it does not bleed across the wt-b anchor into featb1. + assert.Equal(t, []string{"feat1"}, slices.Collect(graph.Upstack("feat1"))) + assert.Empty(t, slices.Collect(graph.Aboves("feat1"))) + + // Worktree B's region roots at its own anchor. + assert.Equal(t, "featb1", graph.Bottom("featb1")) + assert.Equal(t, []string{"featb1"}, slices.Collect(graph.Upstack("featb1"))) } func TestBranchGraphRapid(t *testing.T) { diff --git a/internal/ui/branchtree/tree.go b/internal/ui/branchtree/tree.go index 14334d0f5..1122470bf 100644 --- a/internal/ui/branchtree/tree.go +++ b/internal/ui/branchtree/tree.go @@ -63,6 +63,17 @@ type Item struct { // If non-nil and Total > 0, rendered as " [☑️Resolved/Total💬]". CommentCounts *forge.CommentCounts + // Anchor reports whether this item is an anchor: + // a per-worktree trunk that roots another worktree's stack. + // If true, rendered with a " [root anchor]" or " [internal anchor]" + // tag depending on AnchorBase. + Anchor bool + + // AnchorBase is the branch an internal anchor is pinned at. + // Empty for a root anchor (which tracks the remote trunk). + // Only meaningful when Anchor is true. + AnchorBase string + // Worktree is the absolute path where this branch is checked out. // If non-empty and differs from GraphOptions.CurrentWorktree, // rendered as "[wt: path]". @@ -337,6 +348,14 @@ func (r *branchTreeRenderer) item(sb *strings.Builder, item *Item) { r.commentCounts(sb, cc) } + if item.Anchor { + tag := " [root anchor]" + if item.AnchorBase != "" { + tag = " [internal anchor]" + } + sb.WriteString(r.Style.Worktree.Render(tag)) + } + if wt := item.Worktree; wt != "" && wt != r.CurrentWorktree { r.worktree(sb, item.Worktree, item.WorktreeHighlights) } diff --git a/log.go b/log.go index ffcd5fa80..ee96e5f01 100644 --- a/log.go +++ b/log.go @@ -190,6 +190,8 @@ func (p *graphLogPresenter) Present(res *list.BranchesResponse, currentBranch st item := &branchtree.Item{ Branch: b.Name, Worktree: b.Worktree, + Anchor: b.Anchor, + AnchorBase: b.AnchorBase, NeedsRestack: b.NeedsRestack, Aboves: b.Aboves, Highlighted: b.Name == currentBranch, @@ -351,6 +353,19 @@ func (p *jsonLogPresenter) Present(res *list.BranchesResponse, currentBranch str logBranch.Worktree = wt } + if branch.Anchor { + logBranch.Anchor = true + logBranch.AnchorKind = "root" + if branch.AnchorBase != "" { + logBranch.AnchorKind = "internal" + } + // An anchor's owning worktree is meaningful even though + // the anchor branch is not itself checked out there. + if wt := branch.Worktree; wt != "" { + logBranch.Worktree = wt + } + } + if err := enc.Encode(logBranch); err != nil { return fmt.Errorf("encode branch %q: %w", branch.Name, err) } @@ -397,7 +412,19 @@ type jsonLogBranch struct { // This is unset if the branch is not checked out // in any worktree, // or if it's the current branch (current is true). + // + // For an anchor, this is the worktree that owns the anchor, + // even though the anchor branch itself may not be checked out there. Worktree string `json:"worktree,omitempty"` + + // Anchor is true if this branch is an anchor: + // a per-worktree trunk that roots another worktree's stack. + Anchor bool `json:"anchor,omitempty"` + + // AnchorKind distinguishes the two anchor flavors when Anchor is true: + // "root" for a per-worktree trunk that tracks the remote canonical + // trunk, or "internal" for an anchor pinned at a tracked branch. + AnchorKind string `json:"anchorKind,omitempty"` } type jsonLogDown struct { diff --git a/repo_restack.go b/repo_restack.go index 822936e28..3b1564dd8 100644 --- a/repo_restack.go +++ b/repo_restack.go @@ -52,7 +52,15 @@ func (cmd *repoRestackCmd) Run( ContinueCommand: []string{"repo", "restack"}, SkipCheckout: true, // caller handles checkout } + // WholeRepo seeds the restack set with stacks rooted at anchors + // (per-worktree trunks), which are disconnected from the canonical + // trunk and so are never reached by the upstack-from-trunk walk. + // It is always needed: a '-w' restack run from inside an anchor + // worktree must include that worktree's own anchor-rooted stack + // before WorktreeFilter narrows the set to this worktree. + req.WholeRepo = true if cmd.Worktree { + // Scope to stacks touching this worktree. req.WorktreeFilter = wt.RootDir() } diff --git a/testdata/script/anchor_checkout_in_use.txt b/testdata/script/anchor_checkout_in_use.txt new file mode 100644 index 000000000..fda20e708 --- /dev/null +++ b/testdata/script/anchor_checkout_in_use.txt @@ -0,0 +1,24 @@ +# Checking out a branch that is checked out in another worktree +# fails with a legible message naming the worktree, instead of +# git's raw 'already used by worktree' fatal. + +as 'Test ' +at '2026-03-29T14:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# An anchor worktree with a branch checked out in it. +gs anchor create $WORK/wt1 --name dev -b feat1 +stderr 'Created and checked out branch feat1' + +# From the primary worktree, the branch is in use by wt1. +cd $WORK/repo +! gs branch checkout feat1 +stderr 'feat1 is in use by worktree .*wt1' + +-- repo/dummy.txt -- +dummy diff --git a/testdata/script/anchor_scope_restack_isolation.txt b/testdata/script/anchor_scope_restack_isolation.txt new file mode 100644 index 000000000..56a8a5e21 --- /dev/null +++ b/testdata/script/anchor_scope_restack_isolation.txt @@ -0,0 +1,51 @@ +# Restack operations are scoped to the invoking worktree's anchor: +# they never reach across an anchor boundary into another worktree's +# stack. This holds structurally because an anchor is a graph root, +# disconnected from the canonical trunk and from its own base. + +as 'Test ' +at '2026-03-29T14:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Primary builds a stack: main -> featA1 +cp $WORK/featA1.txt featA1.txt +git add featA1.txt +gs bc featA1 -m 'Add featA1' + +# A dependent worktree anchored at featA1 (an internal anchor), +# carrying its own stack: wtB -> featB1 -> featB2. +gs anchor create $WORK/wtB --anchor featA1 -b featB1 +cd $WORK/wtB +cp $WORK/featB1.txt featB1.txt +git add featB1.txt +gs cc -m 'Add featB1' +cp $WORK/featB2.txt featB2.txt +git add featB2.txt +gs bc featB2 -m 'Add featB2' + +# From the primary, an upstack restack at featA1 stops at the child +# anchor: it does not reach into wtB's stack. +cd $WORK/repo +gs upstack restack +! stderr 'featB1' +! stderr 'featB2' + +# From wtB, a stack restack stays above the anchor: it does not reach +# down into the primary's featA1. +cd $WORK/wtB +gs stack restack +! stderr 'featA1: restacked' + +-- repo/dummy.txt -- +dummy +-- featA1.txt -- +featA1 +-- featB1.txt -- +featB1 +-- featB2.txt -- +featB2 diff --git a/testdata/script/log_anchor_internal.txt b/testdata/script/log_anchor_internal.txt new file mode 100644 index 000000000..687b0764a --- /dev/null +++ b/testdata/script/log_anchor_internal.txt @@ -0,0 +1,42 @@ +# An internal anchor (one pinned at a tracked branch) renders with an +# '[internal anchor]' tag and reports "anchorKind":"internal" in JSON, +# distinguishing it from a root anchor that tracks the remote trunk. + +as 'Test ' +at '2026-03-29T14:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Primary builds a stack: main -> featA1 +cp $WORK/featA1.txt featA1.txt +git add featA1.txt +gs bc featA1 -m 'Add featA1' + +# A dependent worktree with an internal anchor pinned at featA1. +gs anchor create $WORK/wtB --anchor featA1 -b featB1 +cd $WORK/wtB +cp $WORK/featB1.txt featB1.txt +git add featB1.txt +gs cc -m 'Add featB1' + +# From the primary, the anchor node is tagged internal and nested +# under its pinned base featA1. +cd $WORK/repo +gs bco featA1 +gs ls -a +stderr 'wtB.*\[internal anchor\]' + +# JSON records the internal kind. +gs ls -a --json +stdout '"name":"wtB".*"anchorKind":"internal"' + +-- repo/dummy.txt -- +dummy +-- featA1.txt -- +featA1 +-- featB1.txt -- +featB1 diff --git a/testdata/script/log_anchor_nested.txt b/testdata/script/log_anchor_nested.txt new file mode 100644 index 000000000..dfe2a58da --- /dev/null +++ b/testdata/script/log_anchor_nested.txt @@ -0,0 +1,53 @@ +# 'gs ll -a' from the primary worktree shows another worktree's +# anchor-rooted stack, nested under the anchor's base. The anchor +# pointer branch renders as an intermediate node tagged as an anchor. + +as 'Test ' +at '2026-03-29T14:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# A trunk-based branch in the primary worktree. +cp $WORK/t1.txt t1.txt +git add t1.txt +gs bc t1 -m 'Add t1' + +# An anchor worktree with its own stack: dev -> a1 -> a2. +gs anchor create $WORK/wt1 --name dev +cd $WORK/wt1 +cp $WORK/a1.txt a1.txt +git add a1.txt +gs bc a1 -m 'Add a1' +cp $WORK/a2.txt a2.txt +git add a2.txt +gs bc a2 -m 'Add a2' + +# From the primary worktree, -a lists every branch, including the +# anchor's stack, rooted at the anchor node under main. +cd $WORK/repo +gs bco t1 +gs ls -a +stderr 'a2' +stderr 'a1' +stderr 'dev.*\[root anchor\]' +stderr 'main' + +# JSON output marks the anchor node, its kind, and owning worktree. +gs ls -a --json +stdout '"name":"dev"' +stdout '"anchor":true' +stdout '"anchorKind":"root"' +stdout '"name":"a1".*"down":\{"name":"dev"' + +-- repo/dummy.txt -- +dummy +-- t1.txt -- +t1 +-- a1.txt -- +a1 +-- a2.txt -- +a2 diff --git a/testdata/script/repo_restack_anchor_whole_repo.txt b/testdata/script/repo_restack_anchor_whole_repo.txt new file mode 100644 index 000000000..66208e7c9 --- /dev/null +++ b/testdata/script/repo_restack_anchor_whole_repo.txt @@ -0,0 +1,55 @@ +# 'gs repo restack' (whole repo) reaches stacks rooted at an anchor, +# not just those reachable from the canonical trunk. +# 'gs repo restack -w' stays scoped to the current worktree. + +as 'Test ' +at '2026-03-29T14:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# An anchor worktree with its own two-branch stack: +# dev (anchor) -> feat1 -> feat2. +gs anchor create $WORK/wt1 --name dev +cd $WORK/wt1 +cp $WORK/feat1.txt feat1.txt +git add feat1.txt +gs bc feat1 -m 'Add feat1' +cp $WORK/feat2.txt feat2.txt +git add feat2.txt +gs bc feat2 -m 'Add feat2' + +# Rewrite feat1 so feat2 needs restacking, +# without restacking the upstack. +gs bco feat1 +git commit --amend -m 'Add feat1 reworded' + +# Park the worktree on its anchor so the stack branches +# are not checked out anywhere. +git checkout dev + +# From the primary worktree, a whole-repo restack reaches +# the anchor-rooted stack and restacks feat2 onto feat1. +cd $WORK/repo +gs repo restack +stderr 'feat2: restacked on feat1' + +# A worktree-scoped restack from the primary worktree +# does not touch the anchor's stack: the primary worktree +# holds no branch in it. Make feat2 stale again, then confirm +# 'repo restack -w' leaves it alone. +gs bco feat1 +git commit --amend -m 'Add feat1 reworded again' +gs bco main +gs repo restack -w +! stderr 'feat2' + +-- repo/dummy.txt -- +dummy +-- feat1.txt -- +feat1 content +-- feat2.txt -- +feat2 content diff --git a/testdata/script/repo_restack_anchor_worktree.txt b/testdata/script/repo_restack_anchor_worktree.txt new file mode 100644 index 000000000..fef070084 --- /dev/null +++ b/testdata/script/repo_restack_anchor_worktree.txt @@ -0,0 +1,53 @@ +# 'repo restack -w' run from inside an anchor worktree restacks that +# worktree's own stack, which is rooted at the anchor and thus +# disconnected from the canonical trunk. + +as 'Test ' +at '2026-03-29T14:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# A second stack rooted at the canonical trunk, in the primary +# worktree: main -> other. +git add other.txt +gs bc other -m 'Add other' +gs bco main + +# An anchor worktree with its own stack: dev -> a1 -> a2. +gs anchor create $WORK/wt1 --name dev +cd $WORK/wt1 +cp $WORK/a1.txt a1.txt +git add a1.txt +gs bc a1 -m 'Add a1' +cp $WORK/a2.txt a2.txt +git add a2.txt +gs bc a2 -m 'Add a2' + +# Add a commit on the anchor so a1/a2 need restacking onto it. +gs bco dev +cp $WORK/dev-update.txt dev-update.txt +git add dev-update.txt +git commit -m 'Update dev' + +# From inside the anchor worktree, '-w' restacks the anchor's stack... +gs repo restack -w +stderr 'a1: restacked' +stderr 'a2: restacked' + +# ...and does not touch the canonical-trunk stack in another worktree. +! stderr 'other' + +-- repo/dummy.txt -- +dummy +-- repo/other.txt -- +other +-- a1.txt -- +a1 +-- a2.txt -- +a2 +-- dev-update.txt -- +dev update