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
1 change: 1 addition & 0 deletions anchor.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ type anchorCmd struct {
Create anchorCreateCmd `cmd:"" aliases:"c" help:"Create a new worktree anchored at a branch"`
List anchorListCmd `cmd:"" aliases:"ls" help:"List anchors and their worktrees"`
Track anchorTrackCmd `cmd:"" aliases:"tr" help:"Track an existing worktree as an anchor"`
Rm anchorRmCmd `cmd:"" aliases:"rm" help:"Remove an anchor worktree and dissolve its anchor"`
}
145 changes: 145 additions & 0 deletions anchor_rm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package main

import (
"cmp"
"context"
"errors"
"fmt"
"slices"

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

type anchorRmCmd struct {
Path string `arg:"" help:"Path of the anchor worktree to remove"`
Force bool `help:"Remove the worktree even if it has uncommitted changes"`
}

func (*anchorRmCmd) Help() string {
return text.Dedent(`
Removes an anchor worktree and dissolves its anchor.

The anchor's direct child branches are retargeted onto the
anchor's base (the canonical trunk for a root anchor, or the
pinned branch for an internal anchor); they are left needing a
restack. The anchor branch is then deleted and the worktree
directory removed.

Use this when a worktree's work is done. Run it from a different
worktree (for example, the primary checkout): a worktree cannot
remove itself.
`)
}

func (cmd *anchorRmCmd) Run(
ctx context.Context,
log *silog.Logger,
repo *git.Repository,
store *state.Store,
svc *spice.Service,
wt *git.Worktree,
) error {
// Resolve the path to a worktree root and find its anchor.
target, err := repo.OpenWorktree(ctx, cmd.Path)
if err != nil {
return fmt.Errorf("open worktree %q: %w", cmd.Path, err)
}
rootDir := target.RootDir()

anchor, found := findAnchorForWorktree(ctx, store, target, rootDir)
if !found {
return fmt.Errorf("%v is not an anchor worktree", cmd.Path)
}

// A worktree cannot remove itself: Git refuses to remove the current
// working tree, so refuse early, before mutating any state.
if wt.RootDir() == rootDir {
return errors.New("a worktree cannot remove itself; " +
"run 'gs anchor rm' from a different worktree")
}

// The base onto which the anchor's children are retargeted:
// the pinned branch for an internal anchor, else the canonical trunk.
base := cmp.Or(anchor.Base, store.Trunk())

// Collect the anchor's direct children before mutating anything.
graph, err := svc.BranchGraph(ctx, nil)
if err != nil {
return fmt.Errorf("load branch graph: %w", err)
}
children := slices.Collect(graph.Aboves(anchor.Branch))

// Remove the worktree first. This is the only step that can fail for
// a reason outside our control (a dirty tree without --force), so do
// it before any state mutation: if it fails, nothing has changed and
// the command is safe to retry. Refs are left untouched.
if err := repo.WorktreeRemove(ctx, git.WorktreeRemoveRequest{
Path: rootDir,
Force: cmd.Force,
}); err != nil {
return fmt.Errorf("remove worktree: %w", err)
}
log.Infof("Removed worktree %s", cmd.Path)

// Retarget each direct child onto the anchor's base. Retarget-only
// updates state without rebasing, so the children are left needing
// a restack rather than risking a conflict.
for _, child := range children {
if err := svc.BranchOnto(ctx, &spice.BranchOntoRequest{
Branch: child,
Onto: base,
Mode: spice.BranchOntoRetargetOnly,
}); err != nil {
return fmt.Errorf("retarget %q onto %q: %w", child, base, err)
}
log.Infof("%v: retargeted onto %v", child, base)
}

// Tear out the anchor: delete its pointer branch and drop its
// registration.
if err := repo.DeleteBranch(ctx, anchor.Branch, git.BranchDeleteOptions{
Force: true,
}); err != nil {
return fmt.Errorf("delete anchor branch %q: %w", anchor.Branch, err)
}

if err := store.UnregisterAnchor(ctx, anchor.Branch); err != nil {
return fmt.Errorf("unregister anchor: %w", err)
}
log.Infof("Dissolved anchor %s", anchor.Branch)

return nil
}

// findAnchorForWorktree resolves the anchor owned by a worktree.
//
// It matches the registry's recorded worktree path first, then falls
// back to the anchor branch checked out in the worktree: the recorded
// path is advisory and can be stale if the worktree moved.
func findAnchorForWorktree(
ctx context.Context,
store *state.Store,
target *git.Worktree,
rootDir string,
) (state.Anchor, bool) {
anchors := store.Anchors()
for _, a := range anchors {
if a.Worktree == rootDir {
return a, true
}
}

if branch, err := target.CurrentBranch(ctx); err == nil {
for _, a := range anchors {
if a.Branch == branch {
return a, true
}
}
}

return state.Anchor{}, false
}
64 changes: 49 additions & 15 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,11 @@ Before merging, the stack is checked for branches
whose base PR was already merged on the forge.
Use --no-branch-check to skip this validation.

Before each merge, waits for CI checks to pass.
Use --build-timeout to configure the maximum wait
before failing if checks are not ready.
Before each merge, waits for merge readiness:
the forge must observe the pushed head
and report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait
before failing if merge readiness is not reached.

By default, a branch failure skips that branch's upstack descendants,
but independent sibling branches continue.
Expand All @@ -327,12 +329,12 @@ Use --fail-fast to stop the queue after the first branch failure.
**Flags**

* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--no-branch-check`: Skip stale base validation before merging.
* `--fail-fast`: Stop the merge queue after the first branch failure.
* `--branch=NAME`: Branch whose stack to merge

**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod)
**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)

### git-spice stack restack {#gs-stack-restack}

Expand Down Expand Up @@ -630,7 +632,7 @@ This command acts as a local merge queue:
it merges one Change Request,
waits for that merge to finish,
restacks and updates the next Change Request,
waits for its CI checks to pass,
waits for merge readiness on the updated Change Request,
and then repeats the process.

For a stack like this:
Expand All @@ -648,13 +650,15 @@ Before merging, the downstack is checked for branches
whose base PR was already merged on the forge.
Use --no-branch-check to skip this validation.

Before each merge, waits for CI checks to pass.
Use --build-timeout to configure the maximum wait
Before each merge, waits for merge readiness:
the forge must observe the pushed head
and report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait
(default: 30m, 0 means fail immediately if not ready).

Between merges, the command waits for each merge
to complete, restacks and updates the next PR,
waits for CI checks on the updated PR,
waits for merge readiness on the updated PR,
and syncs merged branch cleanup.

Use --no-wait for single branch merging
Expand All @@ -664,12 +668,12 @@ when you don't want to wait for the merge to propagate.
**Flags**

* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--no-wait`: Skip polling for a single branch merge to propagate.
* `--no-branch-check`: Skip stale base validation before merging.
* `--branch=NAME`: Branch to start merging from

**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod)
**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)

### git-spice downstack edit {#gs-downstack-edit}

Expand Down Expand Up @@ -1139,16 +1143,18 @@ Use --branch to merge a different branch.
The branch must be based directly on trunk.
To merge a stacked branch, use 'gs downstack merge'.

Before merging, waits for CI checks to pass.
Use --build-timeout to configure the maximum wait.
Before merging, waits for merge readiness:
the forge must observe the pushed head
and report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait.

**Flags**

* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--branch=NAME`: Branch to merge

**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod)
**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)

### git-spice branch submit {#gs-branch-submit}

Expand Down Expand Up @@ -1452,6 +1458,34 @@ the worktree updates it from the remote.

* `--name=BRANCH`: Branch to register as this worktree's anchor (defaults to the branch checked out in the current worktree)

### git-spice anchor rm {#gs-anchor-rm}

```
gs anchor rm (rm) <path> [flags]
```

Remove an anchor worktree and dissolve its anchor

Removes an anchor worktree and dissolves its anchor.

The anchor's direct child branches are retargeted onto the
anchor's base (the canonical trunk for a root anchor, or the
pinned branch for an internal anchor); they are left needing a
restack. The anchor branch is then deleted and the worktree
directory removed.

Use this when a worktree's work is done. Run it from a different
worktree (for example, the primary checkout): a worktree cannot
remove itself.

**Arguments**

* `path`: Path of the anchor worktree to remove

**Flags**

* `--force`: Remove the worktree even if it has uncommitted changes

## Rebase

### git-spice rebase continue {#gs-rebase-continue}
Expand Down
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.

26 changes: 26 additions & 0 deletions testdata/help/anchor_rm.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Usage: gs anchor rm (rm) <path> [flags]

Remove an anchor worktree and dissolve its anchor

Removes an anchor worktree and dissolves its anchor.

The anchor's direct child branches are retargeted onto the anchor's base (the
canonical trunk for a root anchor, or the pinned branch for an internal anchor);
they are left needing a restack. The anchor branch is then deleted and the
worktree directory removed.

Use this when a worktree's work is done. Run it from a different worktree (for
example, the primary checkout): a worktree cannot remove itself.

Arguments:
<path> Path of the anchor worktree to remove

Flags:
--force Remove the worktree even if it has uncommitted changes

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
1 change: 1 addition & 0 deletions testdata/help/gs.txt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Anchor
anchor create (c) Create a new worktree anchored at a branch
anchor list (ls) List anchors and their worktrees
anchor track (tr) Track an existing worktree as an anchor
anchor rm (rm) Remove an anchor worktree and dissolve its anchor

Rebase
rebase (rb) continue (c) Continue an interrupted operation
Expand Down
Loading
Loading