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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260620-063403.yaml
Original file line number Diff line number Diff line change
@@ -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 -- <cmd>` wraps a command in park/restore. While parked, `gs anchor create` is refused.'
time: 2026-06-20T06:34:03.614351-04:00
7 changes: 7 additions & 0 deletions anchor_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 108 additions & 15 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<command> ...] [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}
Expand Down Expand Up @@ -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.
Expand All @@ -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}

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

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

Expand Down
6 changes: 6 additions & 0 deletions internal/git/ref.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
12 changes: 6 additions & 6 deletions internal/handler/sync/mocks_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

122 changes: 122 additions & 0 deletions internal/spice/state/exclusive.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading