Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260619-114007.yaml
Original file line number Diff line number Diff line change
@@ -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
36 changes: 21 additions & 15 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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}

Expand Down Expand Up @@ -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}

Expand Down
23 changes: 23 additions & 0 deletions internal/git/branch_wt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions internal/git/checkout_wt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
55 changes: 55 additions & 0 deletions internal/handler/list/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
})
Expand Down
41 changes: 41 additions & 0 deletions internal/handler/restack/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
Loading
Loading