From 3e3cfcab6197872e660db0cf39022eccc34aa443 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Mon, 1 Jun 2026 11:18:54 -0400 Subject: [PATCH 1/3] integration: submit tip downstacks alongside the integration branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'gs integration submit' previously only force-pushed the integration branch. Since the integration branch is the union of its configured tips, publishing it implies publishing those tips. Otherwise users had to remember to separately submit each tip stack — easy to forget when gs ints is meant to be the one-shot "publish the integration" command. For each configured tip, walk its downstack (trunk-exclusive, dedup'd), then run SubmitBatch in trunk-first topological order so bases are pushed before the branches that depend on them. After the tip-stack batch completes, push the integration branch itself with its existing force-with-lease semantics. All the standard submit flags now apply to gs ints (--fill, --no-publish, --update-only, etc.), so users can keep the previous "push the branch only" behavior with --no-publish if desired. --- .../unreleased/Changed-20260601-111817.yaml | 3 + doc/includes/cli-reference.md | 132 ++++++------- integration_submit.go | 102 +++++++++- testdata/help/integration_submit.txt | 85 ++++++++- testdata/script/integration_submit.txt | 44 ++++- .../script/integration_submit_tip_stacks.txt | 174 ++++++++++++++++++ ...egration_submit_tip_stacks_shared_base.txt | 131 +++++++++++++ 7 files changed, 576 insertions(+), 95 deletions(-) create mode 100644 .changes/unreleased/Changed-20260601-111817.yaml create mode 100644 testdata/script/integration_submit_tip_stacks.txt create mode 100644 testdata/script/integration_submit_tip_stacks_shared_base.txt diff --git a/.changes/unreleased/Changed-20260601-111817.yaml b/.changes/unreleased/Changed-20260601-111817.yaml new file mode 100644 index 000000000..d1f4e78f1 --- /dev/null +++ b/.changes/unreleased/Changed-20260601-111817.yaml @@ -0,0 +1,3 @@ +kind: Changed +body: '''integration submit'': Also submits the downstack branches of each configured tip, so a single ''gs ints'' publishes the full integration.' +time: 2026-06-01T11:18:17.074573-04:00 diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index f4ce0eb2d..6bf2887d1 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -286,48 +286,6 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) -### git-spice stack merge {#gs-stack-merge} - -``` -gs stack (s) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a stack - -Merges the CRs for the current branch's stack into trunk. -Use --branch to merge a different branch's stack. - -The stack includes the selected branch, -its downstack branches down to trunk, -and every upstack branch. - -Already-merged branches are skipped automatically. -Branches must have an open Change Request to be merged. - -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. - -By default, a branch failure skips that branch's upstack descendants, -but independent sibling branches continue. -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. -* `--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) - ### git-spice stack restack {#gs-stack-restack} ``` @@ -657,11 +615,11 @@ 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. +* `--branch=NAME`: Branch to start merging from * `--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 +* `--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. **Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) @@ -1117,33 +1075,6 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff -### git-spice branch merge {#gs-branch-merge} - -``` -gs branch (b) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a branch into trunk - -Merges the CR for the current branch into trunk. -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. - -**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. -* `--branch=NAME`: Branch to merge - -**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) - ### git-spice branch submit {#gs-branch-submit} ``` @@ -1464,7 +1395,7 @@ for manual resolution instead. ### git-spice integration submit {#gs-integration-submit} ``` -gs integration (int) submit (s) +gs integration (int) submit (s) [flags] ``` Push the integration branch to the remote @@ -1473,10 +1404,57 @@ Pushes the integration branch to the configured remote with --force-with-lease against the hash recorded at the previous successful push. -No change request (PR) is opened: this command only pushes the -branch. Once a manual submit succeeds, 'gs stack submit' and -'gs upstack submit' will keep the published branch in sync with -local rebuilds. +No change request (PR) is opened for the integration branch +itself: it is a throwaway artifact. However, the downstack +branches of each configured tip are also submitted, so any +branches that need to be pushed or have their CRs created or +updated are handled in one shot. + +Once a manual submit succeeds, 'gs stack submit' and +'gs upstack submit' will keep the integration branch in sync +with local rebuilds. + +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 populate title and body from the commit messages. +The --[no-]draft flag marks the CR as draft or not. +Use the 'spice.submit.draft' configuration option +to mark new CRs as drafts (or not) by default, +skipping the prompt. + +For updating Change Requests, +use --[no-]draft to change its draft status. +Without the flag, the draft status is not changed. + +Use --no-publish to push branches without creating CRs. +This has no effect if a branch already has an open CR. + +Use --update-only to only update branches with existing CRs, +and skip those that would create new CRs. + +Use --nav-comment=false to disable navigation comments in CRs, +or --nav-comment=multiple to post those comments +only if there are multiple CRs in the stack. + + +**Flags** + +* `-n`, `--dry-run`: Don't actually submit the stack +* `-c`, `--fill`: Fill in the change title and body from the commit messages +* `--[no-]draft`: Whether to mark change requests as drafts +* `--[no-]publish` ([:material-wrench:{ .middle title="spice.submit.publish" }](/cli/config.md#spicesubmitpublish)): Whether to create CRs for pushed branches. Defaults to true. +* `-w`, `--web` ([:material-wrench:{ .middle title="spice.submit.web" }](/cli/config.md#spicesubmitweb)): Open submitted changes in a web browser. Accepts an optional argument: 'true', 'false', 'created'. +* `--nav-comment=true` ([:material-wrench:{ .middle title="spice.submit.navigationComment" }](/cli/config.md#spicesubmitnavigationcomment)): Whether to add a navigation comment to the change request. Must be one of: true, false, multiple. +* `--force`: Force push, bypassing safety checks +* `--no-verify`: Bypass pre-push hooks when pushing to the remote. :material-tag:{ title="Released in version" }[v0.15.0](/changelog.md#v0.15.0) +* `-u`, `--[no-]update-only`: Only update existing change requests, do not create new ones +* `-l`, `--label=LABEL,...`: Add labels to the change request. Pass multiple times or separate with commas. +* `-r`, `--reviewer=REVIEWER,...`: Add reviewers to the change request. Pass multiple times or separate with commas. :material-tag:{ title="Released in version" }[v0.21.0](/changelog.md#v0.21.0) +* `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. :material-tag:{ title="Released in version" }[v0.21.0](/changelog.md#v0.21.0) +* `--no-web`: Alias for --web=false. + +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice integration mark-pushed {#gs-integration-mark-pushed} diff --git a/integration_submit.go b/integration_submit.go index db97b8a54..ccb381f5d 100644 --- a/integration_submit.go +++ b/integration_submit.go @@ -3,15 +3,22 @@ package main import ( "context" "errors" + "fmt" + "slices" "strings" "go.abhg.dev/gs/internal/cli" "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/text" ) -type integrationSubmitCmd struct{} +type integrationSubmitCmd struct { + submitOptions + submit.BatchOptions +} func (*integrationSubmitCmd) Help() string { return text.Dedent(` @@ -19,18 +26,32 @@ func (*integrationSubmitCmd) Help() string { --force-with-lease against the hash recorded at the previous successful push. - No change request (PR) is opened: this command only pushes the - branch. Once a manual submit succeeds, 'gs stack submit' and - 'gs upstack submit' will keep the published branch in sync with - local rebuilds. - `) + No change request (PR) is opened for the integration branch + itself: it is a throwaway artifact. However, the downstack + branches of each configured tip are also submitted, so any + branches that need to be pushed or have their CRs created or + updated are handled in one shot. + + Once a manual submit succeeds, 'gs stack submit' and + 'gs upstack submit' will keep the integration branch in sync + with local rebuilds. + `) + "\n" + _submitHelp } func (cmd *integrationSubmitCmd) Run( ctx context.Context, log *silog.Logger, + svc *spice.Service, handler IntegrationHandler, + submitHandler SubmitHandler, ) error { + // Submit the downstack branches of each tip before pushing the + // integration branch. The integration branch is logically the + // union of these tips, so publishing it implies publishing them. + if err := cmd.submitTipStacks(ctx, log, svc, handler, submitHandler); err != nil { + return err + } + err := handler.Submit(ctx) if err == nil { log.Info("Integration branch pushed.") @@ -45,6 +66,75 @@ func (cmd *integrationSubmitCmd) Run( return err } +// submitTipStacks submits every branch in the downstack of any +// configured tip, in trunk-to-tip topological order, deduplicating +// branches shared between tips. +// +// Missing tips (branches that no longer exist) are silently skipped: +// the integration branch's own push will fail loudly enough if the +// configuration is broken. +func (cmd *integrationSubmitCmd) submitTipStacks( + ctx context.Context, + log *silog.Logger, + svc *spice.Service, + handler IntegrationHandler, + submitHandler SubmitHandler, +) error { + status, err := handler.Show(ctx) + if err != nil { + return err + } + if len(status.Tips) == 0 { + return nil + } + + graph, err := svc.BranchGraph(ctx, nil) + if err != nil { + return fmt.Errorf("build branch graph: %w", err) + } + + branches := tipDownstackOrder(graph, status.Tips) + if len(branches) == 0 { + return nil + } + + log.Infof("Submitting %d branch(es) from integration tip stacks", len(branches)) + return submitHandler.SubmitBatch(ctx, &submit.BatchRequest{ + Branches: branches, + Options: &cmd.Options, + BatchOptions: &cmd.BatchOptions, + BranchGraph: graph, + }) +} + +// tipDownstackOrder returns the union of all tip downstacks in +// trunk-first topological order. A branch shared across multiple +// tips' downstacks appears exactly once, at the position it was +// first encountered while walking the first tip that reaches it. +func tipDownstackOrder( + graph *spice.BranchGraph, tips []integration.TipStatus, +) []string { + var branches []string + seen := make(map[string]struct{}) + for _, tip := range tips { + if tip.Missing { + continue + } + downstack := slices.Collect(graph.Downstack(tip.Name)) + // Downstack is tip-first; reverse so bases come before + // the branches that depend on them. + slices.Reverse(downstack) + for _, b := range downstack { + if _, ok := seen[b]; ok { + continue + } + seen[b] = struct{}{} + branches = append(branches, b) + } + } + return branches +} + // formatPushRejected renders a multi-line explanation of a // [*integration.PushRejectedError] tailored for the user. func formatPushRejected(e *integration.PushRejectedError) string { diff --git a/testdata/help/integration_submit.txt b/testdata/help/integration_submit.txt index 0c7beea66..b6be40aca 100644 --- a/testdata/help/integration_submit.txt +++ b/testdata/help/integration_submit.txt @@ -1,13 +1,63 @@ -Usage: gs integration (int) submit (s) +Usage: gs integration (int) submit (s) [flags] Push the integration branch to the remote Pushes the integration branch to the configured remote with --force-with-lease against the hash recorded at the previous successful push. -No change request (PR) is opened: this command only pushes the branch. Once a -manual submit succeeds, 'gs stack submit' and 'gs upstack submit' will keep the -published branch in sync with local rebuilds. +No change request (PR) is opened for the integration branch itself: it is a +throwaway artifact. However, the downstack branches of each configured tip are +also submitted, so any branches that need to be pushed or have their CRs created +or updated are handled in one shot. + +Once a manual submit succeeds, 'gs stack submit' and 'gs upstack submit' will +keep the integration branch in sync with local rebuilds. + +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 +populate title and body from the commit messages. The --[no-]draft flag marks +the CR as draft or not. Use the 'spice.submit.draft' configuration option to +mark new CRs as drafts (or not) by default, skipping the prompt. + +For updating Change Requests, use --[no-]draft to change its draft status. +Without the flag, the draft status is not changed. + +Use --no-publish to push branches without creating CRs. This has no effect if a +branch already has an open CR. + +Use --update-only to only update branches with existing CRs, and skip those that +would create new CRs. + +Use --nav-comment=false to disable navigation comments in CRs, or +--nav-comment=multiple to post those comments only if there are multiple CRs in +the stack. + +Flags: + -n, --dry-run Don't actually submit the stack + -c, --fill Fill in the change title and body from the + commit messages + --[no-]draft Whether to mark change requests as drafts + --[no-]publish Whether to create CRs for pushed branches. + Defaults to true. (🔧 spice.submit.publish) + -w, --web Open submitted changes in a web browser. + Accepts an optional argument: 'true', 'false', + 'created'. (🔧 spice.submit.web) + --nav-comment=true Whether to add a navigation comment to the + change request. Must be one of: true, false, + multiple. (🔧 spice.submit.navigationComment) + --force Force push, bypassing safety checks + --no-verify Bypass pre-push hooks when pushing to the + remote. + -u, --[no-]update-only Only update existing change requests, do not + create new ones + -l, --label=LABEL,... Add labels to the change request. Pass multiple + times or separate with commas. + -r, --reviewer=REVIEWER,... Add reviewers to the change request. Pass + multiple times or separate with commas. + -a, --assign=ASSIGNEE,... Assign the change request to these users. + Pass multiple times or separate with commas. + --no-web Alias for --web=false. Global Flags: -h, --help Show help for the command @@ -15,3 +65,30 @@ Global Flags: -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 + +Configuration (🔧): + spice.submit.assignees Default assignees to add to change requests. + spice.submit.draft Default value for --draft when creating + change requests. + spice.submit.labels Default labels to add to change requests. + spice.submit.labels.addWhen When to add configured labels. + spice.submit.listTemplatesTimeout + Timeout for listing CR templates + spice.submit.navigationComment.downstack + Which downstack CRs to include in navigation + comments. Must be one of: all, open. + spice.submit.navigationCommentStyle.marker + Marker to use for the current change in + navigation comments. Defaults to '◀'. + spice.submit.navigationCommentSync + Which navigation comment to sync. Must be one + of: branch, downstack. + spice.submit.reviewers Default reviewers to add to change requests. + spice.submit.reviewers.addWhen + When to add configured reviewers. + spice.submit.skipRestackCheck When to skip the restack check. Must be one + of: never, trunk, always. + spice.submit.template Default template to use when multiple + templates are available + spice.submit.updateOnly Default value for --update-only in batch + submit operations. diff --git a/testdata/script/integration_submit.txt b/testdata/script/integration_submit.txt index 2c3dfd7de..bd459f03e 100644 --- a/testdata/script/integration_submit.txt +++ b/testdata/script/integration_submit.txt @@ -1,5 +1,6 @@ -# Verifies 'gs integration submit' pushes the branch without creating a -# change request (PR). +# Verifies 'gs integration submit' pushes the integration branch +# without creating a CR for it, and submits the downstack branches of +# each configured tip as CRs. as 'Test ' at '2025-01-01T00:00:00Z' @@ -24,14 +25,16 @@ git checkout main gs integration create preview --tip feat-a gs integration rebuild -gs integration submit +gs integration submit --fill +stderr 'Created #1' stderr 'Integration branch pushed.' -# No CR (PR) was created. +# Exactly one CR was created (for feat-a, the configured tip). +# The integration branch itself was pushed without a CR. shamhub dump changes -cmp stdout $WORK/golden/no-pulls.txt +cmpenvJSON stdout $WORK/golden/pulls.json -# Verify the branch appeared on the remote. +# Verify both branches appeared on the remote. cd ../ git clone $SHAMHUB_URL/alice/example.git clone cd clone @@ -40,9 +43,34 @@ cmp stdout $WORK/golden/remote-branches.txt -- repo/feat-a.txt -- feature a --- golden/no-pulls.txt -- -[] +-- golden/pulls.json -- +[ + { + "number": 1, + "html_url": "$SHAMHUB_URL/alice/example/change/1", + "state": "open", + "title": "Add feat-a", + "body": "", + "base": { + "repository": { + "owner": "alice", + "name": "example" + }, + "ref": "main", + "sha": "a044690fc011624960e3b0b64961ccc7eb93832e" + }, + "head": { + "repository": { + "owner": "alice", + "name": "example" + }, + "ref": "feat-a", + "sha": "b049cb76e0d5ca3f0b452fef57200741d2b7d1a7" + } + } +] -- golden/remote-branches.txt -- origin/HEAD -> origin/main + origin/feat-a origin/main origin/preview diff --git a/testdata/script/integration_submit_tip_stacks.txt b/testdata/script/integration_submit_tip_stacks.txt new file mode 100644 index 000000000..f5b8ba3e0 --- /dev/null +++ b/testdata/script/integration_submit_tip_stacks.txt @@ -0,0 +1,174 @@ +# Verifies 'gs integration submit' also submits the downstack branches +# of each configured tip, pushing them and creating CRs (PRs). +# +# The integration branch itself never gets a CR, but the tip branches +# and their dependencies do. + +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 +# 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' + +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' + +# Configure the integration branch over the two tips. +gs trunk +gs integration create preview --tip tip-a --tip tip-b +gs integration rebuild + +# Submit the integration branch. This should also submit the tip +# stacks (base-a, tip-a, base-b, tip-b), creating CRs for each. +gs integration submit --fill +stderr 'Created #1' +stderr 'Created #2' +stderr 'Created #3' +stderr 'Created #4' +stderr 'Integration branch pushed.' + +# Verify all four tip-stack branches got CRs, and the integration +# branch was pushed as a regular branch (no CR). +shamhub dump changes +cmpenvJSON stdout $WORK/golden/pulls.json + +# Verify the integration branch appeared on the remote, even though +# it has no CR. +cd ../ +git clone $SHAMHUB_URL/alice/example.git clone +cd clone +git branch -r +cmp stdout $WORK/golden/remote-branches.txt + +-- repo/base-a.txt -- +base a +-- repo/tip-a.txt -- +tip 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" + } + } +] +-- golden/remote-branches.txt -- + origin/HEAD -> origin/main + origin/base-a + origin/base-b + origin/main + origin/preview + origin/tip-a + origin/tip-b diff --git a/testdata/script/integration_submit_tip_stacks_shared_base.txt b/testdata/script/integration_submit_tip_stacks_shared_base.txt new file mode 100644 index 000000000..0eb1603d2 --- /dev/null +++ b/testdata/script/integration_submit_tip_stacks_shared_base.txt @@ -0,0 +1,131 @@ +# Verifies 'gs integration submit' deduplicates branches when multiple +# tips' downstacks share a common base. +# +# Stack: +# main -> shared-base -> tip-a +# main -> shared-base -> tip-b +# +# Configuring both tip-a and tip-b as tips should still only submit +# shared-base once, in trunk-first order so that tip-a/tip-b can be +# stacked on its already-pushed ref. + +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 the diverging stack: +# main -> shared-base -> tip-a +# -> tip-b +git add shared-base.txt +gs branch create shared-base -m 'Add shared-base' +git add tip-a.txt +gs branch create tip-a -m 'Add tip-a' + +gs branch checkout shared-base +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 submit --fill +stderr 'Created #1' +stderr 'Created #2' +stderr 'Created #3' +stderr 'Integration branch pushed.' + +# Three CRs (one for each unique branch), not four. +shamhub dump changes +cmpenvJSON stdout $WORK/golden/pulls.json + +-- repo/shared-base.txt -- +shared base +-- repo/tip-a.txt -- +tip a +-- repo/tip-b.txt -- +tip b +-- golden/pulls.json -- +[ + { + "number": 1, + "html_url": "$SHAMHUB_URL/alice/example/change/1", + "state": "open", + "title": "Add shared-base", + "body": "", + "base": { + "repository": { + "owner": "alice", + "name": "example" + }, + "ref": "main", + "sha": "20c47e7889ce2a42f0262a0a3e826fff131a23cf" + }, + "head": { + "repository": { + "owner": "alice", + "name": "example" + }, + "ref": "shared-base", + "sha": "908135a997fced8da6f40218fbc6d47849e793c9" + } + }, + { + "number": 2, + "html_url": "$SHAMHUB_URL/alice/example/change/2", + "state": "open", + "title": "Add tip-a", + "body": "", + "base": { + "repository": { + "owner": "alice", + "name": "example" + }, + "ref": "shared-base", + "sha": "908135a997fced8da6f40218fbc6d47849e793c9" + }, + "head": { + "repository": { + "owner": "alice", + "name": "example" + }, + "ref": "tip-a", + "sha": "845fd36c180e2948b871e6e45a278b65e4fdd08b" + } + }, + { + "number": 3, + "html_url": "$SHAMHUB_URL/alice/example/change/3", + "state": "open", + "title": "Add tip-b", + "body": "", + "base": { + "repository": { + "owner": "alice", + "name": "example" + }, + "ref": "shared-base", + "sha": "908135a997fced8da6f40218fbc6d47849e793c9" + }, + "head": { + "repository": { + "owner": "alice", + "name": "example" + }, + "ref": "tip-b", + "sha": "ddb28a31cc68562ca734698d169351321167df6a" + } + } +] From 1df251c5955c25ec2dc30b6586312c5977e44fce Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:00:50 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 75 +++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 6bf2887d1..ecad060ec 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -286,6 +286,48 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +### git-spice stack merge {#gs-stack-merge} + +``` +gs stack (s) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a stack + +Merges the CRs for the current branch's stack into trunk. +Use --branch to merge a different branch's stack. + +The stack includes the selected branch, +its downstack branches down to trunk, +and every upstack branch. + +Already-merged branches are skipped automatically. +Branches must have an open Change Request to be merged. + +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. + +By default, a branch failure skips that branch's upstack descendants, +but independent sibling branches continue. +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. +* `--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) + ### git-spice stack restack {#gs-stack-restack} ``` @@ -615,11 +657,11 @@ when you don't want to wait for the merge to propagate. **Flags** -* `--branch=NAME`: Branch to start merging from -* `--no-wait`: Skip polling for a single branch merge to propagate. -* `--no-branch-check`: Skip stale base validation before merging. * `--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. +* `--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) @@ -1075,6 +1117,33 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff +### git-spice branch merge {#gs-branch-merge} + +``` +gs branch (b) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a branch into trunk + +Merges the CR for the current branch into trunk. +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. + +**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. +* `--branch=NAME`: Branch to merge + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) + ### git-spice branch submit {#gs-branch-submit} ``` From cacb661d3a2808dd7190b7542514bcb9e7679480 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:39:02 +0000 Subject: [PATCH 3/3] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 36 ++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index ecad060ec..7ea9b9192 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -310,9 +310,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 +323,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 +626,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 +644,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 +662,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 +1137,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}