From a89d7173d68369b240792fc76406856a0b19793f Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Thu, 14 May 2026 11:34:42 -0400 Subject: [PATCH 1/2] integration: Wire auto-rebuild hook on restack commands and repo sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful restack or sync, branch_restack / stack_restack / upstack_restack / repo_restack / repo_sync now invoke IntegrationHandler.MaybeRebuild. When integration is configured and at least one tracked tip's hash has drifted from the recorded value, the integration branch is regenerated; otherwise this is a no-op. repo sync is included because it restacks tips onto an advanced trunk (and deletes merged tips), drifting integration state exactly as the restack commands do. It was the only restack-capable command the original hook missed. Submit commands intentionally remain unhooked: submit is the user's signal that work is ready to share, and silently rebuilding/merging the integration branch — which may conflict — is a surprising side effect that does not belong in the submit path. Failures in the hook are logged as warnings and do not poison the outer command. --- .../unreleased/Added-20260602-182301.yaml | 3 + branch_restack.go | 11 +++- internal/git/merge_wt_test.go | 6 ++ repo_restack.go | 3 +- repo_sync.go | 15 ++++- stack_restack.go | 6 +- testdata/script/integration_auto_rebuild.txt | 59 +++++++++++++++++ .../integration_auto_rebuild_repo_sync.txt | 65 +++++++++++++++++++ upstack_restack.go | 6 +- 9 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 .changes/unreleased/Added-20260602-182301.yaml create mode 100644 testdata/script/integration_auto_rebuild.txt create mode 100644 testdata/script/integration_auto_rebuild_repo_sync.txt diff --git a/.changes/unreleased/Added-20260602-182301.yaml b/.changes/unreleased/Added-20260602-182301.yaml new file mode 100644 index 000000000..de5ab07ed --- /dev/null +++ b/.changes/unreleased/Added-20260602-182301.yaml @@ -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 diff --git a/branch_restack.go b/branch_restack.go index f84b803b3..f31202284 100644 --- a/branch_restack.go +++ b/branch_restack.go @@ -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) } diff --git a/internal/git/merge_wt_test.go b/internal/git/merge_wt_test.go index a8a8ef40b..5e926963f 100644 --- a/internal/git/merge_wt_test.go +++ b/internal/git/merge_wt_test.go @@ -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' @@ -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' @@ -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' diff --git a/repo_restack.go b/repo_restack.go index c86e1ba01..d88920603 100644 --- a/repo_restack.go +++ b/repo_restack.go @@ -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 { @@ -63,5 +64,5 @@ func (*repoRestackCmd) Run( } log.Infof("Restacked %d branches", count) - return nil + return integrationHandler.MaybeRebuild(ctx) } diff --git a/repo_sync.go b/repo_sync.go index 3cac2100e..10887d2dd 100644 --- a/repo_sync.go +++ b/repo_sync.go @@ -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) } diff --git a/stack_restack.go b/stack_restack.go index 7d7b4d689..aa77c635e 100644 --- a/stack_restack.go +++ b/stack_restack.go @@ -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) } diff --git a/testdata/script/integration_auto_rebuild.txt b/testdata/script/integration_auto_rebuild.txt new file mode 100644 index 000000000..0cb64f5dd --- /dev/null +++ b/testdata/script/integration_auto_rebuild.txt @@ -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 ' +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) diff --git a/testdata/script/integration_auto_rebuild_repo_sync.txt b/testdata/script/integration_auto_rebuild_repo_sync.txt new file mode 100644 index 000000000..eb9331f95 --- /dev/null +++ b/testdata/script/integration_auto_rebuild_repo_sync.txt @@ -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 ' +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 diff --git a/upstack_restack.go b/upstack_restack.go index 4def1ada7..94332cadf 100644 --- a/upstack_restack.go +++ b/upstack_restack.go @@ -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) } From d6588fa66387448ffe4daa5ed6b90eaa75bc57fc 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:37:16 +0000 Subject: [PATCH 2/2] [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 898fc4542..796a421e8 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}