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-20260521-102440.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'integration: Add AI-driven conflict resolution via JSON protocol; configured by spice.integration.resolver and spice.integration.autoResolve'
time: 2026-05-21T10:24:40.113598-04:00
16 changes: 14 additions & 2 deletions branch_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/delete"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
Expand Down Expand Up @@ -84,11 +85,22 @@ type DeleteHandler interface {

func (cmd *branchDeleteCmd) Run(
ctx context.Context,
log *silog.Logger,
handler DeleteHandler,
integrationHandler IntegrationHandler,
) error {
return handler.DeleteBranches(ctx, &delete.Request{
if err := handler.DeleteBranches(ctx, &delete.Request{
Branches: cmd.Branches,
Force: cmd.Force,
Restack: cmd.Restack,
})
}); err != nil {
return err
}

for _, b := range cmd.Branches {
if err := integrationHandler.OnBranchRemoved(ctx, b); err != nil {
log.Warnf("prune integration resolution file: %v", err)
}
}
return nil
}
7 changes: 7 additions & 0 deletions branch_untrack.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"

"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
Expand All @@ -29,8 +30,10 @@ func (*branchUntrackCmd) Help() string {

func (cmd *branchUntrackCmd) Run(
ctx context.Context,
log *silog.Logger,
wt *git.Worktree,
svc *spice.Service,
integrationHandler IntegrationHandler,
) error {
if cmd.Branch == "" {
var err error
Expand All @@ -48,5 +51,9 @@ func (cmd *branchUntrackCmd) Run(
return fmt.Errorf("forget branch: %w", err)
}

if err := integrationHandler.OnBranchRemoved(ctx, cmd.Branch); err != nil {
log.Warnf("prune integration resolution file: %v", err)
}

return nil
}
44 changes: 29 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 Expand Up @@ -1442,9 +1448,17 @@ On conflict, the merge is left in the worktree. Resolve the
conflicting files, commit with 'git merge --continue', then
re-run 'gs integration rebuild' (or 'gs intrb') to resume.

With --auto-resolve (or spice.integration.autoResolve=true), a
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.

**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

**Configuration**: [spice.integration.autoResolve](/cli/config.md#spiceintegrationautoresolve)

### git-spice integration submit {#gs-integration-submit}

Expand Down
149 changes: 149 additions & 0 deletions doc/src/guide/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,155 @@ subsequent `gs stack submit`, `gs upstack submit`, and
`gs downstack submit` invocations will keep the published branch in
sync by pushing the latest rebuild.

## Auto-resolving conflicts

<!-- gs:version unreleased -->

Even with rerere and the `regenerate` merge driver handling the
mechanical conflicts, rebuilds can still surface hand-resolution
conflicts when two tips touch the same file. For local-only integration
branches, you can configure a script that resolves these conflicts
automatically.

The protocol the script speaks (JSON output schema, environment
contract, persistent resolution file, iteration cap, fall-through
rules) is the same across all of git-spice's script-driven features.
See **[Script integrations](scripts.md)** for the complete contract.
This section only documents what is integration-specific.

### Configuration

Two git-config keys control the feature:

- $$spice.integration.resolver$$ — the resolver script body. The value
is the script itself (not a path); git-spice runs it via `sh -c`,
or executes it directly if it starts with `#!`. Typically set at
`--global` scope since the resolver is a personal preference.
- $$spice.integration.autoResolve$$ — when `true`, the resolver runs
automatically on every $$gs integration rebuild$$.

The shared $$spice.scriptResolve.maxIterations$$ key bounds the Q&A
loop. See [Script integrations](scripts.md#iteration-cap).

Per-invocation overrides are available on $$gs integration rebuild$$:

- `--auto-resolve` enables auto-resolve for this invocation.
- `--no-auto-resolve` disables it, even when the config has it on.

### Integration-specific environment

In addition to the shared
[`GS_OPERATION` / `GS_BRANCH` / `GS_BASE`](scripts.md#environment-contract)
values, the resolver runs from the repository root with an in-progress
`git merge`. `GS_OPERATION` is always `integration-rebuild`. `GS_BRANCH`
is the tip being merged in; `GS_BASE` is the integration branch (also
recorded in the resolution file's `current_merge.theirs` and
`current_merge.ours` fields, respectively).

### Resolution file

Integration's persistent Q&A lives at
`<repo-root>/.spice/resolutions/integration.json`. The schema is
described in
[Script integrations: persistent resolution files](scripts.md#persistent-resolution-files).
Each entry is keyed by the `(ours, theirs)` branch pair, so an answer
recorded on one rebuild is reused on subsequent rebuilds.

Entries are pruned automatically when their branches are untracked
($$gs branch untrack$$), deleted ($$gs branch delete$$), or removed
by $$gs repo sync$$ after the underlying CR merges.

### Example: Claude Code resolver

The resolver is configured as a user-level preference — the config
value is the script body itself, run by git-spice via `sh -c`,
the same shape as $$spice.messageGenerator$$. Use `--global` so the
script applies to every repo you run integration rebuilds in.

Paste the block below into a terminal to install a resolver that
delegates to [Claude Code](https://docs.claude.com/en/docs/claude-code)
and turn auto-resolve on by default:

```bash
git config --global spice.integration.resolver "$(cat <<'GITCONFIG'
#!/bin/sh
exec claude --print --max-turns 30 <<'PROMPT'
You are resolving merge conflicts on a throwaway integration branch.

CONTEXT
- 'git ls-files --unmerged' lists the conflicted paths.
- 'git log -p ours' and 'git log -p theirs' show the commits on each
side; 'git diff ours theirs -- <path>' compares them on one file.
- GS_OPERATION, GS_BRANCH, and GS_BASE are set in the environment;
see https://abhinav.github.io/git-spice/guide/scripts/ for the
full env contract.
- .spice/resolutions/integration.json names the merge in progress
(current_merge: ours = GS_BASE, theirs = GS_BRANCH) and carries
any prior Q&A you have recorded under resolutions. Honor that
prior guidance when it applies.

WHAT TO DO
- Edit each conflicted file in place. Remove the <<<<<<<, =======,
and >>>>>>> markers and produce a syntactically valid merged file.
- Do NOT run 'git add' or 'git commit'. After you exit, git-spice
stages every originally-conflicted path and commits the merge.

OUTPUT — emit exactly one JSON document on stdout, then exit:

{"assumptions": [...], "questions": [...], "unresolved_files": [...]}

- All three keys are optional. Empty (or assumptions-only) means
"everything resolved cleanly"; git-spice will stage and commit.
- "assumptions" — short notes on judgement calls you made. They are
surfaced in the rebuild log so a human can spot-check them.
See https://abhinav.github.io/git-spice/guide/scripts/ for what each
field means and when to use it.
PROMPT
GITCONFIG
)"

git config --global spice.integration.autoResolve true
```

For Claude Code to run unattended, pre-approve the tools it needs in
`~/.claude/settings.json`. The schema is a `permissions` object with
an `allow` array of tool patterns
([reference](https://docs.claude.com/en/docs/claude-code/settings#permissions)):

```json
{
"permissions": {
"allow": [
"Read",
"Edit",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git diff:*)",
"Bash(git ls-files:*)"
]
}
}
```

Bare names like `Read` and `Edit` allow the tool on any path; scope
them with `Read(/repo/path/**)` if you'd rather only let the resolver
read inside the integration repo.

If you already run Claude Code with a permissive default — e.g.,
`permissions.defaultMode` set to `auto` (skip routine prompts) or
`bypassPermissions` (skip all prompts) — you can omit the `allow`
list entirely. The integration branch is throwaway, so this is a
reasonable trade-off for unattended rebuilds. See the upstream
[settings reference](https://docs.claude.com/en/docs/claude-code/settings#permissions)
for what each mode covers.

If a resolution turns out to be wrong, the integration branch is
throwaway: investigate the diff, update your prompt or hand-edit
`resolution_instructions` in `.spice/resolutions/integration.json`,
then run $$gs integration rebuild$$ again. If `rerere` has recorded
an incorrect resolution from an earlier run, `git rerere clear` wipes
the cache.

## Removing the configuration

Use $$gs integration delete$$ to remove the configuration. The
Expand Down
3 changes: 2 additions & 1 deletion integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ type IntegrationHandler interface {
RemoveTip(ctx context.Context, branch string) error
Show(ctx context.Context) (*integration.Status, error)
Checkout(ctx context.Context) error
Rebuild(ctx context.Context) (*integration.RebuildResult, error)
Rebuild(ctx context.Context, opts *integration.RebuildOptions) (*integration.RebuildResult, error)
Submit(ctx context.Context) error
MarkPushed(ctx context.Context, hash git.Hash) error
MaybeRebuild(ctx context.Context) error
MaybeRebuildAndSubmit(ctx context.Context) error
OnBranchRemoved(ctx context.Context, branch string) error
}

var _ IntegrationHandler = (*integration.Handler)(nil)
12 changes: 10 additions & 2 deletions integration_rebuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import (
)

type integrationRebuildCmd struct {
Push bool `name:"push" help:"Also push the integration branch after rebuilding"`
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"`
}

func (*integrationRebuildCmd) Help() string {
Expand All @@ -25,6 +26,11 @@ func (*integrationRebuildCmd) Help() string {
On conflict, the merge is left in the worktree. Resolve the
conflicting files, commit with 'git merge --continue', then
re-run 'gs integration rebuild' (or 'gs intrb') to resume.

With --auto-resolve (or spice.integration.autoResolve=true), a
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.
`)
}

Expand All @@ -33,7 +39,9 @@ func (cmd *integrationRebuildCmd) Run(
log *silog.Logger,
handler IntegrationHandler,
) error {
res, err := handler.Rebuild(ctx)
res, err := handler.Rebuild(ctx, &integration.RebuildOptions{
AutoResolve: &cmd.AutoResolve,
})
if err != nil {
conflict := new(integration.ConflictError)
if errors.As(err, &conflict) {
Expand Down
Loading
Loading