diff --git a/.changes/unreleased/Added-20260601-125928.yaml b/.changes/unreleased/Added-20260601-125928.yaml new file mode 100644 index 00000000..ce053f3a --- /dev/null +++ b/.changes/unreleased/Added-20260601-125928.yaml @@ -0,0 +1,3 @@ +kind: Added +body: '''integration tip advance'': Move each configured tip to the leaves of its upstack, optionally scoped to specific tips.' +time: 2026-06-01T12:59:28.111325-04:00 diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index d5dd4fcd..89bbde47 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} @@ -1619,6 +1625,32 @@ subsumes it. A second run is a no-op once nothing remains to prune. Existing tips with no upstack-tip relationship are left alone. +### git-spice integration tip advance {#gs-integration-tip-advance} + +``` +gs integration (int) tip advance [ ...] +``` + +Move tips to the topmost branches of their upstacks + +For each configured tip (or only the named tips, if any are +given as arguments), replaces the tip with the topmost +branch(es) in its upstack — the leaves of the tree above it. + +If a tip already has no branches above it, it is left alone. +If a tip's upstack forks, the tip expands to every leaf of +the fork. Branches that would become duplicates of other +configured tips are deduplicated. + +Useful as a one-shot maintenance step after extending a stack +above a configured tip: instead of 'tip remove old' + 'tip +add new', a single 'tip advance' walks each tip to its +current top. + +**Arguments** + +* `branches`: Tips to advance; defaults to all configured tips + ## Rebase ### git-spice rebase continue {#gs-rebase-continue} diff --git a/integration_tip.go b/integration_tip.go index 25fb044e..7a2d3f7e 100644 --- a/integration_tip.go +++ b/integration_tip.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "strings" "github.com/alecthomas/kong" "go.abhg.dev/gs/internal/handler/integration" @@ -12,10 +13,11 @@ import ( ) 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"` + 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"` + Advance integrationTipAdvanceCmd `cmd:"" help:"Move tips to the topmost branches of their upstacks"` } type integrationTipAddCmd struct { @@ -130,6 +132,197 @@ func (cmd *integrationTipCleanCmd) Run( return nil } +type integrationTipAdvanceCmd struct { + Branches []string `arg:"" optional:"" predictor:"integrationTips" help:"Tips to advance; defaults to all configured tips"` +} + +func (*integrationTipAdvanceCmd) Help() string { + return text.Dedent(` + For each configured tip (or only the named tips, if any are + given as arguments), replaces the tip with the topmost + branch(es) in its upstack — the leaves of the tree above it. + + If a tip already has no branches above it, it is left alone. + If a tip's upstack forks, the tip expands to every leaf of + the fork. Branches that would become duplicates of other + configured tips are deduplicated. + + Useful as a one-shot maintenance step after extending a stack + above a configured tip: instead of 'tip remove old' + 'tip + add new', a single 'tip advance' walks each tip to its + current top. + `) +} + +func (cmd *integrationTipAdvanceCmd) 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) + } + + scope := scopeOfTips(status.Tips, cmd.Branches) + if err := validateAdvanceScope(scope, cmd.Branches); err != nil { + return err + } + + plan := advanceTips(graph, status.Tips, scope) + if len(plan) == 0 { + log.Info("No tips to advance.") + return nil + } + + for _, step := range plan { + if err := handler.RemoveTip(ctx, step.from); err != nil { + return fmt.Errorf("remove tip %q: %w", step.from, err) + } + for _, leaf := range step.to { + if err := handler.AddTip(ctx, leaf); err != nil { + return fmt.Errorf("add tip %q: %w", leaf, err) + } + } + log.Infof("Advanced %q to %s.", step.from, formatLeaves(step.to)) + } + return nil +} + +// scopeOfTips returns the set of tip names to advance. An empty +// requested slice selects all currently-configured tips. +func scopeOfTips(tips []integration.TipStatus, requested []string) map[string]struct{} { + scope := make(map[string]struct{}) + if len(requested) == 0 { + for _, t := range tips { + scope[t.Name] = struct{}{} + } + return scope + } + for _, r := range requested { + scope[r] = struct{}{} + } + return scope +} + +// validateAdvanceScope ensures every name in requested is actually a +// configured tip; an unrecognized name aborts before any mutation. +func validateAdvanceScope(scope map[string]struct{}, requested []string) error { + if len(requested) == 0 { + return nil + } + configured := make(map[string]struct{}, len(scope)) + for k := range scope { + configured[k] = struct{}{} + } + for _, r := range requested { + if _, ok := configured[r]; !ok { + return fmt.Errorf("tip %q is not configured", r) + } + } + return nil +} + +// advanceStep records that a configured tip should be replaced with +// the listed leaves. +type advanceStep struct { + from string + to []string +} + +// advanceTips computes the replacement plan. For each tip in scope: +// - If the tip is missing from the graph, skip it. +// - Collect the leaves of its upstack via [BranchGraph.Tops]. +// - If the only leaf is the tip itself, the tip is already at +// the top and is left alone. +// - Otherwise replace it with all leaves, dropping leaves that +// are already configured as other tips (or would duplicate +// leaves already emitted by an earlier step in the plan). +// +// The plan preserves the configured tip order so output is stable. +func advanceTips( + graph *spice.BranchGraph, + tips []integration.TipStatus, + scope map[string]struct{}, +) []advanceStep { + existing := make(map[string]struct{}, len(tips)) + for _, t := range tips { + existing[t.Name] = struct{}{} + } + + var plan []advanceStep + for _, t := range tips { + if _, ok := scope[t.Name]; !ok { + continue + } + if t.Missing { + continue + } + var leaves []string + seen := map[string]struct{}{} + for leaf := range graph.Tops(t.Name) { + if _, dup := seen[leaf]; dup { + continue + } + seen[leaf] = struct{}{} + leaves = append(leaves, leaf) + } + if len(leaves) == 0 { + continue + } + if len(leaves) == 1 && leaves[0] == t.Name { + continue // already at the top + } + // Filter out leaves that would duplicate other configured + // tips. Removing t and adding such a leaf would just collide + // with an existing entry. + filtered := leaves[:0] + for _, leaf := range leaves { + if leaf == t.Name { + continue + } + if _, ok := existing[leaf]; ok { + continue + } + filtered = append(filtered, leaf) + } + if len(filtered) == 0 { + continue + } + // Update existing so subsequent steps don't emit duplicates. + delete(existing, t.Name) + for _, leaf := range filtered { + existing[leaf] = struct{}{} + } + plan = append(plan, advanceStep{from: t.Name, to: filtered}) + } + return plan +} + +// formatLeaves renders a leaf list for the log line. +func formatLeaves(leaves []string) string { + switch len(leaves) { + case 0: + return "" + case 1: + return fmt.Sprintf("%q", leaves[0]) + } + var sb strings.Builder + for i, leaf := range leaves { + if i > 0 { + sb.WriteString(", ") + } + fmt.Fprintf(&sb, "%q", leaf) + } + return sb.String() +} + // subsumption records that tip is fully contained in the upstack of // subsumer, both of which are configured integration tips. type subsumption struct { diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 062531cf..82897235 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -89,6 +89,8 @@ Integration integration (int) tip clean (prune) Remove tips whose upstack already contains another tip + integration (int) tip advance Move tips to the topmost branches of their + upstacks Rebase rebase (rb) continue (c) Continue an interrupted operation diff --git a/testdata/help/integration_tip_advance.txt b/testdata/help/integration_tip_advance.txt new file mode 100644 index 00000000..ba6e52f8 --- /dev/null +++ b/testdata/help/integration_tip_advance.txt @@ -0,0 +1,25 @@ +Usage: gs integration (int) tip advance [ ...] + +Move tips to the topmost branches of their upstacks + +For each configured tip (or only the named tips, if any are given as arguments), +replaces the tip with the topmost branch(es) in its upstack — the leaves of the +tree above it. + +If a tip already has no branches above it, it is left alone. If a tip's upstack +forks, the tip expands to every leaf of the fork. Branches that would become +duplicates of other configured tips are deduplicated. + +Useful as a one-shot maintenance step after extending a stack above a configured +tip: instead of 'tip remove old' + 'tip add new', a single 'tip advance' walks +each tip to its current top. + +Arguments: + [ ...] Tips to advance; defaults to all configured tips + +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_advance.txt b/testdata/script/integration_tip_advance.txt new file mode 100644 index 00000000..d5d1ac18 --- /dev/null +++ b/testdata/script/integration_tip_advance.txt @@ -0,0 +1,90 @@ +# Verifies 'gs integration tip advance' moves each configured tip +# to the topmost branch(es) in its upstack — i.e., the leaf branches +# that have no branches above them. This is the natural maintenance +# operation after adding new work above a tip: instead of asking the +# user to manually 'tip remove' + 'tip add', the same effect is +# achieved in one step. + +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' + +# Forked stack: main -> base -> fork-a +# -> fork-b +gs trunk +git add base.txt +gs branch create base -m 'Add base' +git add fork-a.txt +gs branch create fork-a -m 'Add fork-a' + +gs branch checkout base +git add fork-b.txt +gs branch create fork-b -m 'Add fork-b' + +# Configure only the lower branches as tips: 'low' (linear) and +# 'base' (forked). After advance: +# - 'low' moves up to 'high' (single leaf) +# - 'base' expands to 'fork-a' and 'fork-b' (two leaves) +gs trunk +gs integration create preview --tip low --tip base + +gs integration tip advance +stderr 'Advanced "low" to "high"' +stderr 'Advanced "base" to "fork-a", "fork-b"' + +# The new tip list is the union of all leaves, in encounter order +# (the configured order of source tips, then per-tip leaves). +gs integration tip list +cmp stdout $WORK/golden/tips-after.txt + +# A second run is a no-op: each tip is already a leaf. +gs integration tip advance +stderr 'No tips to advance' + +# Advance can be scoped to a single tip with the optional argument. +# Add a new branch above fork-a, then advance only fork-a. +gs branch checkout fork-a +git add above-a.txt +gs branch create above-a -m 'Add above-a' + +gs integration tip advance fork-a +stderr 'Advanced "fork-a" to "above-a"' +! stderr 'Advanced "fork-b"' + +gs integration tip list +cmp stdout $WORK/golden/tips-after-scoped.txt + +-- repo/low.txt -- +low +-- repo/middle.txt -- +middle +-- repo/high.txt -- +high +-- repo/base.txt -- +base +-- repo/fork-a.txt -- +fork a +-- repo/fork-b.txt -- +fork b +-- repo/above-a.txt -- +above a +-- golden/tips-after.txt -- +high +fork-a +fork-b +-- golden/tips-after-scoped.txt -- +high +fork-b +above-a