Skip to content
Closed
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-20260602-160450.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'anchor: Add ''gs anchor create'', ''gs anchor list'', and ''gs anchor track'' commands. ''gs anchor create'' sets up a per-worktree trunk branch (an anchor) that tracks the remote trunk, so ''repo sync'' and restacks in different worktrees operate on their own trunk without contending on a shared checkout. ''gs anchor track'' adopts the anchor of a worktree created outside git-spice. ''gs anchor list'' shows the registered anchors. Also adds ''-w/--worktree'' to ''log short'' and ''repo restack'' to scope operations to the current worktree''s stacks.'
time: 2026-06-02T16:04:50.426069-04:00
11 changes: 11 additions & 0 deletions anchor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package main

// anchorCmd groups commands that manage worktree anchors: per-worktree
// pointer branches that hold a slice of the stack in place so parallel
// processes (agents, CI, the user) can work in one repository without
// clobbering each other.
type anchorCmd struct {
Create anchorCreateCmd `cmd:"" aliases:"c" help:"Create a new worktree anchored at a branch"`
List anchorListCmd `cmd:"" aliases:"ls" help:"List anchors and their worktrees"`
Track anchorTrackCmd `cmd:"" aliases:"tr" help:"Track an existing worktree as an anchor"`
}
250 changes: 250 additions & 0 deletions anchor_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
package main

import (
"cmp"
"context"
"errors"
"fmt"
"path/filepath"

"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
)

type anchorCreateCmd struct {
Path string `arg:"" help:"Path for the new worktree"`

Name string `name:"name" placeholder:"BRANCH" help:"Name of the anchor branch to create (defaults to the worktree directory name)"`
Anchor string `name:"anchor" placeholder:"BRANCH" help:"Anchor on an existing tracked branch (a dependent worktree) instead of the remote trunk"`
NoAnchor bool `name:"no-anchor" help:"Do not create an anchor; start in detached HEAD at trunk"`

Branch string `short:"b" placeholder:"BRANCH" help:"Create and check out a new tracked branch stacked on the anchor"`
}

func (*anchorCreateCmd) Help() string {
return text.Dedent(`
Creates a new Git worktree at the given path, anchored at a branch.

By default the worktree gets a root anchor: a per-worktree pointer
branch (named after the worktree directory, or set with --name)
that tracks the same remote trunk as the main checkout.
Stacks created in the worktree are based on this anchor, so
'gs repo sync' and restacks in different worktrees never contend
on a single shared trunk checkout.

Use --anchor to instead pin the worktree at an existing tracked
branch: a dependent worktree, for building on top of another
worktree's work.

Use -b/--branch to also create a tracked branch stacked on the
anchor. With --no-anchor there is no anchor to stack on, so the
branch is created untracked at the trunk commit.

Use --no-anchor to instead start the worktree in detached HEAD
state at the current trunk commit.
`)
}

func (cmd *anchorCreateCmd) Run(
ctx context.Context,
log *silog.Logger,
repo *git.Repository,
store *state.Store,
svc *spice.Service,
) (retErr error) {
if cmd.NoAnchor && cmd.Anchor != "" {
return errors.New("--no-anchor and --anchor are mutually exclusive")
}
if cmd.NoAnchor && cmd.Name != "" {
return errors.New("--name has no effect with --no-anchor: " +
"there is no anchor branch to name")
}

canonicalTrunk := store.Trunk()

// Resolve the base the worktree is anchored at: the canonical trunk
// for a root anchor, or an existing tracked branch for an internal
// (dependent) anchor.
base := canonicalTrunk
internal := cmd.Anchor != ""
if internal {
base = cmd.Anchor
if base == canonicalTrunk {
return fmt.Errorf("--anchor %q is the canonical trunk; "+
"omit --anchor for a root anchor", base)
}
if !repo.BranchExists(ctx, base) {
return fmt.Errorf("--anchor %q is not a tracked branch", base)
}
if _, err := svc.LookupBranch(ctx, base); err != nil {
if errors.Is(err, state.ErrNotExist) {
return fmt.Errorf("--anchor %q is not a tracked branch", base)
}
return fmt.Errorf("look up %q: %w", base, err)
}
}

baseHash, err := repo.PeelToCommit(ctx, base)
if err != nil {
return fmt.Errorf("resolve %v: %w", base, err)
}

// Create the worktree in detached HEAD state at the base;
// any branch checkout happens below inside the new worktree.
if err := repo.WorktreeAdd(ctx, git.WorktreeAddRequest{
Path: cmd.Path,
Detach: true,
Head: baseHash.String(),
}); err != nil {
return fmt.Errorf("create worktree: %w", err)
}
log.Infof("Created worktree at %s", cmd.Path)

// Roll back everything created below if a later step fails, so the
// user can retry without manually removing a half-built worktree or
// deleting orphaned branches.
//
// Cleanups run in registration order: the worktree is removed first
// because it holds the checkout of any branch we created, and a
// checked-out branch cannot be deleted until its worktree is gone.
var cleanups []func()
defer func() {
if retErr == nil {
return
}
for _, cleanup := range cleanups {
cleanup()
}
}()
cleanups = append(cleanups, func() {
if err := repo.WorktreeRemove(ctx, git.WorktreeRemoveRequest{
Path: cmd.Path,
Force: true,
}); err != nil {
log.Warnf("Could not clean up worktree %s: %v", cmd.Path, err)
}
})

newWT, err := repo.OpenWorktree(ctx, cmd.Path)
if err != nil {
return fmt.Errorf("open worktree: %w", err)
}

// --no-anchor preserves the legacy detached-HEAD behavior.
if cmd.NoAnchor {
if cmd.Branch != "" {
if err := repo.CreateBranch(ctx, git.CreateBranchRequest{
Name: cmd.Branch,
Head: baseHash.String(),
}); err != nil {
return fmt.Errorf("create branch: %w", err)
}
cleanups = append(cleanups, deleteBranchCleanup(ctx, repo, log, cmd.Branch))
if err := newWT.CheckoutBranch(ctx, cmd.Branch); err != nil {
return fmt.Errorf("checkout branch: %w", err)
}
log.Infof("Created and checked out branch %s", cmd.Branch)
}
return nil
}

// The anchor is a per-worktree pointer branch at the base commit.
anchorBranch := cmp.Or(cmd.Name, filepath.Base(cmd.Path))
if anchorBranch == canonicalTrunk {
return fmt.Errorf("anchor %q is the same as the canonical trunk; "+
"choose another name with --name", anchorBranch)
}

if err := repo.CreateBranch(ctx, git.CreateBranchRequest{
Name: anchorBranch,
Head: baseHash.String(),
}); err != nil {
return fmt.Errorf("create anchor %q: %w", anchorBranch, err)
}
cleanups = append(cleanups, deleteBranchCleanup(ctx, repo, log, anchorBranch))
if err := newWT.CheckoutBranch(ctx, anchorBranch); err != nil {
return fmt.Errorf("checkout anchor: %w", err)
}

// A root anchor tracks the remote canonical trunk so 'gs repo sync'
// updates it from the remote. An internal anchor is fast-forwarded
// from its local base by sync instead (recorded in Base below), so
// it gets no remote upstream.
anchorBase := ""
if internal {
anchorBase = base
} else if remote, err := store.Remote(); err == nil {
upstream := cmp.Or(remote.Upstream, remote.Push) + "/" + canonicalTrunk
if err := repo.SetBranchUpstream(ctx, anchorBranch, upstream); err != nil {
log.Warnf("Could not set upstream of %q to %q: %v", anchorBranch, upstream, err)
}
}

if err := store.RegisterAnchor(ctx, state.Anchor{
Branch: anchorBranch,
Worktree: newWT.RootDir(),
Base: anchorBase,
}); err != nil {
return fmt.Errorf("register anchor: %w", err)
}
cleanups = append(cleanups, func() {
if err := store.UnregisterAnchor(ctx, anchorBranch); err != nil {
log.Warnf("Could not unregister anchor %s: %v", anchorBranch, err)
}
})
if internal {
log.Infof("Created anchor %s anchored at %s", anchorBranch, base)
} else {
log.Infof("Created anchor %s tracking %s", anchorBranch, canonicalTrunk)
}

if cmd.Branch != "" {
if err := repo.CreateBranch(ctx, git.CreateBranchRequest{
Name: cmd.Branch,
Head: baseHash.String(),
}); err != nil {
return fmt.Errorf("create branch: %w", err)
}
cleanups = append(cleanups, deleteBranchCleanup(ctx, repo, log, cmd.Branch))
if err := newWT.CheckoutBranch(ctx, cmd.Branch); err != nil {
return fmt.Errorf("checkout branch: %w", err)
}

// Track the feature branch on the anchor.
tx := store.BeginBranchTx()
if err := tx.Upsert(ctx, state.UpsertRequest{
Name: cmd.Branch,
Base: anchorBranch,
BaseHash: baseHash,
}); err != nil {
return fmt.Errorf("track branch %q: %w", cmd.Branch, err)
}
if err := tx.Commit(ctx,
fmt.Sprintf("create branch %q in worktree", cmd.Branch)); err != nil {
return fmt.Errorf("commit branch tracking: %w", err)
}
log.Infof("Created and checked out branch %s", cmd.Branch)
}

return nil
}

// deleteBranchCleanup returns a rollback that force-deletes a branch
// created by 'gs anchor create', logging a warning if removal fails.
func deleteBranchCleanup(
ctx context.Context,
repo *git.Repository,
log *silog.Logger,
branch string,
) func() {
return func() {
if err := repo.DeleteBranch(ctx, branch, git.BranchDeleteOptions{
Force: true,
}); err != nil {
log.Warnf("Could not clean up branch %s: %v", branch, err)
}
}
}
58 changes: 58 additions & 0 deletions anchor_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
)

type anchorListCmd struct{}

func (*anchorListCmd) Help() string {
return text.Dedent(`
Lists the anchors registered in the repository.

For each anchor, shows its branch, the worktree that owns it, and
whether it is a root anchor (tracking the canonical trunk) or an
internal anchor (pinned at another local branch). The anchor that
is the trunk in effect for the current worktree is marked with a
'*'.
`)
}

func (*anchorListCmd) Run(
log *silog.Logger,
store *state.Store,
wt *git.Worktree,
) error {
anchors := store.Anchors()
if len(anchors) == 0 {
log.Infof("No anchors in this repository")
return nil
}

// The anchor in effect for the current worktree, if any, is marked.
currentAnchor := store.TrunkFor(wt.RootDir())

for _, a := range anchors {
marker := " "
if a.Branch == currentAnchor {
marker = "*"
}

kind := "root"
if a.Base != "" {
kind = "on " + a.Base
}

worktree := a.Worktree
if worktree == "" {
worktree = "(unknown)"
}

log.Infof("%s %s\t%s\t(%s)", marker, a.Branch, worktree, kind)
}

return nil
}
Loading
Loading