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-111817.yaml
Original file line number Diff line number Diff line change
@@ -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
93 changes: 73 additions & 20 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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}

Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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
Expand All @@ -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. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag:{ title="Released in version" }</span><span class="mdx-badge__text">[v0.15.0](/changelog.md#v0.15.0)</span>
* `-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. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag:{ title="Released in version" }</span><span class="mdx-badge__text">[v0.21.0](/changelog.md#v0.21.0)</span>
* `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag:{ title="Released in version" }</span><span class="mdx-badge__text">[v0.21.0](/changelog.md#v0.21.0)</span>
* `--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}

Expand Down
102 changes: 96 additions & 6 deletions integration_submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,55 @@ 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(`
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.
`) + "\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.")
Expand All @@ -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 {
Expand Down
Loading
Loading