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-125928.yaml
Original file line number Diff line number Diff line change
@@ -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
62 changes: 47 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 @@ -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 [<branches> ...]
```

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}
Expand Down
201 changes: 197 additions & 4 deletions integration_tip.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"strings"

"github.com/alecthomas/kong"
"go.abhg.dev/gs/internal/handler/integration"
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions testdata/help/gs.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions testdata/help/integration_tip_advance.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
Usage: gs integration (int) tip advance [<branches> ...]

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

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
Loading
Loading