From f78e5bc58fc37719b771b2ef8fd5596aecbf2341 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Sat, 20 Jun 2026 06:18:49 -0400 Subject: [PATCH 1/3] feat(repo): Add exclusive mode via park/restore Parallel processes (agents, CI, the human) share one git-spice repository through worktrees. Restacks and syncs stay scoped to a worktree's anchor, but some operations need the whole stack to themselves. Exclusive mode is the writer lock for those. 'gs repo park' records every linked worktree in a durable manifest (stored in the spice data ref, so it survives the worktree dirs being removed) and then removes the directories, leaving the entire graph reachable from the primary checkout. The manifest is written before any removal, so an interrupted park is resumable. Dirty worktrees are refused unless --force. 'gs repo restore' re-creates the parked worktrees at their branches' current tips and clears the marker; it is idempotent and crash-resumable. 'gs repo exclusive -- ' parks, runs the command with the repo to itself, and always restores afterward, even on failure. While parked, 'gs anchor create' is refused: the repository belongs to the parking process until restore. --- .../unreleased/Added-20260620-063403.yaml | 3 + anchor_create.go | 7 + doc/includes/cli-reference.md | 83 ++++++++- internal/spice/state/exclusive.go | 116 +++++++++++++ internal/spice/state/exclusive_test.go | 51 ++++++ internal/spice/state/store.go | 8 + repo.go | 4 + repo_exclusive.go | 82 +++++++++ repo_park.go | 163 ++++++++++++++++++ repo_restore.go | 71 ++++++++ testdata/help/gs.txt | 4 + testdata/help/repo_exclusive.txt | 28 +++ testdata/help/repo_park.txt | 27 +++ testdata/help/repo_restore.txt | 16 ++ .../anchor_create_blocked_in_exclusive.txt | 29 ++++ testdata/script/repo_exclusive_wrapper.txt | 36 ++++ testdata/script/repo_park_crash_recovery.txt | 42 +++++ testdata/script/repo_park_refuses_dirty.txt | 41 +++++ .../script/repo_park_restore_roundtrip.txt | 40 +++++ 19 files changed, 842 insertions(+), 9 deletions(-) create mode 100644 .changes/unreleased/Added-20260620-063403.yaml create mode 100644 internal/spice/state/exclusive.go create mode 100644 internal/spice/state/exclusive_test.go create mode 100644 repo_exclusive.go create mode 100644 repo_park.go create mode 100644 repo_restore.go create mode 100644 testdata/help/repo_exclusive.txt create mode 100644 testdata/help/repo_park.txt create mode 100644 testdata/help/repo_restore.txt create mode 100644 testdata/script/anchor_create_blocked_in_exclusive.txt create mode 100644 testdata/script/repo_exclusive_wrapper.txt create mode 100644 testdata/script/repo_park_crash_recovery.txt create mode 100644 testdata/script/repo_park_refuses_dirty.txt create mode 100644 testdata/script/repo_park_restore_roundtrip.txt diff --git a/.changes/unreleased/Added-20260620-063403.yaml b/.changes/unreleased/Added-20260620-063403.yaml new file mode 100644 index 000000000..e9e63ff3d --- /dev/null +++ b/.changes/unreleased/Added-20260620-063403.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'repo: Add exclusive mode: `gs repo park` records and removes linked worktrees so one process owns the whole repository, `gs repo restore` brings them back, and `gs repo exclusive -- ` wraps a command in park/restore. While parked, `gs anchor create` is refused.' +time: 2026-06-20T06:34:03.614351-04:00 diff --git a/anchor_create.go b/anchor_create.go index 1c5572527..e141362a0 100644 --- a/anchor_create.go +++ b/anchor_create.go @@ -63,6 +63,13 @@ func (cmd *anchorCreateCmd) Run( "there is no anchor branch to name") } + // While the repository is in exclusive mode, it belongs to the + // parking process; new worktrees would race with its reorganization. + if store.InExclusiveMode() { + return errors.New("repository is in exclusive mode; " + + "run 'gs repo restore' first") + } + canonicalTrunk := store.Trunk() // Resolve the base the worktree is anchored at: the canonical trunk diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 0f673d773..1de80e92f 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -185,6 +185,76 @@ respective bases in dependency order, ensuring a linear history. * `-w`, `--worktree`: Only restack branches in the current worktree. +### git-spice repo park {#gs-repo-park} + +``` +gs repo (r) park [flags] +``` + +Enter exclusive mode: park worktrees and take the whole repo + +Enters exclusive mode: the whole repository is handed to a single +process so it can reorganize the stack without contending with +worktrees owned by other processes. + +Every linked worktree is recorded in a durable manifest and its +directory is removed; the branches themselves are left untouched, +so the entire graph remains reachable from the primary checkout. +Run 'gs repo restore' to leave exclusive mode and re-create the +worktrees. + +Worktrees with uncommitted changes are refused unless --force is +given, which discards those changes. The manifest is written +before any worktree is removed, so an interrupted park can be +resumed by re-running the command. + +**Flags** + +* `--force`: Park worktrees even if they have uncommitted changes (changes are discarded) + +### git-spice repo restore {#gs-repo-restore} + +``` +gs repo (r) restore +``` + +Leave exclusive mode: restore parked worktrees + +Leaves exclusive mode: the worktrees recorded by 'gs repo park' +are re-created at their branches' current tips, and the +exclusive-mode marker is cleared. + +It is idempotent and resumable: worktrees that already exist are +left alone, so an interrupted restore can be finished by +re-running the command. + +### git-spice repo exclusive {#gs-repo-exclusive} + +``` +gs repo (r) exclusive [ ...] [flags] +``` + +Run a command with the whole repo to itself + +Runs a command with the whole repository to itself: it parks every +worktree (see 'gs repo park'), runs the command, then restores the +worktrees (see 'gs repo restore'). + +The worktrees are always restored, even if the command fails, so +this is the safe way to take exclusive mode for a single command. + +Separate the command from this one's flags with '--', for example: + + gs repo exclusive -- git rebase -i main + +**Arguments** + +* `command`: Command to run with the repository to itself + +**Flags** + +* `--force`: Park worktrees even if they have uncommitted changes (changes are discarded) + ## Log ### git-spice log short {#gs-log-short} @@ -1391,8 +1461,7 @@ 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. +anchor. Use --no-anchor to instead start the worktree in detached HEAD state at the current trunk commit. @@ -1416,13 +1485,9 @@ gs anchor list (ls) List anchors and their worktrees -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 -'*'. +Lists all worktrees associated with the repository. +For each worktree, shows the checked-out branch +and any tracked branches in its stack. ### git-spice anchor track {#gs-anchor-track} diff --git a/internal/spice/state/exclusive.go b/internal/spice/state/exclusive.go new file mode 100644 index 000000000..b399342cd --- /dev/null +++ b/internal/spice/state/exclusive.go @@ -0,0 +1,116 @@ +package state + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/spice/state/storage" +) + +// _exclusiveJSON is the store key that holds the exclusive-mode manifest. +// Its presence is the exclusive-mode marker: while the key exists, the +// repository is parked and belongs to a single process until restore. +// It is additive and optional; repositories that have never been parked +// (and older binaries) simply have no entry. +const _exclusiveJSON = "exclusive" + +// ParkedWorktree records a worktree that was removed when the repository +// entered exclusive mode, so that restore can re-create it. +type ParkedWorktree struct { + // Path is the absolute filesystem path the worktree had. + Path string + + // Branch is the branch that was checked out in the worktree. + // Empty if the worktree was in detached-HEAD state. + Branch string + + // Anchor is the anchor branch the worktree owned, if any. + // Recorded for legibility; restore re-checks-out Branch, and the + // anchor registration itself survives parking untouched. + Anchor string +} + +// exclusiveInfo is the persisted form of the manifest. +type exclusiveInfo struct { + Worktrees []parkedWorktreeInfo `json:"worktrees,omitempty"` +} + +type parkedWorktreeInfo struct { + Path string `json:"path"` + Branch string `json:"branch,omitempty"` + Anchor string `json:"anchor,omitempty"` +} + +// loadExclusive reads the manifest into memory. A missing key is not an +// error: it leaves the store out of exclusive mode. +func (s *Store) loadExclusive(ctx context.Context) error { + s.exclusive = nil + + var info exclusiveInfo + if err := s.db.Get(ctx, _exclusiveJSON, &info); err != nil { + if errors.Is(err, ErrNotExist) { + return nil + } + return fmt.Errorf("get exclusive: %w", err) + } + + worktrees := make([]ParkedWorktree, 0, len(info.Worktrees)) + for _, w := range info.Worktrees { + worktrees = append(worktrees, ParkedWorktree(w)) + } + s.exclusive = &worktrees + return nil +} + +// InExclusiveMode reports whether the repository is currently parked. +func (s *Store) InExclusiveMode() bool { + return s.exclusive != nil +} + +// ParkedWorktrees returns the worktrees recorded in the exclusive-mode +// manifest. It returns nil when the repository is not in exclusive mode. +func (s *Store) ParkedWorktrees() []ParkedWorktree { + if s.exclusive == nil { + return nil + } + return *s.exclusive +} + +// Park enters exclusive mode, recording the given worktrees in the +// durable manifest. Calling it again overwrites the manifest, which makes +// park resumable: a re-run after a crash simply re-records the (possibly +// already-removed) worktrees. +func (s *Store) Park(ctx context.Context, worktrees []ParkedWorktree) error { + info := exclusiveInfo{ + Worktrees: make([]parkedWorktreeInfo, 0, len(worktrees)), + } + for _, w := range worktrees { + info.Worktrees = append(info.Worktrees, parkedWorktreeInfo(w)) + } + + if err := s.db.Update(ctx, storage.UpdateRequest{ + Sets: []storage.SetRequest{{Key: _exclusiveJSON, Value: info}}, + Message: "enter exclusive mode", + }); err != nil { + return fmt.Errorf("update exclusive: %w", err) + } + + next := make([]ParkedWorktree, len(worktrees)) + copy(next, worktrees) + s.exclusive = &next + return nil +} + +// Unpark leaves exclusive mode by deleting the manifest. It is a no-op +// when the repository is not parked, so restore is idempotent. +func (s *Store) Unpark(ctx context.Context) error { + if s.exclusive == nil { + return nil + } + if err := s.db.Delete(ctx, _exclusiveJSON, "leave exclusive mode"); err != nil { + return fmt.Errorf("delete exclusive: %w", err) + } + s.exclusive = nil + return nil +} diff --git a/internal/spice/state/exclusive_test.go b/internal/spice/state/exclusive_test.go new file mode 100644 index 000000000..08362ba55 --- /dev/null +++ b/internal/spice/state/exclusive_test.go @@ -0,0 +1,51 @@ +package state_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/spice/state/storage" +) + +func TestStore_Exclusive(t *testing.T) { + ctx := t.Context() + db := storage.NewDB(make(storage.MapBackend)) + store, err := state.InitStore(ctx, state.InitStoreRequest{ + DB: db, + Trunk: "main", + }) + require.NoError(t, err) + + // A fresh repository is not in exclusive mode. + assert.False(t, store.InExclusiveMode()) + assert.Empty(t, store.ParkedWorktrees()) + + worktrees := []state.ParkedWorktree{ + {Path: "/wt/a", Branch: "feat-a", Anchor: "anchor-a"}, + {Path: "/wt/b", Branch: "feat-b"}, + } + require.NoError(t, store.Park(ctx, worktrees)) + + assert.True(t, store.InExclusiveMode()) + assert.Equal(t, worktrees, store.ParkedWorktrees()) + + // The manifest survives a reopen of the store: this is what makes + // park/restore resumable across a crash. + reopened, err := state.OpenStore(ctx, db, nil) + require.NoError(t, err) + assert.True(t, reopened.InExclusiveMode()) + assert.Equal(t, worktrees, reopened.ParkedWorktrees()) + + // Unpark leaves exclusive mode. + require.NoError(t, store.Unpark(ctx)) + assert.False(t, store.InExclusiveMode()) + assert.Empty(t, store.ParkedWorktrees()) + + // Unpark is idempotent and the cleared state round-trips. + require.NoError(t, store.Unpark(ctx)) + cleared, err := state.OpenStore(ctx, db, nil) + require.NoError(t, err) + assert.False(t, cleared.InExclusiveMode()) +} diff --git a/internal/spice/state/store.go b/internal/spice/state/store.go index 84c800c05..1f2e7d048 100644 --- a/internal/spice/state/store.go +++ b/internal/spice/state/store.go @@ -37,6 +37,11 @@ type Store struct { // path and base). Loaded once at store open so [Store.IsTrunk] // stays cheap in hot paths. anchors map[string]Anchor + + // exclusive holds the parked-worktree manifest when the repository is + // in exclusive mode, and is nil otherwise. Loaded once at store open + // so [Store.InExclusiveMode] stays cheap. + exclusive *[]ParkedWorktree } // InitStoreRequest is a request to initialize the store @@ -209,6 +214,9 @@ func OpenStore(ctx context.Context, db DB, logger *silog.Logger) (*Store, error) if err := store.loadAnchors(ctx); err != nil { return nil, fmt.Errorf("load anchors: %w", err) } + if err := store.loadExclusive(ctx); err != nil { + return nil, fmt.Errorf("load exclusive: %w", err) + } return store, nil } diff --git a/repo.go b/repo.go index d4a607165..d021c7c47 100644 --- a/repo.go +++ b/repo.go @@ -4,4 +4,8 @@ type repoCmd struct { Init repoInitCmd `cmd:"" aliases:"i" help:"Initialize a repository"` Sync repoSyncCmd `cmd:"" aliases:"s" help:"Pull latest changes from the remote"` Restack repoRestackCmd `cmd:"" aliases:"r" help:"Restack all tracked branches" released:"v0.16.0"` + + Park repoParkCmd `cmd:"" help:"Enter exclusive mode: park worktrees and take the whole repo"` + Restore repoRestoreCmd `cmd:"" help:"Leave exclusive mode: restore parked worktrees"` + Exclusive repoExclusiveCmd `cmd:"" help:"Run a command with the whole repo to itself"` } diff --git a/repo_exclusive.go b/repo_exclusive.go new file mode 100644 index 000000000..4607b8071 --- /dev/null +++ b/repo_exclusive.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "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" + "go.abhg.dev/gs/internal/xec" +) + +type repoExclusiveCmd struct { + Force bool `help:"Park worktrees even if they have uncommitted changes (changes are discarded)"` + + Command []string `arg:"" optional:"" passthrough:"" name:"command" help:"Command to run with the repository to itself"` +} + +func (*repoExclusiveCmd) Help() string { + return text.Dedent(` + Runs a command with the whole repository to itself: it parks every + worktree (see 'gs repo park'), runs the command, then restores the + worktrees (see 'gs repo restore'). + + The worktrees are always restored, even if the command fails, so + this is the safe way to take exclusive mode for a single command. + + Separate the command from this one's flags with '--', for example: + + gs repo exclusive -- git rebase -i main + `) +} + +func (cmd *repoExclusiveCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + repo *git.Repository, + store *state.Store, +) error { + // Passthrough parsing keeps the '--' separator as the first token; + // drop it so it is not treated as the command to run. + command := cmd.Command + if len(command) > 0 && command[0] == "--" { + command = command[1:] + } + if len(command) == 0 { + return errors.New("no command given; pass one after '--', " + + "e.g. 'gs repo exclusive -- git status'") + } + + if err := (&repoParkCmd{Force: cmd.Force}).Run(ctx, log, wt, repo, store); err != nil { + return fmt.Errorf("park: %w", err) + } + + // Run the command, then always restore, so the worktrees come back + // even when the command fails. + runErr := xec.Command(ctx, log, command[0], command[1:]...). + WithDir(wt.RootDir()). + WithStdin(os.Stdin). + WithStdout(os.Stdout). + WithStderr(os.Stderr). + Run() + + if err := (&repoRestoreCmd{}).Run(ctx, log, repo, store); err != nil { + if runErr != nil { + return errors.Join( + fmt.Errorf("command: %w", runErr), + fmt.Errorf("restore: %w", err), + ) + } + return fmt.Errorf("restore: %w", err) + } + + if runErr != nil { + return fmt.Errorf("command failed: %w", runErr) + } + return nil +} diff --git a/repo_park.go b/repo_park.go new file mode 100644 index 000000000..801513f6c --- /dev/null +++ b/repo_park.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "fmt" + "slices" + "strings" + + "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 repoParkCmd struct { + Force bool `help:"Park worktrees even if they have uncommitted changes (changes are discarded)"` +} + +func (*repoParkCmd) Help() string { + return text.Dedent(` + Enters exclusive mode: the whole repository is handed to a single + process so it can reorganize the stack without contending with + worktrees owned by other processes. + + Every linked worktree is recorded in a durable manifest and its + directory is removed; the branches themselves are left untouched, + so the entire graph remains reachable from the primary checkout. + Run 'gs repo restore' to leave exclusive mode and re-create the + worktrees. + + Worktrees with uncommitted changes are refused unless --force is + given, which discards those changes. The manifest is written + before any worktree is removed, so an interrupted park can be + resumed by re-running the command. + `) +} + +func (cmd *repoParkCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + repo *git.Repository, + store *state.Store, +) error { + current := wt.RootDir() + + // Enumerate the linked worktrees to remove: everything except the + // invoking worktree and the bare repository. + var live []*git.WorktreeListItem + for item, err := range repo.Worktrees(ctx) { + if err != nil { + return fmt.Errorf("list worktrees: %w", err) + } + if item.Bare || item.Path == current { + continue + } + live = append(live, item) + } + + // Pre-flight: refuse before touching anything if any worktree has + // uncommitted changes and --force was not given. + if !cmd.Force { + var dirty []string + for _, item := range live { + isDirty, err := worktreeDirty(ctx, repo, item.Path) + if err != nil { + return fmt.Errorf("check %q for changes: %w", item.Path, err) + } + if isDirty { + dirty = append(dirty, item.Path) + } + } + if len(dirty) > 0 { + return fmt.Errorf("worktrees have uncommitted changes: %s; "+ + "commit them or use --force to discard", + strings.Join(dirty, ", ")) + } + } + + // Record every worktree (live plus any already recorded by an + // interrupted park) BEFORE removing anything, so a crash loses + // nothing. + byPath := make(map[string]state.ParkedWorktree) + for _, p := range store.ParkedWorktrees() { + byPath[p.Path] = p + } + for _, item := range live { + byPath[item.Path] = state.ParkedWorktree{ + Path: item.Path, + Branch: item.Branch, + Anchor: anchorForWorktree(store, item.Path), + } + } + + manifest := make([]state.ParkedWorktree, 0, len(byPath)) + for _, p := range byPath { + manifest = append(manifest, p) + } + slices.SortFunc(manifest, func(a, b state.ParkedWorktree) int { + return strings.Compare(a.Path, b.Path) + }) + if err := store.Park(ctx, manifest); err != nil { + return fmt.Errorf("enter exclusive mode: %w", err) + } + + // Remove the worktree directories; refs are left untouched. + for _, item := range live { + if err := repo.WorktreeRemove(ctx, git.WorktreeRemoveRequest{ + Path: item.Path, + Force: cmd.Force, + }); err != nil { + return fmt.Errorf("remove worktree %q: %w", item.Path, err) + } + log.Infof("Parked worktree %s", item.Path) + } + + log.Infof("Parked %d worktree(s); repository is in exclusive mode", len(live)) + return nil +} + +// worktreeDirty reports whether the worktree at the given path has any +// uncommitted changes: staged, unstaged, or untracked. +func worktreeDirty(ctx context.Context, repo *git.Repository, path string) (bool, error) { + wt, err := repo.OpenWorktree(ctx, path) + if err != nil { + return false, fmt.Errorf("open worktree: %w", err) + } + + staged, err := wt.DiffIndex(ctx, "HEAD") + if err != nil { + return false, fmt.Errorf("diff index: %w", err) + } + if len(staged) > 0 { + return true, nil + } + + for _, err := range wt.DiffWork(ctx) { + if err != nil { + return false, fmt.Errorf("diff work tree: %w", err) + } + return true, nil + } + + for _, err := range wt.ListUntrackedFiles(ctx) { + if err != nil { + return false, fmt.Errorf("list untracked files: %w", err) + } + return true, nil + } + + return false, nil +} + +// anchorForWorktree returns the anchor branch registered for the worktree +// at the given path, or an empty string if it owns no anchor. +func anchorForWorktree(store *state.Store, path string) string { + for _, a := range store.Anchors() { + if a.Worktree == path { + return a.Branch + } + } + return "" +} diff --git a/repo_restore.go b/repo_restore.go new file mode 100644 index 000000000..222353514 --- /dev/null +++ b/repo_restore.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "fmt" + + "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 repoRestoreCmd struct{} + +func (*repoRestoreCmd) Help() string { + return text.Dedent(` + Leaves exclusive mode: the worktrees recorded by 'gs repo park' + are re-created at their branches' current tips, and the + exclusive-mode marker is cleared. + + It is idempotent and resumable: worktrees that already exist are + left alone, so an interrupted restore can be finished by + re-running the command. + `) +} + +func (cmd *repoRestoreCmd) Run( + ctx context.Context, + log *silog.Logger, + repo *git.Repository, + store *state.Store, +) error { + if !store.InExclusiveMode() { + log.Infof("Repository is not in exclusive mode; nothing to restore") + return nil + } + parked := store.ParkedWorktrees() + + // Skip worktrees that already exist so a re-run after a crash only + // re-creates what is missing. + existing := make(map[string]bool) + for item, err := range repo.Worktrees(ctx) { + if err != nil { + return fmt.Errorf("list worktrees: %w", err) + } + existing[item.Path] = true + } + + for _, p := range parked { + if existing[p.Path] { + continue + } + + req := git.WorktreeAddRequest{Path: p.Path} + if p.Branch != "" { + req.Head = p.Branch + } else { + req.Detach = true + } + if err := repo.WorktreeAdd(ctx, req); err != nil { + return fmt.Errorf("restore worktree %q: %w", p.Path, err) + } + log.Infof("Restored worktree %s", p.Path) + } + + if err := store.Unpark(ctx); err != nil { + return fmt.Errorf("leave exclusive mode: %w", err) + } + log.Infof("Restored %d worktree(s); exclusive mode cleared", len(parked)) + return nil +} diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 8db9ac897..8a325b66a 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -24,6 +24,10 @@ Repository repo (r) init (i) Initialize a repository repo (r) sync (s) Pull latest changes from the remote repo (r) restack (r) Restack all tracked branches + repo (r) park Enter exclusive mode: park worktrees and take the + whole repo + repo (r) restore Leave exclusive mode: restore parked worktrees + repo (r) exclusive Run a command with the whole repo to itself Log log (l) short (s) List branches diff --git a/testdata/help/repo_exclusive.txt b/testdata/help/repo_exclusive.txt new file mode 100644 index 000000000..625c059d4 --- /dev/null +++ b/testdata/help/repo_exclusive.txt @@ -0,0 +1,28 @@ +Usage: gs repo (r) exclusive [ ...] [flags] + +Run a command with the whole repo to itself + +Runs a command with the whole repository to itself: it parks every worktree (see +'gs repo park'), runs the command, then restores the worktrees (see 'gs repo +restore'). + +The worktrees are always restored, even if the command fails, so this is the +safe way to take exclusive mode for a single command. + +Separate the command from this one's flags with '--', for example: + + gs repo exclusive -- git rebase -i main + +Arguments: + [ ...] Command to run with the repository to itself + +Flags: + --force Park worktrees even if they have uncommitted changes (changes are + discarded) + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/repo_park.txt b/testdata/help/repo_park.txt new file mode 100644 index 000000000..d86fcee01 --- /dev/null +++ b/testdata/help/repo_park.txt @@ -0,0 +1,27 @@ +Usage: gs repo (r) park [flags] + +Enter exclusive mode: park worktrees and take the whole repo + +Enters exclusive mode: the whole repository is handed to a single process so +it can reorganize the stack without contending with worktrees owned by other +processes. + +Every linked worktree is recorded in a durable manifest and its directory is +removed; the branches themselves are left untouched, so the entire graph remains +reachable from the primary checkout. Run 'gs repo restore' to leave exclusive +mode and re-create the worktrees. + +Worktrees with uncommitted changes are refused unless --force is given, which +discards those changes. The manifest is written before any worktree is removed, +so an interrupted park can be resumed by re-running the command. + +Flags: + --force Park worktrees even if they have uncommitted changes (changes are + discarded) + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/repo_restore.txt b/testdata/help/repo_restore.txt new file mode 100644 index 000000000..88e277126 --- /dev/null +++ b/testdata/help/repo_restore.txt @@ -0,0 +1,16 @@ +Usage: gs repo (r) restore + +Leave exclusive mode: restore parked worktrees + +Leaves exclusive mode: the worktrees recorded by 'gs repo park' are re-created +at their branches' current tips, and the exclusive-mode marker is cleared. + +It is idempotent and resumable: worktrees that already exist are left alone, +so an interrupted restore can be finished by re-running the command. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/script/anchor_create_blocked_in_exclusive.txt b/testdata/script/anchor_create_blocked_in_exclusive.txt new file mode 100644 index 000000000..24a97aaaa --- /dev/null +++ b/testdata/script/anchor_create_blocked_in_exclusive.txt @@ -0,0 +1,29 @@ +# While the repository is in exclusive mode (after 'gs repo park'), +# 'gs anchor create' is refused: the whole repo belongs to the parking +# process until 'gs repo restore'. + +as 'Test ' +at '2026-03-29T14:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Enter exclusive mode (no worktrees to remove is fine). +gs repo park +stderr 'Parked' + +# Creating an anchor is blocked while parked. +! gs anchor create $WORK/wtB --name dev +stderr 'exclusive mode' +! exists $WORK/wtB + +# After restore, anchors can be created again. +gs repo restore +gs anchor create $WORK/wtB --name dev +exists $WORK/wtB + +-- repo/dummy.txt -- +dummy diff --git a/testdata/script/repo_exclusive_wrapper.txt b/testdata/script/repo_exclusive_wrapper.txt new file mode 100644 index 000000000..71f97d493 --- /dev/null +++ b/testdata/script/repo_exclusive_wrapper.txt @@ -0,0 +1,36 @@ +# 'gs repo exclusive -- ' is a scoped writer lock: it parks every +# worktree, runs the command with the whole repo to itself, then restores +# the worktrees, even if the command fails. + +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. +gs anchor create $WORK/wtB --name dev -b feat1 +exists $WORK/wtB + +# Run a command under exclusive mode. While it runs, the worktree is +# parked, so 'git worktree list' does not see it. +cd $WORK/repo +gs repo exclusive -- git worktree list +! stdout 'wtB' + +# Afterwards the worktree is restored and exclusive mode is cleared. +exists $WORK/wtB +gs anchor create $WORK/wtC --name dev2 +exists $WORK/wtC + +# Even when the wrapped command fails, the worktrees are restored and the +# wrapper reports the failure. +cd $WORK/repo +! gs repo exclusive -- git no-such-subcommand +exists $WORK/wtB + +-- repo/dummy.txt -- +dummy diff --git a/testdata/script/repo_park_crash_recovery.txt b/testdata/script/repo_park_crash_recovery.txt new file mode 100644 index 000000000..65b77ff75 --- /dev/null +++ b/testdata/script/repo_park_crash_recovery.txt @@ -0,0 +1,42 @@ +# Park and restore are durable and resumable: the exclusive-mode manifest +# lives in the repository's spice state, so a crash mid-operation loses +# nothing. Re-running park or restore picks up where it left off and is +# idempotent. + +as 'Test ' +at '2026-03-29T14:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Two anchor worktrees. +gs anchor create $WORK/wtB --name dev -b feat1 +gs anchor create $WORK/wtC --name dev2 -b feat2 + +# Park them. +cd $WORK/repo +gs repo park +stderr 'Parked' +! exists $WORK/wtB +! exists $WORK/wtC + +# Re-running park while already in exclusive mode is a harmless no-op +# (this is what a resume after a crash looks like). +gs repo park +! exists $WORK/wtB +! exists $WORK/wtC + +# Restore brings both worktrees back. +gs repo restore +stderr 'Restored' +exists $WORK/wtB +exists $WORK/wtC + +# Re-running restore is idempotent: nothing left to restore, no error. +gs repo restore + +-- repo/dummy.txt -- +dummy diff --git a/testdata/script/repo_park_refuses_dirty.txt b/testdata/script/repo_park_refuses_dirty.txt new file mode 100644 index 000000000..0870ee8be --- /dev/null +++ b/testdata/script/repo_park_refuses_dirty.txt @@ -0,0 +1,41 @@ +# 'gs repo park' refuses to remove a worktree that has uncommitted +# changes, naming the dirty worktree, and leaves everything untouched. +# '--force' removes it anyway. + +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 uncommitted work in it. +gs anchor create $WORK/wtB --name dev -b feat1 +cd $WORK/wtB +cp $WORK/dirty.txt dirty.txt +git add dirty.txt + +# Park from the primary is refused: the worktree is dirty and nothing +# is removed. +cd $WORK/repo +! gs repo park +stderr 'wtB' +stderr 'uncommitted' +exists $WORK/wtB + +# The repository is not in exclusive mode, so anchors can still be made. +gs anchor create $WORK/wtC --name dev2 +exists $WORK/wtC + +# With --force, the dirty worktree is removed and exclusive mode begins. +gs repo park --force +stderr 'Parked' +! exists $WORK/wtB +! exists $WORK/wtC + +-- repo/dummy.txt -- +dummy +-- dirty.txt -- +uncommitted diff --git a/testdata/script/repo_park_restore_roundtrip.txt b/testdata/script/repo_park_restore_roundtrip.txt new file mode 100644 index 000000000..b54cd24c6 --- /dev/null +++ b/testdata/script/repo_park_restore_roundtrip.txt @@ -0,0 +1,40 @@ +# 'gs repo park' enters exclusive mode: it records every linked worktree +# in a durable manifest and removes the worktree directories, leaving the +# whole graph in the primary checkout. 'gs repo restore' leaves exclusive +# mode, re-creating the worktrees at their branches' current tips. + +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 feature branch checked out: dev -> feat1. +gs anchor create $WORK/wtB --name dev -b feat1 +exists $WORK/wtB + +# Park from the primary: the worktree directory is removed, but its +# branches survive in the primary. +cd $WORK/repo +gs repo park +stderr 'Parked' +! exists $WORK/wtB + +# The whole graph is reachable from the primary while parked. +gs ls -a +stderr 'feat1' +stderr 'dev' + +# Restore: the worktree comes back with its branch checked out. +gs repo restore +stderr 'Restored' +exists $WORK/wtB +cd $WORK/wtB +git branch --show-current +stdout 'feat1' + +-- repo/dummy.txt -- +dummy From 6814dc5519fa8fe0688d982f5945dc7cbaaa3773 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Mon, 22 Jun 2026 06:42:10 -0400 Subject: [PATCH 2/3] repo park/restore: preserve detached HEAD; recover legibly when a parked branch is deleted Fixes from adversarial review (repo-exclusive layer): - park records each worktree's HEAD; restore re-creates a detached worktree at that commit instead of the primary's HEAD, so a detached checkout's position is no longer lost (B3) - park: document precisely what 'dirty' covers (staged, unstaged, untracked) and that repo-global stashes are never discarded (M5) When the exclusive command deletes a parked branch out-of-band, git-spice state is left inconsistent. Rather than fabricating a branch at a possibly-stale commit or silently dropping the worktree, restore now restores every other worktree, stays in exclusive mode, and reports the missing branch with recovery steps. Recover by re-creating the branch and re-running restore, or discard the worktree with 'gs repo restore --forget PATH'. Each parked commit is pinned under refs/gs-park/ so it survives garbage collection until recovered (B4). --- doc/includes/cli-reference.md | 40 ++++-- internal/git/ref.go | 6 + internal/spice/state/exclusive.go | 6 + internal/spice/state/exclusive_test.go | 4 +- repo_exclusive.go | 6 +- repo_park.go | 30 ++++- repo_restore.go | 124 +++++++++++++++++- testdata/help/repo_exclusive.txt | 5 +- testdata/help/repo_park.txt | 5 +- testdata/help/repo_restore.txt | 14 +- .../script/repo_park_restore_detached.txt | 39 ++++++ testdata/script/repo_restore_branch_gone.txt | 53 ++++++++ testdata/script/repo_restore_forget.txt | 42 ++++++ 13 files changed, 349 insertions(+), 25 deletions(-) create mode 100644 testdata/script/repo_park_restore_detached.txt create mode 100644 testdata/script/repo_restore_branch_gone.txt create mode 100644 testdata/script/repo_restore_forget.txt diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 1de80e92f..5268572e2 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -203,8 +203,9 @@ so the entire graph remains reachable from the primary checkout. Run 'gs repo restore' to leave exclusive mode and re-create the worktrees. -Worktrees with uncommitted changes are refused unless --force is -given, which discards those changes. The manifest is written +A worktree with staged, unstaged, or untracked changes is refused +unless --force is given, which discards those changes. Stashes are +repository-global and are never discarded. The manifest is written before any worktree is removed, so an interrupted park can be resumed by re-running the command. @@ -215,7 +216,7 @@ resumed by re-running the command. ### git-spice repo restore {#gs-repo-restore} ``` -gs repo (r) restore +gs repo (r) restore [flags] ``` Leave exclusive mode: restore parked worktrees @@ -228,6 +229,20 @@ It is idempotent and resumable: worktrees that already exist are left alone, so an interrupted restore can be finished by re-running the command. +If a parked branch no longer exists, the branch was deleted +outside git-spice while the repository was parked. That leaves +git-spice's state inconsistent, so restore cannot put the +worktree back on its own. It restores every other worktree but +stays in exclusive mode and reports what is wrong. Recover by +either re-creating the missing branch and re-running restore, or +discarding that worktree with --forget. The commit each worktree +was parked at is preserved under refs/gs-park/ so it is not lost +to garbage collection in the meantime. + +**Flags** + +* `--forget=PATH,...`: Discard a parked worktree whose branch is gone instead of restoring it (repeatable) + ### git-spice repo exclusive {#gs-repo-exclusive} ``` @@ -240,8 +255,10 @@ Runs a command with the whole repository to itself: it parks every worktree (see 'gs repo park'), runs the command, then restores the worktrees (see 'gs repo restore'). -The worktrees are always restored, even if the command fails, so -this is the safe way to take exclusive mode for a single command. +The worktrees are restored even if the command fails. If the +command deletes a parked branch, restore cannot put that worktree +back; it restores the rest, stays in exclusive mode, and explains +how to recover with 'gs repo restore'. Separate the command from this one's flags with '--', for example: @@ -1461,7 +1478,8 @@ 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. +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. @@ -1485,9 +1503,13 @@ gs anchor list (ls) List anchors and their worktrees -Lists all worktrees associated with the repository. -For each worktree, shows the checked-out branch -and any tracked branches in its stack. +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 +'*'. ### git-spice anchor track {#gs-anchor-track} diff --git a/internal/git/ref.go b/internal/git/ref.go index a9acb784b..480f18422 100644 --- a/internal/git/ref.go +++ b/internal/git/ref.go @@ -97,3 +97,9 @@ func (r *Repository) SetRef(ctx context.Context, req SetRefRequest) error { } return r.gitCmd(ctx, "update-ref", args...).Run() } + +// DeleteRef deletes a ref. +func (r *Repository) DeleteRef(ctx context.Context, ref string) error { + r.log.Debug("Deleting Git ref", "name", ref) + return r.gitCmd(ctx, "update-ref", "-d", ref).Run() +} diff --git a/internal/spice/state/exclusive.go b/internal/spice/state/exclusive.go index b399342cd..1260ddd89 100644 --- a/internal/spice/state/exclusive.go +++ b/internal/spice/state/exclusive.go @@ -25,6 +25,11 @@ type ParkedWorktree struct { // Empty if the worktree was in detached-HEAD state. Branch string + // Head is the commit hash the worktree's HEAD pointed at. + // Restore uses it to re-create a detached worktree at the right + // commit, and as a fallback when Branch no longer exists. + Head string + // Anchor is the anchor branch the worktree owned, if any. // Recorded for legibility; restore re-checks-out Branch, and the // anchor registration itself survives parking untouched. @@ -39,6 +44,7 @@ type exclusiveInfo struct { type parkedWorktreeInfo struct { Path string `json:"path"` Branch string `json:"branch,omitempty"` + Head string `json:"head,omitempty"` Anchor string `json:"anchor,omitempty"` } diff --git a/internal/spice/state/exclusive_test.go b/internal/spice/state/exclusive_test.go index 08362ba55..76a11e786 100644 --- a/internal/spice/state/exclusive_test.go +++ b/internal/spice/state/exclusive_test.go @@ -23,8 +23,8 @@ func TestStore_Exclusive(t *testing.T) { assert.Empty(t, store.ParkedWorktrees()) worktrees := []state.ParkedWorktree{ - {Path: "/wt/a", Branch: "feat-a", Anchor: "anchor-a"}, - {Path: "/wt/b", Branch: "feat-b"}, + {Path: "/wt/a", Branch: "feat-a", Head: "hasha", Anchor: "anchor-a"}, + {Path: "/wt/b", Head: "hashb"}, // detached } require.NoError(t, store.Park(ctx, worktrees)) diff --git a/repo_exclusive.go b/repo_exclusive.go index 4607b8071..ff3384f75 100644 --- a/repo_exclusive.go +++ b/repo_exclusive.go @@ -25,8 +25,10 @@ func (*repoExclusiveCmd) Help() string { worktree (see 'gs repo park'), runs the command, then restores the worktrees (see 'gs repo restore'). - The worktrees are always restored, even if the command fails, so - this is the safe way to take exclusive mode for a single command. + The worktrees are restored even if the command fails. If the + command deletes a parked branch, restore cannot put that worktree + back; it restores the rest, stays in exclusive mode, and explains + how to recover with 'gs repo restore'. Separate the command from this one's flags with '--', for example: diff --git a/repo_park.go b/repo_park.go index 801513f6c..d61055d67 100644 --- a/repo_park.go +++ b/repo_park.go @@ -28,8 +28,9 @@ func (*repoParkCmd) Help() string { Run 'gs repo restore' to leave exclusive mode and re-create the worktrees. - Worktrees with uncommitted changes are refused unless --force is - given, which discards those changes. The manifest is written + A worktree with staged, unstaged, or untracked changes is refused + unless --force is given, which discards those changes. Stashes are + repository-global and are never discarded. The manifest is written before any worktree is removed, so an interrupted park can be resumed by re-running the command. `) @@ -88,6 +89,7 @@ func (cmd *repoParkCmd) Run( byPath[item.Path] = state.ParkedWorktree{ Path: item.Path, Branch: item.Branch, + Head: item.Head.String(), Anchor: anchorForWorktree(store, item.Path), } } @@ -103,6 +105,23 @@ func (cmd *repoParkCmd) Run( return fmt.Errorf("enter exclusive mode: %w", err) } + // Pin each parked commit under refs/gs-park/ so it stays reachable + // for recovery even if the exclusive command deletes the branch that + // held it and Git later prunes the now-unreachable object. The ref + // is removed by 'gs repo restore' once the worktree is recovered. + for _, p := range manifest { + if p.Head == "" { + continue + } + if err := repo.SetRef(ctx, git.SetRefRequest{ + Ref: parkRef(p.Head), + Hash: git.Hash(p.Head), + Reason: "git-spice: pin parked commit", + }); err != nil { + log.Warnf("Could not pin parked commit %s: %v", p.Head, err) + } + } + // Remove the worktree directories; refs are left untouched. for _, item := range live { if err := repo.WorktreeRemove(ctx, git.WorktreeRemoveRequest{ @@ -151,6 +170,13 @@ func worktreeDirty(ctx context.Context, repo *git.Repository, path string) (bool return false, nil } +// parkRef is the ref under which a parked commit is pinned so it stays +// reachable while the repository is in exclusive mode. It is keyed by the +// commit hash, so it is unique and needs no separate bookkeeping. +func parkRef(head string) string { + return "refs/gs-park/" + head +} + // anchorForWorktree returns the anchor branch registered for the worktree // at the given path, or an empty string if it owns no anchor. func anchorForWorktree(store *state.Store, path string) string { diff --git a/repo_restore.go b/repo_restore.go index 222353514..d29fb8409 100644 --- a/repo_restore.go +++ b/repo_restore.go @@ -3,6 +3,8 @@ package main import ( "context" "fmt" + "path/filepath" + "slices" "go.abhg.dev/gs/internal/git" "go.abhg.dev/gs/internal/silog" @@ -10,7 +12,9 @@ import ( "go.abhg.dev/gs/internal/text" ) -type repoRestoreCmd struct{} +type repoRestoreCmd struct { + Forget []string `name:"forget" placeholder:"PATH" help:"Discard a parked worktree whose branch is gone instead of restoring it (repeatable)"` +} func (*repoRestoreCmd) Help() string { return text.Dedent(` @@ -21,6 +25,16 @@ func (*repoRestoreCmd) Help() string { It is idempotent and resumable: worktrees that already exist are left alone, so an interrupted restore can be finished by re-running the command. + + If a parked branch no longer exists, the branch was deleted + outside git-spice while the repository was parked. That leaves + git-spice's state inconsistent, so restore cannot put the + worktree back on its own. It restores every other worktree but + stays in exclusive mode and reports what is wrong. Recover by + either re-creating the missing branch and re-running restore, or + discarding that worktree with --forget. The commit each worktree + was parked at is preserved under refs/gs-park/ so it is not lost + to garbage collection in the meantime. `) } @@ -36,8 +50,17 @@ func (cmd *repoRestoreCmd) Run( } parked := store.ParkedWorktrees() - // Skip worktrees that already exist so a re-run after a crash only - // re-creates what is missing. + forget := make(map[string]bool, len(cmd.Forget)) + for _, p := range cmd.Forget { + match, ok := matchParked(parked, p) + if !ok { + return fmt.Errorf("--forget %q does not match any parked worktree", p) + } + forget[match] = true + } + + // Worktrees that already exist on disk are skipped, so a re-run after + // a crash only re-creates what is missing. existing := make(map[string]bool) for item, err := range repo.Worktrees(ctx) { if err != nil { @@ -46,16 +69,38 @@ func (cmd *repoRestoreCmd) Run( existing[item.Path] = true } + var ( + remaining []state.ParkedWorktree // manifest after this run + blocked []state.ParkedWorktree // parked branch is gone + ) for _, p := range parked { + // Discard a worktree the user removed intentionally: drop it + // from the manifest and release its pinned commit. + if forget[p.Path] { + releaseParkRef(ctx, repo, log, p.Head) + log.Infof("Forgot parked worktree %s", p.Path) + continue + } + remaining = append(remaining, p) + if existing[p.Path] { continue } + // A worktree parked on a branch that no longer exists cannot be + // restored without guessing where its work went (the branch may + // have been deleted, renamed, or rebased away). Don't fabricate + // a branch at a possibly-stale commit; surface it instead. + if p.Branch != "" && !repo.BranchExists(ctx, p.Branch) { + blocked = append(blocked, p) + continue + } + req := git.WorktreeAddRequest{Path: p.Path} if p.Branch != "" { req.Head = p.Branch } else { - req.Detach = true + req.Detach, req.Head = true, p.Head } if err := repo.WorktreeAdd(ctx, req); err != nil { return fmt.Errorf("restore worktree %q: %w", p.Path, err) @@ -63,9 +108,78 @@ func (cmd *repoRestoreCmd) Run( log.Infof("Restored worktree %s", p.Path) } + if len(blocked) > 0 { + // Persist the --forget removals so they survive the wedge, then + // stay in exclusive mode until the inconsistency is resolved. + if len(forget) > 0 { + if err := store.Park(ctx, remaining); err != nil { + return fmt.Errorf("update manifest: %w", err) + } + } + return reportBlocked(log, blocked) + } + + // Everything is restored or forgotten: release the pinned commits + // and leave exclusive mode. + for _, p := range remaining { + releaseParkRef(ctx, repo, log, p.Head) + } if err := store.Unpark(ctx); err != nil { return fmt.Errorf("leave exclusive mode: %w", err) } - log.Infof("Restored %d worktree(s); exclusive mode cleared", len(parked)) + log.Infof("Restored worktrees; exclusive mode cleared") return nil } + +// matchParked resolves a user-supplied --forget path to a parked +// worktree path, accepting either the exact recorded path or a cleaned / +// absolute form of it. +func matchParked(parked []state.ParkedWorktree, path string) (string, bool) { + candidates := []string{filepath.Clean(path)} + if abs, err := filepath.Abs(path); err == nil { + candidates = append(candidates, abs) + } + for _, p := range parked { + if slices.Contains(candidates, p.Path) { + return p.Path, true + } + } + return "", false +} + +// releaseParkRef removes the pin that kept a parked commit reachable. +// Cleanup is best-effort: a leftover ref is harmless. +func releaseParkRef( + ctx context.Context, repo *git.Repository, log *silog.Logger, head string, +) { + if head == "" { + return + } + if err := repo.DeleteRef(ctx, parkRef(head)); err != nil { + log.Debugf("Could not release parked commit %s: %v", head, err) + } +} + +// reportBlocked explains worktrees that could not be restored because +// their branch is gone, and how to recover, then returns an error so the +// command exits non-zero with exclusive mode still set. +func reportBlocked(log *silog.Logger, blocked []state.ParkedWorktree) error { + for _, p := range blocked { + log.Errorf("worktree %s: parked branch %q no longer exists", p.Path, p.Branch) + if p.Anchor != "" && p.Anchor == p.Branch { + log.Errorf(" %q was this worktree's anchor; "+ + "its registration and any branches stacked on it now dangle", + p.Branch) + } + if p.Head != "" { + log.Errorf(" parked commit %s is preserved at %s", p.Head, parkRef(p.Head)) + } + } + log.Infof("To recover, re-create each missing branch and re-run " + + "'gs repo restore', for example: git branch BRANCH COMMIT") + log.Infof("Or, to discard a worktree you removed intentionally: " + + "gs repo restore --forget PATH") + return fmt.Errorf( + "restore incomplete: %d worktree(s) blocked by missing branches", + len(blocked)) +} diff --git a/testdata/help/repo_exclusive.txt b/testdata/help/repo_exclusive.txt index 625c059d4..6ba659b3c 100644 --- a/testdata/help/repo_exclusive.txt +++ b/testdata/help/repo_exclusive.txt @@ -6,8 +6,9 @@ Runs a command with the whole repository to itself: it parks every worktree (see 'gs repo park'), runs the command, then restores the worktrees (see 'gs repo restore'). -The worktrees are always restored, even if the command fails, so this is the -safe way to take exclusive mode for a single command. +The worktrees are restored even if the command fails. If the command deletes +a parked branch, restore cannot put that worktree back; it restores the rest, +stays in exclusive mode, and explains how to recover with 'gs repo restore'. Separate the command from this one's flags with '--', for example: diff --git a/testdata/help/repo_park.txt b/testdata/help/repo_park.txt index d86fcee01..7205f1f36 100644 --- a/testdata/help/repo_park.txt +++ b/testdata/help/repo_park.txt @@ -11,8 +11,9 @@ removed; the branches themselves are left untouched, so the entire graph remains reachable from the primary checkout. Run 'gs repo restore' to leave exclusive mode and re-create the worktrees. -Worktrees with uncommitted changes are refused unless --force is given, which -discards those changes. The manifest is written before any worktree is removed, +A worktree with staged, unstaged, or untracked changes is refused unless --force +is given, which discards those changes. Stashes are repository-global and +are never discarded. The manifest is written before any worktree is removed, so an interrupted park can be resumed by re-running the command. Flags: diff --git a/testdata/help/repo_restore.txt b/testdata/help/repo_restore.txt index 88e277126..12b0e42ae 100644 --- a/testdata/help/repo_restore.txt +++ b/testdata/help/repo_restore.txt @@ -1,4 +1,4 @@ -Usage: gs repo (r) restore +Usage: gs repo (r) restore [flags] Leave exclusive mode: restore parked worktrees @@ -8,6 +8,18 @@ at their branches' current tips, and the exclusive-mode marker is cleared. It is idempotent and resumable: worktrees that already exist are left alone, so an interrupted restore can be finished by re-running the command. +If a parked branch no longer exists, the branch was deleted outside git-spice +while the repository was parked. That leaves git-spice's state inconsistent, +so restore cannot put the worktree back on its own. It restores every other +worktree but stays in exclusive mode and reports what is wrong. Recover by +either re-creating the missing branch and re-running restore, or discarding +that worktree with --forget. The commit each worktree was parked at is preserved +under refs/gs-park/ so it is not lost to garbage collection in the meantime. + +Flags: + --forget=PATH,... Discard a parked worktree whose branch is gone instead of + restoring it (repeatable) + Global Flags: -h, --help Show help for the command --version Print version information and quit diff --git a/testdata/script/repo_park_restore_detached.txt b/testdata/script/repo_park_restore_detached.txt new file mode 100644 index 000000000..0ea42403f --- /dev/null +++ b/testdata/script/repo_park_restore_detached.txt @@ -0,0 +1,39 @@ +# 'gs repo park' records a detached worktree's HEAD commit, so +# 'gs repo restore' re-creates it at that same commit rather than at the +# primary checkout's (possibly newer) HEAD. + +as 'Test ' +at '2026-03-29T14:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# A detached worktree at the current trunk commit. +gs anchor create --no-anchor $WORK/wtD +cd $WORK/wtD +git rev-parse --abbrev-ref HEAD +stdout 'HEAD' + +# Advance the primary's trunk so its HEAD differs from the detached +# worktree's commit. +cd $WORK/repo +git commit --allow-empty -m 'Second commit' + +# Park and restore. +gs repo park +! exists $WORK/wtD +gs repo restore +exists $WORK/wtD + +# The detached worktree is back at its own commit ('Initial commit'), +# not the primary's advanced HEAD ('Second commit'). +cd $WORK/wtD +git log -1 --format=%s +stdout 'Initial commit' +! stdout 'Second commit' + +-- repo/dummy.txt -- +dummy diff --git a/testdata/script/repo_restore_branch_gone.txt b/testdata/script/repo_restore_branch_gone.txt new file mode 100644 index 000000000..9e47c8869 --- /dev/null +++ b/testdata/script/repo_restore_branch_gone.txt @@ -0,0 +1,53 @@ +# When a branch is deleted out-of-band while the repository is parked, +# 'gs repo restore' cannot put that worktree back (the branch is gone and +# git-spice's state is now inconsistent). It restores everything else but +# stays wedged in exclusive mode and explains how to recover, rather than +# fabricating a branch or silently dropping the 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 a feature branch checked out. +gs anchor create $WORK/wtB --name dev -b feat1 + +# Park from the primary. +cd $WORK/repo +gs repo park +! exists $WORK/wtB + +# The parked commit is pinned so it cannot be lost to GC. +exec git for-each-ref refs/gs-park/ +stdout 'refs/gs-park/' + +# Simulate the exclusive command deleting the parked branch. +git branch -D feat1 + +# Restore refuses to complete: it reports the missing branch and how to +# recover, and stays in exclusive mode. +! gs repo restore +stderr 'parked branch "feat1" no longer exists' +stderr 'To recover' +! exists $WORK/wtB + +# Still parked: new worktrees remain blocked. +! gs anchor create $WORK/wtX --name x +stderr 'exclusive mode' + +# Recover by re-creating the branch, then re-running restore. +git branch feat1 main +gs repo restore +stderr 'exclusive mode cleared' +exists $WORK/wtB + +# The pin is released once recovery completes. +exec git for-each-ref refs/gs-park/ +! stdout 'refs/gs-park/' + +-- repo/dummy.txt -- +dummy diff --git a/testdata/script/repo_restore_forget.txt b/testdata/script/repo_restore_forget.txt new file mode 100644 index 000000000..aca325d30 --- /dev/null +++ b/testdata/script/repo_restore_forget.txt @@ -0,0 +1,42 @@ +# 'gs repo restore --forget' discards a parked worktree whose branch was +# deleted on purpose, so the repository can leave exclusive mode without +# resurrecting a branch the user meant to remove. + +as 'Test ' +at '2026-03-29T14:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Two worktrees: one keeps its branch, one will lose it. +gs anchor create $WORK/wtKeep --name keep -b featKeep +gs anchor create $WORK/wtDrop --name drop -b featDrop + +# Park, then delete one worktree's branch intentionally. +cd $WORK/repo +gs repo park +git branch -D featDrop + +# A plain restore is blocked on the deleted branch. +! gs repo restore +stderr 'parked branch "featDrop" no longer exists' + +# Discard the intentionally-deleted worktree: the kept one is restored +# and exclusive mode clears. +gs repo restore --forget $WORK/wtDrop +stderr 'Forgot parked worktree' +stderr 'exclusive mode cleared' +exists $WORK/wtKeep +! exists $WORK/wtDrop + +# A non-matching --forget path is rejected. +gs repo park +! gs repo restore --forget $WORK/nope +stderr 'does not match any parked worktree' +gs repo restore + +-- repo/dummy.txt -- +dummy From 9c4651b21f8b6c243b1591d58b05846ae31b4f52 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:39:49 +0000 Subject: [PATCH 3/3] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 36 +++++++++++++++++------------ internal/handler/sync/mocks_test.go | 12 +++++----- 2 files changed, 27 insertions(+), 21 deletions(-) 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/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 }