From f6d96533e6eeb6b08c1356d1d237971b484c40c9 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Mon, 1 Jun 2026 08:30:26 -0400 Subject: [PATCH 1/4] integration: accept-incoming fallback after auto-resolve --- .../unreleased/Added-20260601-084442.yaml | 3 + doc/includes/cli-reference.md | 84 ++---------- integration_rebuild.go | 14 +- internal/git/merge_wt.go | 19 +++ internal/handler/integration/handler.go | 61 ++++++++- internal/handler/integration/handler_test.go | 78 +++++++++++ internal/handler/integration/mocks_test.go | 125 ++++++------------ internal/spice/config.go | 21 +++ main.go | 21 +-- testdata/help/integration_rebuild.txt | 15 ++- .../integration_auto_resolve_disabled.txt | 5 +- ...ntegration_auto_resolve_invalid_output.txt | 4 +- ...n_auto_resolve_unresolved_no_questions.txt | 5 +- .../integration_conflict_sequential.txt | 4 +- .../integration_rebuild_accept_incoming.txt | 70 ++++++++++ 15 files changed, 352 insertions(+), 177 deletions(-) create mode 100644 .changes/unreleased/Added-20260601-084442.yaml create mode 100644 testdata/script/integration_rebuild_accept_incoming.txt diff --git a/.changes/unreleased/Added-20260601-084442.yaml b/.changes/unreleased/Added-20260601-084442.yaml new file mode 100644 index 000000000..8ddb950e7 --- /dev/null +++ b/.changes/unreleased/Added-20260601-084442.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'integration: accept-incoming fallback completes integration rebuild without manual intervention when conflicts survive the merge drivers and resolver. Disable with --no-accept-incoming or spice.integration.acceptIncoming=false.' +time: 2026-06-01T08:44:42.990672-04:00 diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 71e913973..72c90d569 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} ``` @@ -1447,12 +1378,19 @@ configured resolver script is invoked to attempt automatic resolution before surfacing conflicts. See the recipe for details on the JSON protocol the script must implement. +Any conflicts that survive the merge drivers and the resolver +are auto-resolved by taking the incoming tip's version. Pass +--no-accept-incoming (or set spice.integration.acceptIncoming +=false) to disable that final fallback and surface conflicts +for manual resolution instead. + **Flags** * `--push`: Also push the integration branch after rebuilding * `--[no-]auto-resolve` ([:material-wrench:{ .middle title="spice.integration.autoResolve" }](/cli/config.md#spiceintegrationautoresolve)): Auto-resolve merge conflicts using the configured resolver script +* `--[no-]accept-incoming` ([:material-wrench:{ .middle title="spice.integration.acceptIncoming" }](/cli/config.md#spiceintegrationacceptincoming)): Final-stage fallback: take the incoming tip's version for any remaining conflicts so the rebuild completes without manual intervention -**Configuration**: [spice.integration.autoResolve](/cli/config.md#spiceintegrationautoresolve) +**Configuration**: [spice.integration.acceptIncoming](/cli/config.md#spiceintegrationacceptincoming), [spice.integration.autoResolve](/cli/config.md#spiceintegrationautoresolve) ### git-spice integration submit {#gs-integration-submit} diff --git a/integration_rebuild.go b/integration_rebuild.go index 4fc215db7..fb35f5e7b 100644 --- a/integration_rebuild.go +++ b/integration_rebuild.go @@ -12,8 +12,9 @@ import ( ) type integrationRebuildCmd struct { - Push bool `name:"push" help:"Also push the integration branch after rebuilding"` - AutoResolve bool `name:"auto-resolve" negatable:"" config:"integration.autoResolve" help:"Auto-resolve merge conflicts using the configured resolver script"` + Push bool `name:"push" help:"Also push the integration branch after rebuilding"` + AutoResolve bool `name:"auto-resolve" negatable:"" config:"integration.autoResolve" help:"Auto-resolve merge conflicts using the configured resolver script"` + AcceptIncoming bool `name:"accept-incoming" negatable:"" default:"true" config:"integration.acceptIncoming" help:"Final-stage fallback: take the incoming tip's version for any remaining conflicts so the rebuild completes without manual intervention"` } func (*integrationRebuildCmd) Help() string { @@ -31,6 +32,12 @@ func (*integrationRebuildCmd) Help() string { configured resolver script is invoked to attempt automatic resolution before surfacing conflicts. See the recipe for details on the JSON protocol the script must implement. + + Any conflicts that survive the merge drivers and the resolver + are auto-resolved by taking the incoming tip's version. Pass + --no-accept-incoming (or set spice.integration.acceptIncoming + =false) to disable that final fallback and surface conflicts + for manual resolution instead. `) } @@ -40,7 +47,8 @@ func (cmd *integrationRebuildCmd) Run( handler IntegrationHandler, ) error { res, err := handler.Rebuild(ctx, &integration.RebuildOptions{ - AutoResolve: &cmd.AutoResolve, + AutoResolve: &cmd.AutoResolve, + AcceptIncoming: &cmd.AcceptIncoming, }) if err != nil { conflict := new(integration.ConflictError) diff --git a/internal/git/merge_wt.go b/internal/git/merge_wt.go index 7e82775e3..cd84349a1 100644 --- a/internal/git/merge_wt.go +++ b/internal/git/merge_wt.go @@ -180,6 +180,25 @@ func (w *Worktree) MergeContinue( return nil } +// CheckoutTheirs replaces the given paths in the worktree with their +// "theirs" (MERGE_HEAD) version. Intended as a final-stage fallback +// during integration rebuild: after merge drivers and an optional +// resolver have run, any paths still conflicting fall through to this +// take-incoming step so the merge can complete. +// +// The caller is responsible for staging the paths and committing. +// See [Worktree.MergeContinue]. +func (w *Worktree) CheckoutTheirs(ctx context.Context, paths []string) error { + if len(paths) == 0 { + return nil + } + args := append([]string{"--theirs", "--"}, paths...) + if err := w.gitCmd(ctx, "checkout", args...).Run(); err != nil { + return fmt.Errorf("git checkout --theirs: %w", err) + } + return nil +} + // AmendCommitAll stages all worktree changes (additions, modifications, // deletions) and amends HEAD with --no-edit. Preserves merge-commit // parentage. diff --git a/internal/handler/integration/handler.go b/internal/handler/integration/handler.go index 59512b67d..cb927e8b2 100644 --- a/internal/handler/integration/handler.go +++ b/internal/handler/integration/handler.go @@ -40,6 +40,7 @@ type GitWorktree interface { CurrentBranch(ctx context.Context) (string, error) CheckoutBranch(ctx context.Context, branch string) error CheckoutNewBranch(ctx context.Context, req git.CheckoutNewBranchRequest) error + CheckoutTheirs(ctx context.Context, paths []string) error Merge(ctx context.Context, opts git.MergeOptions) error MergeContinue(ctx context.Context, paths []string, message string) error AmendCommitAll(ctx context.Context) error @@ -108,6 +109,17 @@ type Handler struct { // (mocks, CLI docs, test fixtures) that the take-incoming driver // could not merge correctly. nil disables the step entirely. Regenerator Regenerator + + // DefaultAcceptIncoming sets the default behavior when + // [RebuildOptions.AcceptIncoming] is nil. Typically populated + // from spice.integration.acceptIncoming. + // + // When true, conflicts that survive the merge drivers AND any + // configured resolver are resolved by taking the incoming tip's + // version, so the rebuild can complete without user intervention. + // When false, surviving conflicts surface as a [ConflictError] + // and the rebuild halts for manual resolution. + DefaultAcceptIncoming bool } // defaultMaxResolveIterations is the fallback when @@ -357,6 +369,12 @@ type RebuildOptions struct { // for this invocation. A true value enables the configured // resolver; a false value disables it even when configured. AutoResolve *bool + + // AcceptIncoming, if non-nil, overrides + // [Handler.DefaultAcceptIncoming] for this invocation. When true, + // conflicts that remain after the resolver are auto-resolved by + // taking the incoming tip's version. + AcceptIncoming *bool } // Rebuild regenerates the integration branch by sequentially merging @@ -486,6 +504,14 @@ func (h *Handler) shouldAutoResolve(opts *RebuildOptions) bool { return h.DefaultAutoResolve } +// shouldAcceptIncoming resolves opts against DefaultAcceptIncoming. +func (h *Handler) shouldAcceptIncoming(opts *RebuildOptions) bool { + if opts != nil && opts.AcceptIncoming != nil { + return *opts.AcceptIncoming + } + return h.DefaultAcceptIncoming +} + func (h *Handler) freshRebuild( ctx context.Context, info *state.IntegrationInfo, @@ -628,12 +654,29 @@ func (h *Handler) runMerges( case resolveErr != nil: h.Log.Warnf("Auto-resolve failed for tip %q: %v", tip.Name, resolveErr) - // fall through to conflict-surfacing + // fall through to accept-incoming / conflict-surfacing case resolved: continue } } + // Final fallback: accept the incoming tip's version of any + // still-conflicted paths. This is the layer that lets a rebuild + // complete without user intervention when no resolver script is + // configured or the resolver could not resolve everything. + if h.shouldAcceptIncoming(opts) && len(conflict.ConflictPaths) > 0 { + if acceptErr := h.acceptIncoming(ctx, conflict.ConflictPaths, mergeMsg); acceptErr != nil { + h.Log.Warnf("Accept-incoming failed for tip %q: %v", + tip.Name, acceptErr) + // fall through to conflict-surfacing + } else { + h.Log.Infof("Tip %q: accepted incoming for %d conflicted path(s): %s", + tip.Name, len(conflict.ConflictPaths), + strings.Join(conflict.ConflictPaths, ", ")) + continue + } + } + if saveErr := h.Store.SetPendingIntegrationRebuild(ctx, &state.IntegrationRebuild{ Integration: info.Name, Tips: tips, @@ -938,6 +981,22 @@ func (h *Handler) MaybeRebuild(ctx context.Context) error { return nil } +// acceptIncoming completes an in-progress merge by taking the incoming +// tip's version for every conflicted path and committing the merge. +// Used as a final, non-interactive fallback when no resolver is +// configured or the resolver leaves residual conflicts. +func (h *Handler) acceptIncoming( + ctx context.Context, paths []string, mergeMsg string, +) error { + if err := h.Worktree.CheckoutTheirs(ctx, paths); err != nil { + return fmt.Errorf("checkout theirs: %w", err) + } + if err := h.Worktree.MergeContinue(ctx, paths, mergeMsg); err != nil { + return fmt.Errorf("commit merge: %w", err) + } + return nil +} + // autoResolveLoop drives the resolver iteration for a single tip merge. // Returns resolved=true if the merge was completed automatically. // diff --git a/internal/handler/integration/handler_test.go b/internal/handler/integration/handler_test.go index f0451648e..fcc96e4db 100644 --- a/internal/handler/integration/handler_test.go +++ b/internal/handler/integration/handler_test.go @@ -927,6 +927,84 @@ func TestHandler_Rebuild_autoResolveAssumptions(t *testing.T) { require.NoError(t, err) } +func TestHandler_Rebuild_acceptIncomingFallback(t *testing.T) { + h, mocks, _, _ := newHandlerWithResolver(t) + h.Resolver = nil // no resolver configured + mergeMsg := setupConflictMerge(t, mocks) + + mocks.Worktree.EXPECT(). + CheckoutTheirs(gomock.Any(), []string{"shared.txt"}). + Return(nil) + mocks.Worktree.EXPECT(). + MergeContinue(gomock.Any(), []string{"shared.txt"}, mergeMsg). + Return(nil) + + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Any()). + Return(nil) + mocks.Store.EXPECT(). + ClearPendingIntegrationRebuild(gomock.Any()). + Return(nil) + mocks.Worktree.EXPECT(). + CheckoutBranch(gomock.Any(), "main"). + Return(nil) + + res, err := h.Rebuild(t.Context(), + &RebuildOptions{AcceptIncoming: boolPtr(true)}) + require.NoError(t, err) + assert.Equal(t, "preview", res.Name) +} + +func TestHandler_Rebuild_acceptIncomingAfterResolverFailure(t *testing.T) { + h, mocks, resolver, _ := newHandlerWithResolver(t) + mergeMsg := setupConflictMerge(t, mocks) + + resolver.EXPECT(). + Resolve(gomock.Any(), gomock.Any()). + Return(nil, errors.New("resolver crashed")) + + mocks.Worktree.EXPECT(). + CheckoutTheirs(gomock.Any(), []string{"shared.txt"}). + Return(nil) + mocks.Worktree.EXPECT(). + MergeContinue(gomock.Any(), []string{"shared.txt"}, mergeMsg). + Return(nil) + + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Any()). + Return(nil) + mocks.Store.EXPECT(). + ClearPendingIntegrationRebuild(gomock.Any()). + Return(nil) + mocks.Worktree.EXPECT(). + CheckoutBranch(gomock.Any(), "main"). + Return(nil) + + _, err := h.Rebuild(t.Context(), &RebuildOptions{ + AutoResolve: boolPtr(true), + AcceptIncoming: boolPtr(true), + }) + require.NoError(t, err) +} + +func TestHandler_Rebuild_acceptIncomingDisabled(t *testing.T) { + h, mocks, _, _ := newHandlerWithResolver(t) + h.Resolver = nil + h.DefaultAcceptIncoming = true + setupConflictMerge(t, mocks) + + mocks.Store.EXPECT(). + SetPendingIntegrationRebuild(gomock.Any(), gomock.Any()). + Return(nil) + + _, err := h.Rebuild(t.Context(), + &RebuildOptions{AcceptIncoming: boolPtr(false)}) + require.Error(t, err) + var conflictErr *ConflictError + require.True(t, errors.As(err, &conflictErr)) + assert.Equal(t, "feat-a", conflictErr.Tip) +} + func TestHandler_OnBranchRemoved(t *testing.T) { h, mocks, _, _ := newHandlerWithResolver(t) // No integration configured -> tip pruning is a no-op. diff --git a/internal/handler/integration/mocks_test.go b/internal/handler/integration/mocks_test.go index 66ee3b7fb..48dff397e 100644 --- a/internal/handler/integration/mocks_test.go +++ b/internal/handler/integration/mocks_test.go @@ -14,7 +14,6 @@ import ( reflect "reflect" git "go.abhg.dev/gs/internal/git" - scriptrun "go.abhg.dev/gs/internal/scriptrun" spice "go.abhg.dev/gs/internal/spice" state "go.abhg.dev/gs/internal/spice/state" gomock "go.uber.org/mock/gomock" @@ -121,44 +120,6 @@ func (c *MockGitRepositoryPeelToCommitCall) DoAndReturn(f func(context.Context, return c } -// Worktrees mocks base method. -func (m *MockGitRepository) Worktrees(ctx context.Context) iter.Seq2[*git.WorktreeListItem, error] { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Worktrees", ctx) - ret0, _ := ret[0].(iter.Seq2[*git.WorktreeListItem, error]) - return ret0 -} - -// Worktrees indicates an expected call of Worktrees. -func (mr *MockGitRepositoryMockRecorder) Worktrees(ctx any) *MockGitRepositoryWorktreesCall { - mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Worktrees", reflect.TypeOf((*MockGitRepository)(nil).Worktrees), ctx) - return &MockGitRepositoryWorktreesCall{Call: call} -} - -// MockGitRepositoryWorktreesCall wrap *gomock.Call -type MockGitRepositoryWorktreesCall struct { - *gomock.Call -} - -// Return rewrite *gomock.Call.Return -func (c *MockGitRepositoryWorktreesCall) Return(arg0 iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { - c.Call = c.Call.Return(arg0) - return c -} - -// Do rewrite *gomock.Call.Do -func (c *MockGitRepositoryWorktreesCall) Do(f func(context.Context) iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { - c.Call = c.Call.Do(f) - return c -} - -// DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockGitRepositoryWorktreesCall) DoAndReturn(f func(context.Context) iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { - c.Call = c.Call.DoAndReturn(f) - return c -} - // MockGitWorktree is a mock of GitWorktree interface. type MockGitWorktree struct { ctrl *gomock.Controller @@ -297,6 +258,44 @@ func (c *MockGitWorktreeCheckoutNewBranchCall) DoAndReturn(f func(context.Contex return c } +// CheckoutTheirs mocks base method. +func (m *MockGitWorktree) CheckoutTheirs(ctx context.Context, paths []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckoutTheirs", ctx, paths) + ret0, _ := ret[0].(error) + return ret0 +} + +// CheckoutTheirs indicates an expected call of CheckoutTheirs. +func (mr *MockGitWorktreeMockRecorder) CheckoutTheirs(ctx, paths any) *MockGitWorktreeCheckoutTheirsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckoutTheirs", reflect.TypeOf((*MockGitWorktree)(nil).CheckoutTheirs), ctx, paths) + return &MockGitWorktreeCheckoutTheirsCall{Call: call} +} + +// MockGitWorktreeCheckoutTheirsCall wrap *gomock.Call +type MockGitWorktreeCheckoutTheirsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitWorktreeCheckoutTheirsCall) Return(arg0 error) *MockGitWorktreeCheckoutTheirsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitWorktreeCheckoutTheirsCall) Do(f func(context.Context, []string) error) *MockGitWorktreeCheckoutTheirsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitWorktreeCheckoutTheirsCall) DoAndReturn(f func(context.Context, []string) error) *MockGitWorktreeCheckoutTheirsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // CurrentBranch mocks base method. func (m *MockGitWorktree) CurrentBranch(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -489,44 +488,6 @@ func (c *MockGitWorktreePushCall) DoAndReturn(f func(context.Context, git.PushOp return c } -// RootDir mocks base method. -func (m *MockGitWorktree) RootDir() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RootDir") - ret0, _ := ret[0].(string) - return ret0 -} - -// RootDir indicates an expected call of RootDir. -func (mr *MockGitWorktreeMockRecorder) RootDir() *MockGitWorktreeRootDirCall { - mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RootDir", reflect.TypeOf((*MockGitWorktree)(nil).RootDir)) - return &MockGitWorktreeRootDirCall{Call: call} -} - -// MockGitWorktreeRootDirCall wrap *gomock.Call -type MockGitWorktreeRootDirCall struct { - *gomock.Call -} - -// Return rewrite *gomock.Call.Return -func (c *MockGitWorktreeRootDirCall) Return(arg0 string) *MockGitWorktreeRootDirCall { - c.Call = c.Call.Return(arg0) - return c -} - -// Do rewrite *gomock.Call.Do -func (c *MockGitWorktreeRootDirCall) Do(f func() string) *MockGitWorktreeRootDirCall { - c.Call = c.Call.Do(f) - return c -} - -// DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockGitWorktreeRootDirCall) DoAndReturn(f func() string) *MockGitWorktreeRootDirCall { - c.Call = c.Call.DoAndReturn(f) - return c -} - // MockStore is a mock of Store interface. type MockStore struct { ctrl *gomock.Controller @@ -908,10 +869,10 @@ func (m *MockResolver) EXPECT() *MockResolverMockRecorder { } // Resolve mocks base method. -func (m *MockResolver) Resolve(ctx context.Context, req *ResolveRequest) (*scriptrun.ResolveResponse, error) { +func (m *MockResolver) Resolve(ctx context.Context, req *ResolveRequest) (*ResolveResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Resolve", ctx, req) - ret0, _ := ret[0].(*scriptrun.ResolveResponse) + ret0, _ := ret[0].(*ResolveResponse) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -929,19 +890,19 @@ type MockResolverResolveCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockResolverResolveCall) Return(arg0 *scriptrun.ResolveResponse, arg1 error) *MockResolverResolveCall { +func (c *MockResolverResolveCall) Return(arg0 *ResolveResponse, arg1 error) *MockResolverResolveCall { c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockResolverResolveCall) Do(f func(context.Context, *ResolveRequest) (*scriptrun.ResolveResponse, error)) *MockResolverResolveCall { +func (c *MockResolverResolveCall) Do(f func(context.Context, *ResolveRequest) (*ResolveResponse, error)) *MockResolverResolveCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockResolverResolveCall) DoAndReturn(f func(context.Context, *ResolveRequest) (*scriptrun.ResolveResponse, error)) *MockResolverResolveCall { +func (c *MockResolverResolveCall) DoAndReturn(f func(context.Context, *ResolveRequest) (*ResolveResponse, error)) *MockResolverResolveCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/spice/config.go b/internal/spice/config.go index 71950b806..c0cdc3ba9 100644 --- a/internal/spice/config.go +++ b/internal/spice/config.go @@ -297,6 +297,27 @@ func (c *Config) IntegrationAutoResolve() bool { return v } +// IntegrationAcceptIncoming reports whether the rebuild's final +// fallback should accept the incoming tip's version for any conflict +// that survives the merge drivers and the configured resolver. +// +// Read from spice.integration.acceptIncoming. Defaults to true so +// rebuild can complete without user intervention out of the box. +// Unparseable values are treated as the default. +func (c *Config) IntegrationAcceptIncoming() bool { + values := c.items[git.ConfigKey( + _spiceSection+".integration.acceptIncoming", + ).Canonical()] + if len(values) == 0 { + return true + } + v, err := strconv.ParseBool(values[len(values)-1]) + if err != nil { + return true + } + return v +} + // Validate checks if the configuration is valid for the given application. // This is a no-op, as we allow unknown configuration keys. func (*Config) Validate(*kong.Application) error { return nil } diff --git a/main.go b/main.go index 702602006..799b78cda 100644 --- a/main.go +++ b/main.go @@ -723,16 +723,17 @@ func (cmd *mainCmd) AfterApply( } } return &integration.Handler{ - Log: log, - Repository: repo, - Worktree: wt, - Store: store, - Service: svc, - Resolver: resolver, - Prompter: integration.NewViewPrompter(view), - DefaultAutoResolve: cfg.IntegrationAutoResolve(), - RepoRoot: repoRoot, - MaxResolveIterations: cfg.ScriptResolveMaxIterations(), + Log: log, + Repository: repo, + Worktree: wt, + Store: store, + Service: svc, + Resolver: resolver, + Prompter: integration.NewViewPrompter(view), + DefaultAutoResolve: cfg.IntegrationAutoResolve(), + DefaultAcceptIncoming: cfg.IntegrationAcceptIncoming(), + RepoRoot: repoRoot, + MaxResolveIterations: cfg.ScriptResolveMaxIterations(), Regenerator: &integration.FileRegenerator{ Log: log, Runner: &scriptrun.Runner{ diff --git a/testdata/help/integration_rebuild.txt b/testdata/help/integration_rebuild.txt index 906fe7eaa..b4940987b 100644 --- a/testdata/help/integration_rebuild.txt +++ b/testdata/help/integration_rebuild.txt @@ -15,10 +15,19 @@ resolver script is invoked to attempt automatic resolution before surfacing conflicts. See the recipe for details on the JSON protocol the script must implement. +Any conflicts that survive the merge drivers and the resolver are auto-resolved +by taking the incoming tip's version. Pass --no-accept-incoming (or set +spice.integration.acceptIncoming =false) to disable that final fallback and +surface conflicts for manual resolution instead. + Flags: - --push Also push the integration branch after rebuilding - --[no-]auto-resolve Auto-resolve merge conflicts using the configured - resolver script (🔧 spice.integration.autoResolve) + --push Also push the integration branch after rebuilding + --[no-]auto-resolve Auto-resolve merge conflicts using the configured + resolver script (🔧 spice.integration.autoResolve) + --[no-]accept-incoming Final-stage fallback: take the incoming tip's + version for any remaining conflicts so the + rebuild completes without manual intervention (🔧 + spice.integration.acceptIncoming) Global Flags: -h, --help Show help for the command diff --git a/testdata/script/integration_auto_resolve_disabled.txt b/testdata/script/integration_auto_resolve_disabled.txt index 80a63058d..c52d36ccc 100644 --- a/testdata/script/integration_auto_resolve_disabled.txt +++ b/testdata/script/integration_auto_resolve_disabled.txt @@ -28,7 +28,10 @@ git config spice.integration.autoResolve true # --no-auto-resolve must short-circuit before the resolver runs; # the resolver never gets to write its sentinel file. -! gs integration rebuild --no-auto-resolve +# --no-accept-incoming disables the final fallback so the conflict +# surfaces for manual resolution (otherwise accept-incoming would +# silently complete the merge). +! gs integration rebuild --no-auto-resolve --no-accept-incoming stderr 'Merge conflict in tip feat-b' ! exists $WORK/resolver-ran diff --git a/testdata/script/integration_auto_resolve_invalid_output.txt b/testdata/script/integration_auto_resolve_invalid_output.txt index 7540eee5d..1222f440a 100644 --- a/testdata/script/integration_auto_resolve_invalid_output.txt +++ b/testdata/script/integration_auto_resolve_invalid_output.txt @@ -26,7 +26,9 @@ chmod 700 $WORK/resolver.sh git config spice.integration.resolver $WORK/resolver.sh git config spice.integration.autoResolve true -! gs integration rebuild +# --no-accept-incoming disables the final fallback so the conflict +# surfaces for manual resolution after the invalid-output rejection. +! gs integration rebuild --no-accept-incoming stderr 'Merge conflict in tip feat-b' -- repo/shared.txt -- diff --git a/testdata/script/integration_auto_resolve_unresolved_no_questions.txt b/testdata/script/integration_auto_resolve_unresolved_no_questions.txt index 686652e6d..0e229a5f8 100644 --- a/testdata/script/integration_auto_resolve_unresolved_no_questions.txt +++ b/testdata/script/integration_auto_resolve_unresolved_no_questions.txt @@ -1,5 +1,6 @@ # Resolver reports unresolved_files with no questions: auto-resolve -# fails, falling through to the existing conflict-surfacing path. +# fails, and with --no-accept-incoming the conflict surfaces for +# manual resolution rather than being silently taken-incoming. as 'Test ' at '2025-01-01T00:00:00Z' @@ -26,7 +27,7 @@ chmod 700 $WORK/resolver.sh git config spice.integration.resolver $WORK/resolver.sh git config spice.integration.autoResolve true -! gs integration rebuild +! gs integration rebuild --no-accept-incoming stderr 'unresolved files with no questions: shared.txt' stderr 'Merge conflict in tip feat-b' diff --git a/testdata/script/integration_conflict_sequential.txt b/testdata/script/integration_conflict_sequential.txt index a6498ea21..c2dfb1395 100644 --- a/testdata/script/integration_conflict_sequential.txt +++ b/testdata/script/integration_conflict_sequential.txt @@ -30,7 +30,9 @@ git checkout main gs integration create preview --tip feat-a --tip feat-b --tip feat-c # First rebuild merges feat-a, then conflicts on feat-b. -! gs integration rebuild +# Disable the accept-incoming fallback to exercise the manual +# resolve-and-resume flow this test cares about. +! gs integration rebuild --no-accept-incoming stderr 'Merge conflict in tip feat-b' stderr 'git merge --continue' diff --git a/testdata/script/integration_rebuild_accept_incoming.txt b/testdata/script/integration_rebuild_accept_incoming.txt new file mode 100644 index 000000000..511a6a15b --- /dev/null +++ b/testdata/script/integration_rebuild_accept_incoming.txt @@ -0,0 +1,70 @@ +# When no resolver is configured, conflicts that survive the merge +# drivers are auto-resolved by the accept-incoming fallback so the +# rebuild completes without user intervention. The incoming tip's +# version wins for each conflicted path. +# +# Also verifies that --no-accept-incoming disables the fallback and +# surfaces the conflict for manual resolution. + +as 'Test ' +at '2026-06-01T12:00:00Z' + +cd repo +git init +git add shared.txt +git commit -m 'Initial commit' +gs repo init + +cp feat-a-version.txt shared.txt +git add shared.txt +gs bc feat-a -m 'feat-a changes shared.txt' + +git checkout main +cp feat-b-version.txt shared.txt +git add shared.txt +gs bc feat-b -m 'feat-b changes shared.txt' + +git checkout main +git add feat-c.txt +gs bc feat-c -m 'feat-c adds an unrelated file' + +git checkout main +gs integration create preview --tip feat-a --tip feat-b --tip feat-c + +# Rebuild completes without prompting: +# - feat-a merges cleanly +# - feat-b conflicts on shared.txt; accept-incoming takes feat-b's version +# - feat-c merges cleanly +gs integration rebuild +stderr 'accepted incoming for 1 conflicted path\(s\): shared.txt' +stderr 'rebuilt with 3 tip\(s\)' + +# preview contains both feat-b's resolution and feat-c's file. +git show preview:shared.txt +cmp stdout $WORK/golden/feat-b.txt + +git show preview:feat-c.txt +cmp stdout $WORK/golden/feat-c.txt + +# With --no-accept-incoming, the conflict surfaces for manual resolution. +# Clear rerere recordings from the first rebuild so the same conflict +# surfaces fresh. +rm -rf .git/rr-cache +gs integration delete +gs integration create preview --tip feat-a --tip feat-b +! gs integration rebuild --no-accept-incoming +stderr 'Merge conflict in tip feat-b' +stderr 'git merge --continue' + +-- repo/shared.txt -- +base content +-- repo/feat-a-version.txt -- +feat-a content +-- repo/feat-b-version.txt -- +feat-b content +-- repo/feat-c.txt -- +feat-c content +-- golden/feat-b.txt -- +feat-b content +-- golden/feat-c.txt -- +feat-c content From 027516513aa7c1542e61cdbbd75b302eee46ebdb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:17:17 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- internal/handler/integration/mocks_test.go | 87 ++++++++++++++++++++-- 1 file changed, 82 insertions(+), 5 deletions(-) diff --git a/internal/handler/integration/mocks_test.go b/internal/handler/integration/mocks_test.go index 48dff397e..cc1ca1d2b 100644 --- a/internal/handler/integration/mocks_test.go +++ b/internal/handler/integration/mocks_test.go @@ -14,6 +14,7 @@ import ( reflect "reflect" git "go.abhg.dev/gs/internal/git" + scriptrun "go.abhg.dev/gs/internal/scriptrun" spice "go.abhg.dev/gs/internal/spice" state "go.abhg.dev/gs/internal/spice/state" gomock "go.uber.org/mock/gomock" @@ -120,6 +121,44 @@ func (c *MockGitRepositoryPeelToCommitCall) DoAndReturn(f func(context.Context, return c } +// Worktrees mocks base method. +func (m *MockGitRepository) Worktrees(ctx context.Context) iter.Seq2[*git.WorktreeListItem, error] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Worktrees", ctx) + ret0, _ := ret[0].(iter.Seq2[*git.WorktreeListItem, error]) + return ret0 +} + +// Worktrees indicates an expected call of Worktrees. +func (mr *MockGitRepositoryMockRecorder) Worktrees(ctx any) *MockGitRepositoryWorktreesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Worktrees", reflect.TypeOf((*MockGitRepository)(nil).Worktrees), ctx) + return &MockGitRepositoryWorktreesCall{Call: call} +} + +// MockGitRepositoryWorktreesCall wrap *gomock.Call +type MockGitRepositoryWorktreesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitRepositoryWorktreesCall) Return(arg0 iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitRepositoryWorktreesCall) Do(f func(context.Context) iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitRepositoryWorktreesCall) DoAndReturn(f func(context.Context) iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // MockGitWorktree is a mock of GitWorktree interface. type MockGitWorktree struct { ctrl *gomock.Controller @@ -488,6 +527,44 @@ func (c *MockGitWorktreePushCall) DoAndReturn(f func(context.Context, git.PushOp return c } +// RootDir mocks base method. +func (m *MockGitWorktree) RootDir() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RootDir") + ret0, _ := ret[0].(string) + return ret0 +} + +// RootDir indicates an expected call of RootDir. +func (mr *MockGitWorktreeMockRecorder) RootDir() *MockGitWorktreeRootDirCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RootDir", reflect.TypeOf((*MockGitWorktree)(nil).RootDir)) + return &MockGitWorktreeRootDirCall{Call: call} +} + +// MockGitWorktreeRootDirCall wrap *gomock.Call +type MockGitWorktreeRootDirCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitWorktreeRootDirCall) Return(arg0 string) *MockGitWorktreeRootDirCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitWorktreeRootDirCall) Do(f func() string) *MockGitWorktreeRootDirCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitWorktreeRootDirCall) DoAndReturn(f func() string) *MockGitWorktreeRootDirCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // MockStore is a mock of Store interface. type MockStore struct { ctrl *gomock.Controller @@ -869,10 +946,10 @@ func (m *MockResolver) EXPECT() *MockResolverMockRecorder { } // Resolve mocks base method. -func (m *MockResolver) Resolve(ctx context.Context, req *ResolveRequest) (*ResolveResponse, error) { +func (m *MockResolver) Resolve(ctx context.Context, req *ResolveRequest) (*scriptrun.ResolveResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Resolve", ctx, req) - ret0, _ := ret[0].(*ResolveResponse) + ret0, _ := ret[0].(*scriptrun.ResolveResponse) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -890,19 +967,19 @@ type MockResolverResolveCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockResolverResolveCall) Return(arg0 *ResolveResponse, arg1 error) *MockResolverResolveCall { +func (c *MockResolverResolveCall) Return(arg0 *scriptrun.ResolveResponse, arg1 error) *MockResolverResolveCall { c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockResolverResolveCall) Do(f func(context.Context, *ResolveRequest) (*ResolveResponse, error)) *MockResolverResolveCall { +func (c *MockResolverResolveCall) Do(f func(context.Context, *ResolveRequest) (*scriptrun.ResolveResponse, error)) *MockResolverResolveCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockResolverResolveCall) DoAndReturn(f func(context.Context, *ResolveRequest) (*ResolveResponse, error)) *MockResolverResolveCall { +func (c *MockResolverResolveCall) DoAndReturn(f func(context.Context, *ResolveRequest) (*scriptrun.ResolveResponse, error)) *MockResolverResolveCall { c.Call = c.Call.DoAndReturn(f) return c } From 7090d2104c31cc87da48f9cf82e49a26145f379a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:59:27 +0000 Subject: [PATCH 3/4] [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 72c90d569..f4ce0eb2d 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 3bcfa3969f5a74551ee0023bc607b5212c3553da 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:40:54 +0000 Subject: [PATCH 4/4] [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 f4ce0eb2d..1e733ab1e 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}