diff --git a/.changes/unreleased/Changed-20260601-125125.yaml b/.changes/unreleased/Changed-20260601-125125.yaml new file mode 100644 index 000000000..92cab233f --- /dev/null +++ b/.changes/unreleased/Changed-20260601-125125.yaml @@ -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 diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index ecad060ec..3703bf208 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -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. @@ -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. @@ -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} @@ -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: @@ -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 @@ -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} @@ -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} diff --git a/stack_submit.go b/stack_submit.go index ad2c2524a..52d9e827b 100644 --- a/stack_submit.go +++ b/stack_submit.go @@ -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" @@ -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 { @@ -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} @@ -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 +} + diff --git a/testdata/help/stack_submit.txt b/testdata/help/stack_submit.txt index f93bff684..a5056b100 100644 --- a/testdata/help/stack_submit.txt +++ b/testdata/help/stack_submit.txt @@ -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 diff --git a/testdata/script/stack_submit_from_integration.txt b/testdata/script/stack_submit_from_integration.txt new file mode 100644 index 000000000..4cb0a8f97 --- /dev/null +++ b/testdata/script/stack_submit_from_integration.txt @@ -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 ' +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" + } + } +]