diff --git a/.changes/unreleased/Added-20260601-125454.yaml b/.changes/unreleased/Added-20260601-125454.yaml new file mode 100644 index 000000000..af3be418c --- /dev/null +++ b/.changes/unreleased/Added-20260601-125454.yaml @@ -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 diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index b88f330a7..32de51634 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -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. @@ -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} @@ -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: @@ -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 @@ -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} @@ -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} @@ -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} diff --git a/integration_tip.go b/integration_tip.go index d34bf0bf2..25fb044ec 100644 --- a/integration_tip.go +++ b/integration_tip.go @@ -5,7 +5,9 @@ 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" ) @@ -13,6 +15,7 @@ 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 { @@ -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 +} diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 9efb9c81a..062531cf9 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -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 diff --git a/testdata/help/integration_tip_clean.txt b/testdata/help/integration_tip_clean.txt new file mode 100644 index 000000000..4c0aa180d --- /dev/null +++ b/testdata/help/integration_tip_clean.txt @@ -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 diff --git a/testdata/script/integration_tip_clean.txt b/testdata/script/integration_tip_clean.txt new file mode 100644 index 000000000..61d6c88b5 --- /dev/null +++ b/testdata/script/integration_tip_clean.txt @@ -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 ' +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