Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260602-072030.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: '''restack'': New ''spice.restack.resolver'' / ''spice.restack.autoResolve'' opt-in feature that runs a user-configured script to resolve rebase conflicts during ''gs branch restack'' (and friends).'
time: 2026-06-02T07:20:30.13834-04:00
3 changes: 3 additions & 0 deletions .changes/unreleased/Fixed-20260623-100052.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Fixed
body: 'restack: Warn when --auto-resolve is requested but no resolver script is configured, instead of silently falling back to manual conflict resolution.'
time: 2026-06-23T10:00:52.299802-04:00
14 changes: 12 additions & 2 deletions branch_restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,26 @@ import (
"fmt"

"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/restack"
"go.abhg.dev/gs/internal/text"
)

type branchRestackCmd struct {
Branch string `placeholder:"NAME" help:"Branch to restack" predictor:"trackedBranches"`
AutoResolve bool `name:"auto-resolve" negatable:"" config:"restack.autoResolve" help:"Auto-resolve rebase conflicts using the configured resolver script"`
Branch string `placeholder:"NAME" help:"Branch to restack" predictor:"trackedBranches"`
}

func (*branchRestackCmd) Help() string {
return text.Dedent(`
The current branch will be rebased onto its base,
ensuring a linear history.
Use --branch to target a different branch.

With --auto-resolve (or spice.restack.autoResolve=true),
conflicts encountered during the rebase are passed to the
configured resolver script before the operation is
interrupted. See the restack auto-resolve guide for the
JSON protocol the script must implement.
`)
}

Expand All @@ -32,5 +40,7 @@ func (cmd *branchRestackCmd) AfterApply(ctx context.Context, wt *git.Worktree) e
}

func (cmd *branchRestackCmd) Run(ctx context.Context, handler RestackHandler) error {
return handler.RestackBranch(ctx, cmd.Branch, nil)
return handler.RestackBranch(ctx, cmd.Branch, &restack.Options{
AutoResolve: &cmd.AutoResolve,
})
}
26 changes: 25 additions & 1 deletion doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ of deleted branches, leaving higher branches in place.
### git-spice repo restack {#gs-repo-restack}

```
gs repo (r) restack (r)
gs repo (r) restack (r) [flags]
```

<span class="mdx-badge"><span class="mdx-badge__icon">:material-tag:{ title="Released in version" }</span><span class="mdx-badge__text">[v0.16.0](/changelog.md#v0.16.0)</span></span>
Expand All @@ -181,6 +181,12 @@ Restack all tracked branches
All tracked branches in the repository are rebased on top of their
respective bases in dependency order, ensuring a linear history.

**Flags**

* `--[no-]auto-resolve` ([:material-wrench:{ .middle title="spice.restack.autoResolve" }](/cli/config.md#spicerestackautoresolve)): Auto-resolve rebase conflicts using the configured resolver script

**Configuration**: [spice.restack.autoResolve](/cli/config.md#spicerestackautoresolve)

## Log

### git-spice log short {#gs-log-short}
Expand Down Expand Up @@ -345,8 +351,11 @@ Use --branch to rebase the stack of a different branch.

**Flags**

* `--[no-]auto-resolve` ([:material-wrench:{ .middle title="spice.restack.autoResolve" }](/cli/config.md#spicerestackautoresolve)): Auto-resolve rebase conflicts using the configured resolver script
* `--branch=NAME`: Branch to restack the stack of

**Configuration**: [spice.restack.autoResolve](/cli/config.md#spicerestackautoresolve)

### git-spice stack edit {#gs-stack-edit}

```
Expand Down Expand Up @@ -473,8 +482,11 @@ but still rebase all branches above it.
**Flags**

* `--skip-start`: Do not restack the starting branch
* `--[no-]auto-resolve` ([:material-wrench:{ .middle title="spice.restack.autoResolve" }](/cli/config.md#spicerestackautoresolve)): Auto-resolve rebase conflicts using the configured resolver script
* `--branch=NAME`: Branch to restack the upstack of

**Configuration**: [spice.restack.autoResolve](/cli/config.md#spicerestackautoresolve)

### git-spice upstack onto {#gs-upstack-onto}

```
Expand Down Expand Up @@ -709,8 +721,11 @@ Use --branch to start at a different branch.

**Flags**

* `--[no-]auto-resolve` ([:material-wrench:{ .middle title="spice.restack.autoResolve" }](/cli/config.md#spicerestackautoresolve)): Auto-resolve rebase conflicts using the configured resolver script
* `--branch=NAME`: Branch to restack the downstack of

**Configuration**: [spice.restack.autoResolve](/cli/config.md#spicerestackautoresolve)

## Branch

### git-spice branch track {#gs-branch-track}
Expand Down Expand Up @@ -1052,10 +1067,19 @@ The current branch will be rebased onto its base,
ensuring a linear history.
Use --branch to target a different branch.

With --auto-resolve (or spice.restack.autoResolve=true),
conflicts encountered during the rebase are passed to the
configured resolver script before the operation is
interrupted. See the restack auto-resolve guide for the
JSON protocol the script must implement.

**Flags**

* `--[no-]auto-resolve` ([:material-wrench:{ .middle title="spice.restack.autoResolve" }](/cli/config.md#spicerestackautoresolve)): Auto-resolve rebase conflicts using the configured resolver script
* `--branch=NAME`: Branch to restack

**Configuration**: [spice.restack.autoResolve](/cli/config.md#spicerestackautoresolve)

### git-spice branch onto {#gs-branch-onto}

```
Expand Down
1 change: 1 addition & 0 deletions doc/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ nav:
- guide/branch.md
- guide/cr.md
- guide/scripts.md
- guide/restack-auto-resolve.md
- guide/limits.md
- guide/troubleshooting.md
- guide/internals.md
Expand Down
171 changes: 171 additions & 0 deletions doc/src/guide/restack-auto-resolve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
---
title: Auto-resolving restack conflicts
icon: octicons/zap-16
description: >-
Have a user-configured script resolve rebase conflicts during
gs branch/upstack/downstack/stack/repo restack.
---

# Auto-resolving restack conflicts

<!-- gs:version unreleased -->

Restacking a branch onto its base is a `git rebase` under the hood,
and rebases can conflict. By default, git-spice surfaces those
conflicts to you (the worktree is left mid-rebase, you resolve, then
run $$gs rebase continue$$).

For local, exploratory stacks where the resolution is often
mechanical, you can plug in a **resolver script** that git-spice
invokes when a rebase step conflicts. If the script reports the
conflict as resolved, git-spice stages the files and tells
`git rebase` to continue — without leaving the worktree mid-rebase
and without prompting you.

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 page only documents what is restack-specific.

The feature is **off by default** and opt-in. Nothing changes unless
you set $$spice.restack.resolver$$ and (optionally)
$$spice.restack.autoResolve$$.

## Configuration

Two git-config keys control the feature:

- $$spice.restack.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.restack.autoResolve$$ — when `true`, the resolver runs
automatically on every restack command.

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

Per-invocation overrides are available on every restacking command
($$gs branch restack$$, $$gs upstack restack$$, $$gs downstack
restack$$, $$gs stack restack$$, $$gs repo restack$$):

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

## Restack-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 inside an
in-progress `git rebase`. The conflicted files contain
`<<<<<<<` / `=======` / `>>>>>>>` markers; the script reads them, edits
the files in place, and emits a JSON response. git-spice then stages
the originally-conflicted paths and runs `git rebase --continue`.

`GS_OPERATION` is one of: `branch-restack`, `upstack-restack`,
`downstack-restack`, `stack-restack`, `repo-restack`. `GS_BRANCH` is
the branch whose commits are being replayed; `GS_BASE` is the branch
being replayed onto (also recorded in the resolution file's
`current_merge.theirs` and `current_merge.ours` fields, respectively).

## Resolution file

Restack's persistent Q&A lives at
`<repo-root>/.spice/resolutions/restack.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 restack is reused on subsequent restacks of the same
pair.

## 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`.
Use `--global` so the script applies to every repo you restack 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.restack.resolver "$(cat <<'GITCONFIG'
#!/bin/sh
claude --print --max-turns 30 --output-format text 2>/dev/null <<'PROMPT' \
| sed -n '/<resolution>/,/<\/resolution>/{/<resolution>/d;/<\/resolution>/d;p;}'
You are resolving rebase conflicts during a git-spice restack.

CONTEXT
- 'git ls-files --unmerged' lists the conflicted paths.
- 'git log -p HEAD' shows the commit being rebased ("theirs"); the
branch being rebased onto is the working state ("ours").
- 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/restack.json names the rebase 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.
- DEFAULT TO ADDITIVE MERGES. When each side adds an independent
top-level declaration at the conflict boundary — a method on a
struct, an exported package function, a struct field, an interface
method, a config getter, a type definition, an import, a struct
literal field — that is almost never a real conflict. Keep BOTH
additions, in either order.
- Do NOT run 'git add' or 'git rebase --continue'. After you exit,
git-spice stages every originally-conflicted path and continues
the rebase.

OUTPUT — emit exactly one JSON document between <resolution> and
</resolution> tags. Only content between these tags is parsed.

<resolution>
{"assumptions": [...], "questions": [...], "unresolved_files": [...]}
</resolution>

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.restack.autoResolve true
```

The `<resolution>...</resolution>` marker pattern (paired with the
`sed -n` extraction in the script) discards Claude's prose preface
without changing the JSON parser. If the model omits the markers
entirely, `sed` produces no output and git-spice halts the restack
with a parse error so the underlying problem can be fixed rather
than silently overwritten.

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:*)"
]
}
}
```

If you already run Claude Code with a permissive default — e.g.,
`permissions.defaultMode` set to `auto` or `bypassPermissions` —
you can omit the `allow` list entirely. Restacks are local
operations on tracked git history, so this is a reasonable
trade-off for unattended runs.
8 changes: 6 additions & 2 deletions downstack_restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import (
"fmt"

"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/restack"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
)

type downstackRestackCmd struct {
Branch string `help:"Branch to restack the downstack of" placeholder:"NAME" predictor:"trackedBranches"`
AutoResolve bool `name:"auto-resolve" negatable:"" config:"restack.autoResolve" help:"Auto-resolve rebase conflicts using the configured resolver script"`
Branch string `help:"Branch to restack the downstack of" placeholder:"NAME" predictor:"trackedBranches"`
}

func (*downstackRestackCmd) Help() string {
Expand Down Expand Up @@ -44,5 +46,7 @@ func (cmd *downstackRestackCmd) Run(
return errors.New("nothing to restack below trunk")
}

return handler.RestackDownstack(ctx, cmd.Branch, nil)
return handler.RestackDownstack(ctx, cmd.Branch, &restack.Options{
AutoResolve: &cmd.AutoResolve,
})
}
14 changes: 14 additions & 0 deletions internal/git/files_wt.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,17 @@ func (w *Worktree) ListUntrackedFiles(ctx context.Context) iter.Seq2[string, err
}
}
}

// StageFiles stages the given paths via `git add`. No-op when the
// path list is empty. Used by auto-resolve loops after a resolver
// script has rewritten conflicted files in place.
func (w *Worktree) StageFiles(ctx context.Context, paths []string) error {
if len(paths) == 0 {
return nil
}
addArgs := append([]string{"--"}, paths...)
if err := w.gitCmd(ctx, "add", addArgs...).Run(); err != nil {
return fmt.Errorf("git add: %w", err)
}
return nil
}
Loading
Loading