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/Added-20260602-182301.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'integration: Restack commands and ''repo sync'' now auto-rebuild the integration branch when configured.'
time: 2026-06-02T18:23:01.734474-04:00
11 changes: 9 additions & 2 deletions branch_restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ func (cmd *branchRestackCmd) AfterApply(ctx context.Context, wt *git.Worktree) e
return nil
}

func (cmd *branchRestackCmd) Run(ctx context.Context, handler RestackHandler) error {
return handler.RestackBranch(ctx, cmd.Branch, nil)
func (cmd *branchRestackCmd) Run(
ctx context.Context,
handler RestackHandler,
integrationHandler IntegrationHandler,
) error {
if err := handler.RestackBranch(ctx, cmd.Branch, nil); err != nil {
return err
}
return integrationHandler.MaybeRebuild(ctx)
}
36 changes: 21 additions & 15 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
6 changes: 6 additions & 0 deletions internal/git/merge_wt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ func TestWorktree_Merge_noFF(t *testing.T) {
at '2025-01-01T00:00:00Z'

git init
git config user.name 'Test'
git config user.email 'test@example.com'
git add base.txt
git commit -m 'Initial commit'

Expand Down Expand Up @@ -65,6 +67,8 @@ func TestWorktree_Merge_conflict(t *testing.T) {
at '2025-01-01T00:00:00Z'

git init
git config user.name 'Test'
git config user.email 'test@example.com'
git add shared.txt
git commit -m 'Initial commit'

Expand Down Expand Up @@ -119,6 +123,8 @@ func TestWorktree_Merge_conflictLeaveInWorktree(t *testing.T) {
at '2025-01-01T00:00:00Z'

git init
git config user.name 'Test'
git config user.email 'test@example.com'
git add shared.txt
git commit -m 'Initial commit'

Expand Down
3 changes: 2 additions & 1 deletion repo_restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func (*repoRestackCmd) Run(
store *state.Store,
handler RestackHandler,
autostashHandler AutostashHandler,
integrationHandler IntegrationHandler,
) (retErr error) {
currentBranch, err := wt.CurrentBranch(ctx)
if err != nil {
Expand Down Expand Up @@ -63,5 +64,5 @@ func (*repoRestackCmd) Run(
}

log.Infof("Restacked %d branches", count)
return nil
return integrationHandler.MaybeRebuild(ctx)
}
15 changes: 13 additions & 2 deletions repo_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ type SyncHandler interface {
SyncTrunk(ctx context.Context, opts *sync.TrunkOptions) error
}

func (cmd *repoSyncCmd) Run(ctx context.Context, syncHandler SyncHandler) error {
return syncHandler.SyncTrunk(ctx, &cmd.TrunkOptions)
func (cmd *repoSyncCmd) Run(
ctx context.Context,
syncHandler SyncHandler,
integrationHandler IntegrationHandler,
) error {
if err := syncHandler.SyncTrunk(ctx, &cmd.TrunkOptions); err != nil {
return err
}

// Like the restack commands, rebuild the integration branch if the
// sync moved or deleted any tracked tip. MaybeRebuild is drift-gated,
// so this is a no-op when nothing changed or integration is unset.
return integrationHandler.MaybeRebuild(ctx)
}
6 changes: 5 additions & 1 deletion stack_restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,14 @@ func (cmd *stackRestackCmd) Run(
view ui.View,
store *state.Store,
handler RestackHandler,
integrationHandler IntegrationHandler,
) error {
if err := verifyRestackFromTrunk(log, view, store, cmd.Branch, "stack"); err != nil {
return err
}

return handler.RestackStack(ctx, cmd.Branch, nil)
if err := handler.RestackStack(ctx, cmd.Branch, nil); err != nil {
return err
}
return integrationHandler.MaybeRebuild(ctx)
}
59 changes: 59 additions & 0 deletions testdata/script/integration_auto_rebuild.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Verifies that restacking a branch which is a tracked integration tip
# automatically triggers a rebuild of the integration branch.

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

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

git add feat-a.txt
gs bc feat-a -m 'Add feat-a'

git checkout main
git add feat-b.txt
gs bc feat-b -m 'Add feat-b'

# Configure integration with feat-a and feat-b.
git checkout main
gs integration create preview --tip feat-a --tip feat-b

# Initial rebuild establishes baseline tip hashes.
gs integration rebuild
stderr 'Integration branch "preview" rebuilt with 2 tip\(s\).'

# Show: no drift after rebuild.
gs integration show
cmp stdout $WORK/golden/show-after-rebuild.txt

# Now amend feat-a so its hash moves.
gs branch checkout feat-a
git add feat-a-extra.txt
gs commit create -m 'feat-a follow-up'

# At this point feat-a has drifted but rebuild hasn't run yet.
git checkout main

# 'gs upstack restack' from main rebuilds nothing (feat-a is upstack),
# but the post-restack hook detects drift and rebuilds the integration.
gs upstack restack
stderr 'Rebuilding integration branch "preview"'

# After auto-rebuild, a second restack finds no drift and does not
# rebuild again (no "Rebuilding integration branch" log this time).
gs upstack restack
! stderr 'Rebuilding integration branch'

-- repo/feat-a.txt --
feature a
-- repo/feat-a-extra.txt --
follow-up
-- repo/feat-b.txt --
feature b
-- golden/show-after-rebuild.txt --
Integration branch: preview
Tips:
- feat-a (b049cb7)
- feat-b (6fc243e)
65 changes: 65 additions & 0 deletions testdata/script/integration_auto_rebuild_repo_sync.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Verifies that 'repo sync --restack' triggers an integration rebuild
# when the sync restacks tracked integration tips, matching the behavior
# of the dedicated restack commands. Previously repo sync was the only
# restack-capable command that did not hook the integration branch.

as 'Test <test@example.com>'
at '2024-05-18T13:59:12Z'

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

# set up a fake GitHub remote
shamhub init
shamhub new origin alice/example.git
shamhub register alice
git push origin main

env SHAMHUB_USERNAME=alice
gs auth login

# stack: feature1 -> feature2 -> feature3
git add feature1.txt
gs bc -m 'Add feature1' feature1

git add feature2.txt
gs bc -m 'Add feature2' feature2

git add feature3.txt
gs bc -m 'Add feature3' feature3

# Integration tracks feature2 and feature3 as tips.
git checkout main
gs integration create preview --tip feature2 --tip feature3

# Initial rebuild establishes baseline tip hashes.
gs integration rebuild
stderr 'Integration branch "preview" rebuilt with 2 tip\(s\).'

# Submit feature1 and merge it server-side so the next sync advances
# trunk and restacks feature2/feature3 (the integration tips) onto it.
gs bco feature1
gs branch submit --fill
stderr 'Created #'
shamhub merge alice/example 1

# 'repo sync --restack' deletes the merged feature1 and restacks
# feature2/feature3 onto the new trunk, drifting the integration tips.
# The post-sync hook must rebuild the integration branch.
git checkout main
gs repo sync --restack
stderr 'feature1: #1 was merged'
stderr 'Rebuilding integration branch "preview"'

# A second sync finds no drift and does not rebuild again.
gs repo sync --restack
! stderr 'Rebuilding integration branch'

-- repo/feature1.txt --
feature 1
-- repo/feature2.txt --
feature 2
-- repo/feature3.txt --
feature 3
6 changes: 5 additions & 1 deletion upstack_restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,14 @@ func (cmd *upstackRestackCmd) Run(
view ui.View,
store *state.Store,
handler RestackHandler,
integrationHandler IntegrationHandler,
) error {
if err := verifyRestackFromTrunk(log, view, store, cmd.Branch, "upstack"); err != nil {
return err
}

return handler.RestackUpstack(ctx, cmd.Branch, &cmd.UpstackOptions)
if err := handler.RestackUpstack(ctx, cmd.Branch, &cmd.UpstackOptions); err != nil {
return err
}
return integrationHandler.MaybeRebuild(ctx)
}
Loading