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-20260601-084442.yaml
Original file line number Diff line number Diff line change
@@ -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
45 changes: 29 additions & 16 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 @@ -1447,12 +1453,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}

Expand Down
14 changes: 11 additions & 3 deletions integration_rebuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
`)
}

Expand All @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions internal/git/merge_wt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 60 additions & 1 deletion internal/handler/integration/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
//
Expand Down
78 changes: 78 additions & 0 deletions internal/handler/integration/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading