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-20260601-125454.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: '''integration tip clean'' (alias ''prune''): Remove tips whose upstack chain already contains another configured tip.'
time: 2026-06-01T12:54:54.098874-04:00
54 changes: 39 additions & 15 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,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 @@ -328,12 +330,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 @@ -631,7 +633,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 @@ -649,13 +651,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 @@ -665,12 +669,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 @@ -1140,16 +1144,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 @@ -1601,6 +1607,24 @@ gs integration (int) tip list (l,ls)

List the configured integration tips

### git-spice integration tip clean {#gs-integration-tip-clean}

```
gs integration (int) tip clean (prune)
```

Remove tips whose upstack already contains another tip

Removes tips whose upstack chain already contains another
configured tip. The higher tip's merge into the integration
branch captures the lower tip's content, so keeping both
costs an extra merge without changing the result.

For each subsumed tip, the message reports which higher tip
subsumes it. A second run is a no-op once nothing remains
to prune. Existing tips with no upstack-tip relationship
are left alone.

## Rebase

### git-spice rebase continue {#gs-rebase-continue}
Expand Down
97 changes: 97 additions & 0 deletions integration_tip.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import (
"fmt"

"github.com/alecthomas/kong"
"go.abhg.dev/gs/internal/handler/integration"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/text"
)

type integrationTipCmd struct {
Add integrationTipAddCmd `cmd:"" aliases:"a" help:"Add a branch to the integration tip list"`
Remove integrationTipRemoveCmd `cmd:"" aliases:"r,rm" help:"Remove a branch from the integration tip list"`
List integrationTipListCmd `cmd:"" aliases:"l,ls" help:"List the configured integration tips"`
Clean integrationTipCleanCmd `cmd:"" aliases:"prune" help:"Remove tips whose upstack already contains another tip"`
}

type integrationTipAddCmd struct {
Expand Down Expand Up @@ -78,3 +81,97 @@ func (cmd *integrationTipListCmd) Run(
}
return nil
}

type integrationTipCleanCmd struct{}

func (*integrationTipCleanCmd) Help() string {
return text.Dedent(`
Removes tips whose upstack chain already contains another
configured tip. The higher tip's merge into the integration
branch captures the lower tip's content, so keeping both
costs an extra merge without changing the result.

For each subsumed tip, the message reports which higher tip
subsumes it. A second run is a no-op once nothing remains
to prune. Existing tips with no upstack-tip relationship
are left alone.
`)
}

func (cmd *integrationTipCleanCmd) Run(
ctx context.Context,
log *silog.Logger,
svc *spice.Service,
handler IntegrationHandler,
) error {
status, err := handler.Show(ctx)
if err != nil {
return err
}

graph, err := svc.BranchGraph(ctx, nil)
if err != nil {
return fmt.Errorf("build branch graph: %w", err)
}

subsumed := subsumedTips(graph, status.Tips)
if len(subsumed) == 0 {
log.Info("No subsumed tips to prune.")
return nil
}

for _, s := range subsumed {
if err := handler.RemoveTip(ctx, s.tip); err != nil {
return fmt.Errorf("remove tip %q: %w", s.tip, err)
}
log.Infof("Removed %q (subsumed by %q).", s.tip, s.subsumer)
}
log.Infof("Pruned %d tip(s).", len(subsumed))
return nil
}

// subsumption records that tip is fully contained in the upstack of
// subsumer, both of which are configured integration tips.
type subsumption struct {
tip string
subsumer string
}

// subsumedTips returns the set of tips that should be pruned because
// they are upstack-reachable from another configured tip.
//
// A tip T is subsumed when at least one branch above T (in the
// inclusive upstack of T, excluding T itself) is also a configured
// tip. The subsumer reported is the first such tip found while
// walking T's upstack; with cleanly-stacked branches this is stable.
//
// Tips whose branches are missing from the graph are ignored.
func subsumedTips(
graph *spice.BranchGraph,
tips []integration.TipStatus,
) []subsumption {
tipSet := make(map[string]struct{}, len(tips))
for _, t := range tips {
if t.Missing {
continue
}
tipSet[t.Name] = struct{}{}
}

var out []subsumption
for _, t := range tips {
if t.Missing {
continue
}
for above := range graph.Upstack(t.Name) {
if above == t.Name {
continue
}
if _, ok := tipSet[above]; ok {
out = append(out, subsumption{tip: t.Name, subsumer: above})
break
}
}
}
return out
}
3 changes: 3 additions & 0 deletions testdata/help/gs.txt
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ Integration
Remove a branch from the integration tip list
integration (int) tip list (l,ls)
List the configured integration tips
integration (int) tip clean (prune)
Remove tips whose upstack already contains
another tip

Rebase
rebase (rb) continue (c) Continue an interrupted operation
Expand Down
18 changes: 18 additions & 0 deletions testdata/help/integration_tip_clean.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Usage: gs integration (int) tip clean (prune)

Remove tips whose upstack already contains another tip

Removes tips whose upstack chain already contains another configured tip. The
higher tip's merge into the integration branch captures the lower tip's content,
so keeping both costs an extra merge without changing the result.

For each subsumed tip, the message reports which higher tip subsumes it.
A second run is a no-op once nothing remains to prune. Existing tips with no
upstack-tip relationship are left alone.

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
68 changes: 68 additions & 0 deletions testdata/script/integration_tip_clean.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Verifies 'gs integration tip clean' removes tips whose upstack
# contains another configured tip.
#
# A tip whose upstack already contains a configured tip is fully
# subsumed: merging the higher tip into the integration already
# captures the lower tip's content. Keeping both inflates the
# rebuild for no benefit.

as 'Test <test@example.com>'
at '2025-06-01T00:00:00Z'

cd repo
git init
git commit --allow-empty -m 'Initial commit'
gs repo init

# Linear stack: main -> low -> middle -> high
git add low.txt
gs branch create low -m 'Add low'
git add middle.txt
gs branch create middle -m 'Add middle'
git add high.txt
gs branch create high -m 'Add high'

# Independent stack: main -> side
gs trunk
git add side.txt
gs branch create side -m 'Add side'

# Configure every branch in the linear stack plus the independent
# 'side' branch as tips. low and middle are subsumed by high; side
# is independent and stays.
gs trunk
gs integration create preview --tip low --tip middle --tip high --tip side

# Clean prunes the subsumed tips. The reported subsumer is the
# nearest configured tip up the stack, not the topmost: 'low' is
# subsumed by 'middle' (which is itself subsumed by 'high').
gs integration tip clean
stderr 'Removed "low" \(subsumed by "middle"\)'
stderr 'Removed "middle" \(subsumed by "high"\)'
stderr 'Pruned 2 tip\(s\)'

# Only the non-subsumed tips remain.
gs integration tip list
cmp stdout $WORK/golden/tips.txt

# A second run is a no-op.
gs integration tip clean
stderr 'No subsumed tips to prune'

# 'tip prune' is an alias.
gs integration tip add low
gs integration tip prune
stderr 'Removed "low" \(subsumed by "high"\)'
# Only 'high' remains in low's upstack chain, so it is the subsumer.

-- repo/low.txt --
low
-- repo/middle.txt --
middle
-- repo/high.txt --
high
-- repo/side.txt --
side
-- golden/tips.txt --
high
side
Loading