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/Changed-20260601-125125.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Changed
body: '''stack submit'': From the integration branch, submits the full stack of every configured tip (upstack + downstack) and then pushes the integration branch.'
time: 2026-06-01T12:51:25.554189-04:00
43 changes: 28 additions & 15 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,13 @@ Submit a stack
Change Requests are created or updated
for all branches in the current stack.

When invoked from the configured integration branch, the
"current stack" is the union of each configured tip's
downstack (the tip and the branches below it), and the
integration branch itself is pushed afterward. Branches
above a tip are deliberately left alone: they are work in
progress that has not been promoted to a tip yet.

Use --dry-run to print what would be submitted without submitting it.

For new Change Requests, a prompt will allow filling metadata.
Expand Down Expand Up @@ -310,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 @@ -321,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 @@ -624,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 @@ -642,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 @@ -658,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 @@ -1133,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
67 changes: 67 additions & 0 deletions stack_submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package main

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

"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/integration"
"go.abhg.dev/gs/internal/handler/submit"
"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"
Expand All @@ -21,15 +24,24 @@ func (*stackSubmitCmd) Help() string {
return text.Dedent(`
Change Requests are created or updated
for all branches in the current stack.

When invoked from the configured integration branch, the
"current stack" is the union of each configured tip's
downstack (the tip and the branches below it), and the
integration branch itself is pushed afterward. Branches
above a tip are deliberately left alone: they are work in
progress that has not been promoted to a tip yet.
`) + "\n" + _submitHelp
}

func (cmd *stackSubmitCmd) Run(
ctx context.Context,
log *silog.Logger,
wt *git.Worktree,
store *state.Store,
svc *spice.Service,
submitHandler SubmitHandler,
integrationHandler IntegrationHandler,
) error {
currentBranch, err := wt.CurrentBranch(ctx)
if err != nil {
Expand All @@ -41,6 +53,17 @@ func (cmd *stackSubmitCmd) Run(
return fmt.Errorf("build branch graph: %w", err)
}

// From the integration branch, "the current stack" is the union of
// every configured tip's full stack. Dispatch into the integration
// flow so all the tip stacks get submitted and the integration
// branch itself is pushed in one shot.
switch info, infoErr := store.Integration(ctx); {
case infoErr == nil && info.Name == currentBranch:
return cmd.runFromIntegration(ctx, log, graph, submitHandler, integrationHandler)
case infoErr != nil && !errors.Is(infoErr, state.ErrNotExist):
log.Warn("Could not load integration branch configuration", "error", infoErr)
}

stack := slices.Collect(graph.Stack(currentBranch))
if len(stack) == 0 {
stack = []string{currentBranch}
Expand All @@ -62,3 +85,47 @@ func (cmd *stackSubmitCmd) Run(
BranchGraph: graph,
})
}

// runFromIntegration submits the union of every configured tip's
// downstack (the tip and the branches below it), then pushes the
// integration branch itself. Branches above a tip are deliberately
// omitted: an upstack branch that has not been promoted to a tip is
// in-progress and not part of the integration.
func (cmd *stackSubmitCmd) runFromIntegration(
ctx context.Context,
log *silog.Logger,
graph *spice.BranchGraph,
submitHandler SubmitHandler,
integrationHandler IntegrationHandler,
) error {
status, err := integrationHandler.Show(ctx)
if err != nil {
return err
}

branches := tipDownstackOrder(graph, status.Tips)
if len(branches) > 0 {
log.Infof("Submitting %d branch(es) from integration tip stacks", len(branches))
if err := submitHandler.SubmitBatch(ctx, &submit.BatchRequest{
Branches: branches,
Options: &cmd.Options,
BatchOptions: &cmd.BatchOptions,
BranchGraph: graph,
}); err != nil {
return err
}
}

err = integrationHandler.Submit(ctx)
if err == nil {
log.Info("Integration branch pushed.")
return nil
}

var rejected *integration.PushRejectedError
if errors.As(err, &rejected) {
log.Error(formatPushRejected(rejected))
}
return err
}

6 changes: 6 additions & 0 deletions testdata/help/stack_submit.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Submit a stack

Change Requests are created or updated for all branches in the current stack.

When invoked from the configured integration branch, the "current stack" is the
union of each configured tip's downstack (the tip and the branches below it),
and the integration branch itself is pushed afterward. Branches above a tip are
deliberately left alone: they are work in progress that has not been promoted to
a tip yet.

Use --dry-run to print what would be submitted without submitting it.

For new Change Requests, a prompt will allow filling metadata. Use --fill to
Expand Down
165 changes: 165 additions & 0 deletions testdata/script/stack_submit_from_integration.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Verifies 'gs stack submit' from the integration branch submits each
# configured tip's downstack and pushes the integration branch.
#
# Branches that sit above a tip but are not themselves tips are
# deliberately left alone: they are work-in-progress that has not
# been promoted to a tip yet.

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

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

shamhub init
shamhub new origin alice/example.git
shamhub register alice
git push origin main

env SHAMHUB_USERNAME=alice
gs auth login
gs repo init

# Build two stacks rooted at trunk:
# main -> base-a -> tip-a -> above-a (above-a is not a tip)
# main -> base-b -> tip-b
git add base-a.txt
gs branch create base-a -m 'Add base-a'
git add tip-a.txt
gs branch create tip-a -m 'Add tip-a'
git add above-a.txt
gs branch create above-a -m 'Add above-a'

gs trunk
git add base-b.txt
gs branch create base-b -m 'Add base-b'
git add tip-b.txt
gs branch create tip-b -m 'Add tip-b'

gs trunk
gs integration create preview --tip tip-a --tip tip-b
gs integration rebuild
gs integration checkout

# Submit. Each tip's downstack (the tip + the branches below it) is
# submitted; above-a is NOT, because it has not been promoted to a
# tip. The integration branch is pushed without a CR.
gs ss --fill
stderr 'Created #1'
stderr 'Created #2'
stderr 'Created #3'
stderr 'Created #4'
stderr 'Integration branch pushed.'
! stderr 'Created #5'

# Exactly four CRs (one per branch in the two tip downstacks). No CR
# for above-a; no CR for the integration branch itself.
shamhub dump changes
cmpenvJSON stdout $WORK/golden/pulls.json

-- repo/base-a.txt --
base a
-- repo/tip-a.txt --
tip a
-- repo/above-a.txt --
above a
-- repo/base-b.txt --
base b
-- repo/tip-b.txt --
tip b
-- golden/pulls.json --
[
{
"number": 1,
"html_url": "$SHAMHUB_URL/alice/example/change/1",
"state": "open",
"title": "Add base-a",
"body": "",
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "main",
"sha": "20c47e7889ce2a42f0262a0a3e826fff131a23cf"
},
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "base-a",
"sha": "ae20834108c790c4fad17fa11718c2b20509c7c8"
}
},
{
"number": 2,
"html_url": "$SHAMHUB_URL/alice/example/change/2",
"state": "open",
"title": "Add tip-a",
"body": "",
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "base-a",
"sha": "ae20834108c790c4fad17fa11718c2b20509c7c8"
},
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "tip-a",
"sha": "e7e426d9c7ba9bb22462cf38908e0851b5096714"
}
},
{
"number": 3,
"html_url": "$SHAMHUB_URL/alice/example/change/3",
"state": "open",
"title": "Add base-b",
"body": "",
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "main",
"sha": "20c47e7889ce2a42f0262a0a3e826fff131a23cf"
},
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "base-b",
"sha": "1b30f4eedaa906d0ced733c2da76bb758f603283"
}
},
{
"number": 4,
"html_url": "$SHAMHUB_URL/alice/example/change/4",
"state": "open",
"title": "Add tip-b",
"body": "",
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "base-b",
"sha": "1b30f4eedaa906d0ced733c2da76bb758f603283"
},
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "tip-b",
"sha": "24e9aa620deb7d78eef540870752326140ea7c57"
}
}
]
Loading