diff --git a/.changes/unreleased/Added-20260620-063403.yaml b/.changes/unreleased/Added-20260620-063403.yaml new file mode 100644 index 00000000..e9e63ff3 --- /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 1c557252..e141362a 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 0f673d77..b51ff79d 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -185,6 +185,93 @@ 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. + +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** + +* `--force`: Park worktrees even if they have uncommitted changes (changes are discarded) + +### git-spice repo restore {#gs-repo-restore} + +``` +gs repo (r) restore [flags] +``` + +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. + +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} + +``` +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 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: + + 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} @@ -316,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. @@ -327,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} @@ -630,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: @@ -648,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 @@ -664,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} @@ -1139,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/git/ref.go b/internal/git/ref.go index a9acb784..480f1842 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/handler/sync/mocks_test.go b/internal/handler/sync/mocks_test.go index bc8f1b22..5092e04f 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/state/exclusive.go b/internal/spice/state/exclusive.go new file mode 100644 index 00000000..1260ddd8 --- /dev/null +++ b/internal/spice/state/exclusive.go @@ -0,0 +1,122 @@ +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 + + // 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. + 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"` + Head string `json:"head,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 00000000..76a11e78 --- /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", Head: "hasha", Anchor: "anchor-a"}, + {Path: "/wt/b", Head: "hashb"}, // detached + } + 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 84c800c0..1f2e7d04 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 d4a60716..d021c7c4 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 00000000..ff3384f7 --- /dev/null +++ b/repo_exclusive.go @@ -0,0 +1,84 @@ +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 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: + + 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 00000000..d61055d6 --- /dev/null +++ b/repo_park.go @@ -0,0 +1,189 @@ +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. + + 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. + `) +} + +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, + Head: item.Head.String(), + 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) + } + + // 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{ + 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 +} + +// 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 { + 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 00000000..d29fb840 --- /dev/null +++ b/repo_restore.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "fmt" + "path/filepath" + "slices" + + "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 { + 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(` + 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. + + 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. + `) +} + +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() + + 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 { + return fmt.Errorf("list worktrees: %w", err) + } + 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, req.Head = true, p.Head + } + 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 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 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/gs.txt b/testdata/help/gs.txt index 8db9ac89..8a325b66 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 00000000..6ba659b3 --- /dev/null +++ b/testdata/help/repo_exclusive.txt @@ -0,0 +1,29 @@ +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 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: + + 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 00000000..7205f1f3 --- /dev/null +++ b/testdata/help/repo_park.txt @@ -0,0 +1,28 @@ +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. + +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: + --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 00000000..12b0e42a --- /dev/null +++ b/testdata/help/repo_restore.txt @@ -0,0 +1,28 @@ +Usage: gs repo (r) restore [flags] + +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. + +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 + -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 00000000..24a97aaa --- /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 00000000..71f97d49 --- /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 00000000..65b77ff7 --- /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 00000000..0870ee8b --- /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_detached.txt b/testdata/script/repo_park_restore_detached.txt new file mode 100644 index 00000000..0ea42403 --- /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_park_restore_roundtrip.txt b/testdata/script/repo_park_restore_roundtrip.txt new file mode 100644 index 00000000..b54cd24c --- /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 diff --git a/testdata/script/repo_restore_branch_gone.txt b/testdata/script/repo_restore_branch_gone.txt new file mode 100644 index 00000000..9e47c886 --- /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 00000000..aca325d3 --- /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