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..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} @@ -1464,7 +1470,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 +1479,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" + } + } +]