diff --git a/.changes/unreleased/Added-20260602-072030.yaml b/.changes/unreleased/Added-20260602-072030.yaml
new file mode 100644
index 000000000..c978934c8
--- /dev/null
+++ b/.changes/unreleased/Added-20260602-072030.yaml
@@ -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
diff --git a/.changes/unreleased/Fixed-20260623-100052.yaml b/.changes/unreleased/Fixed-20260623-100052.yaml
new file mode 100644
index 000000000..2d56d67de
--- /dev/null
+++ b/.changes/unreleased/Fixed-20260623-100052.yaml
@@ -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
diff --git a/branch_restack.go b/branch_restack.go
index f84b803b3..9535d215d 100644
--- a/branch_restack.go
+++ b/branch_restack.go
@@ -5,11 +5,13 @@ 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 {
@@ -17,6 +19,12 @@ func (*branchRestackCmd) Help() string {
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.
`)
}
@@ -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,
+ })
}
diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md
index 507c9d1c6..5f2aed545 100644
--- a/doc/includes/cli-reference.md
+++ b/doc/includes/cli-reference.md
@@ -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]
```
:material-tag:{ title="Released in version" }[v0.16.0](/changelog.md#v0.16.0)
@@ -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}
@@ -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}
```
@@ -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}
```
@@ -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}
@@ -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}
```
diff --git a/doc/mkdocs.yml b/doc/mkdocs.yml
index 806738d93..dc408494a 100644
--- a/doc/mkdocs.yml
+++ b/doc/mkdocs.yml
@@ -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
diff --git a/doc/src/guide/restack-auto-resolve.md b/doc/src/guide/restack-auto-resolve.md
new file mode 100644
index 000000000..4be371618
--- /dev/null
+++ b/doc/src/guide/restack-auto-resolve.md
@@ -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
+
+
+
+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
+`/.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>/{//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 and
+ tags. Only content between these tags is parsed.
+
+
+ {"assumptions": [...], "questions": [...], "unresolved_files": [...]}
+
+
+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 `...` 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.
diff --git a/downstack_restack.go b/downstack_restack.go
index 87a273f65..777c712c2 100644
--- a/downstack_restack.go
+++ b/downstack_restack.go
@@ -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 {
@@ -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,
+ })
}
diff --git a/internal/git/files_wt.go b/internal/git/files_wt.go
index 2caa37ab3..7913a24ba 100644
--- a/internal/git/files_wt.go
+++ b/internal/git/files_wt.go
@@ -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
+}
diff --git a/internal/handler/restack/handler.go b/internal/handler/restack/handler.go
index 93d0dadc1..13c21fd2a 100644
--- a/internal/handler/restack/handler.go
+++ b/internal/handler/restack/handler.go
@@ -6,14 +6,18 @@ import (
"context"
"errors"
"fmt"
+ "iter"
"slices"
"go.abhg.dev/gs/internal/cli"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/iterutil"
"go.abhg.dev/gs/internal/must"
+ "go.abhg.dev/gs/internal/scriptrun"
"go.abhg.dev/gs/internal/silog"
+ "go.abhg.dev/gs/internal/sliceutil"
"go.abhg.dev/gs/internal/spice"
+ "go.abhg.dev/gs/internal/spice/spicedir"
"go.abhg.dev/gs/internal/spice/state"
)
@@ -24,6 +28,19 @@ type GitWorktree interface {
CurrentBranch(ctx context.Context) (string, error)
CheckoutBranch(ctx context.Context, branch string) error
RootDir() string
+
+ // RebaseContinue continues the currently in-progress rebase.
+ // Used by the auto-resolve loop after the resolver script has
+ // rewritten the conflicted files.
+ RebaseContinue(ctx context.Context, opts *git.RebaseContinueOptions) error
+
+ // ListFilesPaths enumerates files under the given selection.
+ // Used to gather unmerged paths to pass to the resolver and to
+ // stage afterwards.
+ ListFilesPaths(ctx context.Context, opts *git.ListFilesOptions) iter.Seq2[string, error]
+
+ // StageFiles stages the given paths via `git add`.
+ StageFiles(ctx context.Context, paths []string) error
}
var _ GitWorktree = (*git.Worktree)(nil)
@@ -38,6 +55,7 @@ type Service interface {
BranchGraph(ctx context.Context, opts *spice.BranchGraphOptions) (*spice.BranchGraph, error)
Restack(ctx context.Context, name string) (*spice.RestackResponse, error)
RebaseRescue(ctx context.Context, req spice.RebaseRescueRequest) error
+ LookupBranch(ctx context.Context, name string) (*spice.LookupBranchResponse, error)
}
// Handler implements various restack operations.
@@ -46,6 +64,61 @@ type Handler struct {
Worktree GitWorktree // required
Store Store // required
Service Service // required
+
+ // Resolver is invoked when a rebase conflicts and auto-resolve
+ // is enabled. nil means no resolver is configured; conflicts
+ // surface normally.
+ Resolver Resolver
+
+ // Prompter collects user answers when the resolver returns
+ // questions. nil disables the question-iteration loop (questions
+ // become an immediate error).
+ Prompter QuestionPrompter
+
+ // DefaultAutoResolve sets the default behavior when
+ // [Request.AutoResolve] is nil. Typically populated from
+ // spice.restack.autoResolve.
+ DefaultAutoResolve bool
+
+ // RepoRoot is the directory containing the resolution file.
+ // Required for the auto-resolve loop.
+ RepoRoot string
+
+ // MaxResolveIterations bounds how many times the resolver may be
+ // invoked for a single conflict. Typically populated from
+ // spice.scriptResolve.maxIterations via
+ // Config.ScriptResolveMaxIterations. A non-positive value falls
+ // back to the package default.
+ MaxResolveIterations int
+}
+
+// defaultMaxResolveIterations is the fallback used when Handler.MaxResolveIterations
+// is non-positive. Matches DefaultScriptResolveMaxIterations in
+// internal/spice.
+const defaultMaxResolveIterations = 10
+
+// resolveIterationCap returns the effective per-conflict iteration cap.
+func (h *Handler) resolveIterationCap() int {
+ if h.MaxResolveIterations > 0 {
+ return h.MaxResolveIterations
+ }
+ return defaultMaxResolveIterations
+}
+
+// operationFromContinueCommand maps a Request.ContinueCommand back to
+// the canonical operation name passed to the resolver. The
+// ContinueCommand is always the (subcommand, action) pair used to
+// resume an interrupted operation -- e.g. {"branch","restack"} ->
+// "branch-restack". See doc/src/guide/scripts.md for the full table.
+func operationFromContinueCommand(cmd []string) scriptrun.Operation {
+ if len(cmd) == 0 {
+ return ""
+ }
+ op := cmd[0]
+ for _, part := range cmd[1:] {
+ op = op + "-" + part
+ }
+ return scriptrun.Operation(op)
}
// Scope specifies which branches are affected
@@ -75,6 +148,28 @@ const (
ScopeStack = ScopeUpstack | ScopeDownstack
)
+// Options carries per-invocation overrides that apply to every
+// restack helper (Branch, Stack, Upstack, Downstack). They turn
+// into command-line flags on the restack commands, so additions
+// should consider end-user UX.
+type Options struct {
+ // AutoResolve, if non-nil, overrides
+ // [Handler.DefaultAutoResolve] for this invocation. A true
+ // value enables the configured resolver; a false value disables
+ // it even when configured.
+ AutoResolve *bool
+}
+
+// autoResolvePtr returns the AutoResolve pointer if opts is
+// non-nil, or nil. Callers pass the result through to
+// [Request.AutoResolve].
+func (o *Options) autoResolvePtr() *bool {
+ if o == nil {
+ return nil
+ }
+ return o.AutoResolve
+}
+
// Request is a request to restack one or more branches.
type Request struct {
// Branch is the starting point for the restack operation.
@@ -101,25 +196,13 @@ type Request struct {
AutoResolve *bool
}
-// Options are optional parameters shared by the high-level restack
-// entry points ([Handler.RestackBranch], [Handler.RestackStack],
-// [Handler.RestackDownstack]).
-type Options struct {
- // AutoResolve, if non-nil, overrides
- // [Handler.DefaultAutoResolve] for this invocation. A true
- // value enables the configured resolver; a false value disables
- // it even when configured.
- AutoResolve *bool
-}
-
-// autoResolvePtr returns the AutoResolve pointer if opts is
-// non-nil, or nil. Callers pass the result through to
-// [Request.AutoResolve].
-func (o *Options) autoResolvePtr() *bool {
- if o == nil {
- return nil
+// shouldAutoResolve resolves Request.AutoResolve against
+// Handler.DefaultAutoResolve.
+func (h *Handler) shouldAutoResolve(req *Request) bool {
+ if req != nil && req.AutoResolve != nil {
+ return *req.AutoResolve
}
- return o.AutoResolve
+ return h.DefaultAutoResolve
}
// Restack restacks one or more branches according to the request.
@@ -227,6 +310,28 @@ loop:
var rebaseErr *git.RebaseInterruptError
switch {
case errors.As(err, &rebaseErr):
+ if h.shouldAutoResolve(req) &&
+ rebaseErr.Kind == git.RebaseInterruptConflict {
+ if h.Resolver == nil {
+ // Auto-resolve was requested but no resolver
+ // is configured; warn rather than silently
+ // falling through to manual resolution.
+ h.Log.Warnf("%v: auto-resolve requested but no resolver script configured; "+
+ "set spice.restack.resolver to enable automatic conflict resolution", branch)
+ } else {
+ resolved, baseName := h.tryAutoResolveRebase(ctx, operationFromContinueCommand(req.ContinueCommand), branch, branchGraph)
+ if resolved {
+ h.Log.Infof("%v: restacked on %v", branch, baseName)
+ restackCount++
+ continue loop
+ }
+ // Fall through to RebaseRescue on auto-resolve
+ // failure: the rebase is still mid-flight in
+ // the worktree, the user resolves manually
+ // and runs 'gs rebase continue'.
+ }
+ }
+
// If the rebase is interrupted by a conflict,
// we'll resume by re-running this command.
return 0, h.Service.RebaseRescue(ctx, spice.RebaseRescueRequest{
@@ -263,3 +368,146 @@ loop:
return restackCount, nil
}
+
+// tryAutoResolveRebase drives the resolver loop for an in-progress
+// rebase of branch onto its base. Returns true if the rebase
+// completes cleanly (with the branch name's base for logging).
+//
+// On failure (resolver error, exhausted iterations, missing prompter
+// for questions, unresolved files without questions), returns false
+// and the rebase remains mid-flight in the worktree; the caller
+// surfaces the original conflict via the existing RebaseRescue path
+// so the user can resolve manually.
+func (h *Handler) tryAutoResolveRebase(
+ ctx context.Context, op scriptrun.Operation, branch string, graph *spice.BranchGraph,
+) (resolved bool, baseName string) {
+ info, ok := graph.Lookup(branch)
+ if !ok {
+ h.Log.Warnf("%v: auto-resolve: branch missing from graph", branch)
+ return false, ""
+ }
+ baseName = info.Base
+
+ maxIters := h.resolveIterationCap()
+ for range maxIters {
+ conflictPaths, err := sliceutil.CollectErr(
+ h.Worktree.ListFilesPaths(ctx, &git.ListFilesOptions{Unmerged: true}))
+ if err != nil {
+ h.Log.Warnf("%v: auto-resolve: list unmerged: %v", branch, err)
+ return false, ""
+ }
+ if len(conflictPaths) == 0 {
+ // No conflicts remaining; resume the rebase.
+ if err := h.Worktree.RebaseContinue(ctx, &git.RebaseContinueOptions{Editor: "true"}); err != nil {
+ return h.handleRebaseContinueResult(ctx, branch, err)
+ }
+ return true, baseName
+ }
+
+ resp, err := h.Resolver.Resolve(ctx, &ResolveRequest{
+ Operation: op,
+ Base: baseName,
+ Branch: branch,
+ })
+ if err != nil {
+ h.Log.Warnf("%v: auto-resolve: resolver: %v", branch, err)
+ return false, ""
+ }
+
+ for _, a := range resp.Assumptions {
+ h.Log.Infof("Auto-resolve: %s", a)
+ }
+
+ if len(resp.Questions) > 0 {
+ if h.Prompter == nil {
+ h.Log.Warnf("%v: auto-resolve: resolver returned %d question(s) but no prompter is configured",
+ branch, len(resp.Questions))
+ return false, ""
+ }
+ answers, err := h.Prompter.AskAnswers(ctx, resp.Questions)
+ if err != nil {
+ h.Log.Warnf("%v: auto-resolve: collect answers: %v", branch, err)
+ return false, ""
+ }
+ if err := h.appendQAToFile(baseName, branch, resp.Questions, answers); err != nil {
+ h.Log.Warnf("%v: auto-resolve: append Q&A: %v", branch, err)
+ return false, ""
+ }
+ continue
+ }
+
+ if len(resp.UnresolvedFiles) > 0 {
+ h.Log.Warnf("%v: auto-resolve: resolver reported unresolved files with no questions: %v",
+ branch, resp.UnresolvedFiles)
+ return false, ""
+ }
+
+ // Resolver says everything is resolved. Stage the files
+ // it touched and tell git to continue the rebase.
+ if err := h.Worktree.StageFiles(ctx, conflictPaths); err != nil {
+ h.Log.Warnf("%v: auto-resolve: stage files: %v", branch, err)
+ return false, ""
+ }
+ if err := h.Worktree.RebaseContinue(ctx, &git.RebaseContinueOptions{Editor: "true"}); err != nil {
+ return h.handleRebaseContinueResult(ctx, branch, err)
+ }
+ return true, baseName
+ }
+
+ h.Log.Warnf("%v: auto-resolve: exceeded iteration cap (%d); investigate manually",
+ branch, maxIters)
+ return false, ""
+}
+
+// handleRebaseContinueResult interprets the error from
+// [GitWorktree.RebaseContinue]. A [git.RebaseInterruptError] from
+// the conflict path means a SUBSEQUENT commit in the same rebase
+// also conflicted; we cannot resume from inside this loop in that
+// case (the conflict-paths list and the resolver invocation need to
+// be fresh), so signal failure and let the outer loop continue the
+// resolution via re-entry.
+//
+// In practice the next RebaseRescue path will catch the user; on
+// the next gs restack/gs rebase continue invocation, the auto-
+// resolve loop fires again for the next conflict.
+func (h *Handler) handleRebaseContinueResult(
+ _ context.Context, branch string, err error,
+) (bool, string) {
+ rebaseErr := new(git.RebaseInterruptError)
+ if errors.As(err, &rebaseErr) {
+ h.Log.Infof("%v: auto-resolve: next commit also conflicted; falling through to manual resolution",
+ branch)
+ return false, ""
+ }
+ h.Log.Warnf("%v: auto-resolve: rebase --continue: %v", branch, err)
+ return false, ""
+}
+
+// appendQAToFile appends Q&A pairs to the resolution file entry for
+// the given (base, branch) pair. The .spice/resolutions/ directory is
+// shared across gs auto-resolve features; each feature uses its own
+// file (restack.json, integration.json, etc.).
+func (h *Handler) appendQAToFile(
+ base, branch string, questions, answers []string,
+) error {
+ if err := spicedir.EnsureResolutionsDir(h.RepoRoot); err != nil {
+ return err
+ }
+ path := spicedir.ResolutionPath(h.RepoRoot, ResolutionFeatureName)
+ file, err := LoadResolutionFile(path)
+ if err != nil {
+ return err
+ }
+
+ pair := MergePair{Ours: base, Theirs: branch}
+ qa := make([]scriptrun.QAPair, 0, len(questions))
+ for i, q := range questions {
+ a := ""
+ if i < len(answers) {
+ a = answers[i]
+ }
+ qa = append(qa, scriptrun.QAPair{Question: q, Answer: a})
+ }
+ file.AppendInstructions(pair, qa...)
+ return file.Save(path)
+}
diff --git a/internal/handler/restack/handler_test.go b/internal/handler/restack/handler_test.go
index fbd3440fc..a378908b0 100644
--- a/internal/handler/restack/handler_test.go
+++ b/internal/handler/restack/handler_test.go
@@ -473,6 +473,53 @@ func TestHandler_Restack(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 0, count)
})
+
+ t.Run("AutoResolveNoResolver", func(t *testing.T) {
+ var logBuffer bytes.Buffer
+ log := silog.New(&logBuffer, nil)
+ ctrl := gomock.NewController(t)
+
+ rebaseErr := &git.RebaseInterruptError{
+ Kind: git.RebaseInterruptConflict,
+ }
+
+ mockService := NewMockService(ctrl)
+ mockService.EXPECT().
+ BranchGraph(gomock.Any(), gomock.Any()).
+ Return(newBranchGraphBuilder("main").
+ Branch("feature", "main").
+ Build(t), nil)
+ mockService.EXPECT().
+ Restack(gomock.Any(), "feature").
+ Return(nil, rebaseErr)
+ mockService.EXPECT().
+ RebaseRescue(gomock.Any(), gomock.Any()).
+ Return(nil)
+
+ mockWorktree := NewMockGitWorktree(ctrl)
+ mockWorktree.EXPECT().
+ RootDir().
+ Return(t.TempDir())
+
+ handler := &Handler{
+ Log: log,
+ Worktree: mockWorktree,
+ Store: statetest.NewMemoryStore(t, "main", "", log),
+ Service: mockService,
+ }
+
+ autoResolve := true
+ count, err := handler.Restack(t.Context(), &Request{
+ Branch: "feature",
+ ContinueCommand: []string{"false"},
+ Scope: ScopeBranch,
+ AutoResolve: &autoResolve,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 0, count)
+ assert.Contains(t, logBuffer.String(), "no resolver script configured")
+ assert.Contains(t, logBuffer.String(), "spice.restack.resolver")
+ })
}
func TestHandler_Restack_trunk(t *testing.T) {
diff --git a/internal/handler/restack/mocks_test.go b/internal/handler/restack/mocks_test.go
index 8edc97530..a27eb68c6 100644
--- a/internal/handler/restack/mocks_test.go
+++ b/internal/handler/restack/mocks_test.go
@@ -11,8 +11,10 @@ package restack
import (
context "context"
+ iter "iter"
reflect "reflect"
+ git "go.abhg.dev/gs/internal/git"
spice "go.abhg.dev/gs/internal/spice"
gomock "go.uber.org/mock/gomock"
)
@@ -70,6 +72,34 @@ func (mr *MockGitWorktreeMockRecorder) CurrentBranch(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurrentBranch", reflect.TypeOf((*MockGitWorktree)(nil).CurrentBranch), ctx)
}
+// ListFilesPaths mocks base method.
+func (m *MockGitWorktree) ListFilesPaths(ctx context.Context, opts *git.ListFilesOptions) iter.Seq2[string, error] {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ListFilesPaths", ctx, opts)
+ ret0, _ := ret[0].(iter.Seq2[string, error])
+ return ret0
+}
+
+// ListFilesPaths indicates an expected call of ListFilesPaths.
+func (mr *MockGitWorktreeMockRecorder) ListFilesPaths(ctx, opts any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFilesPaths", reflect.TypeOf((*MockGitWorktree)(nil).ListFilesPaths), ctx, opts)
+}
+
+// RebaseContinue mocks base method.
+func (m *MockGitWorktree) RebaseContinue(ctx context.Context, opts *git.RebaseContinueOptions) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "RebaseContinue", ctx, opts)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// RebaseContinue indicates an expected call of RebaseContinue.
+func (mr *MockGitWorktreeMockRecorder) RebaseContinue(ctx, opts any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RebaseContinue", reflect.TypeOf((*MockGitWorktree)(nil).RebaseContinue), ctx, opts)
+}
+
// RootDir mocks base method.
func (m *MockGitWorktree) RootDir() string {
m.ctrl.T.Helper()
@@ -84,6 +114,20 @@ func (mr *MockGitWorktreeMockRecorder) RootDir() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RootDir", reflect.TypeOf((*MockGitWorktree)(nil).RootDir))
}
+// StageFiles mocks base method.
+func (m *MockGitWorktree) StageFiles(ctx context.Context, paths []string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "StageFiles", ctx, paths)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// StageFiles indicates an expected call of StageFiles.
+func (mr *MockGitWorktreeMockRecorder) StageFiles(ctx, paths any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StageFiles", reflect.TypeOf((*MockGitWorktree)(nil).StageFiles), ctx, paths)
+}
+
// MockService is a mock of Service interface.
type MockService struct {
ctrl *gomock.Controller
@@ -123,6 +167,21 @@ func (mr *MockServiceMockRecorder) BranchGraph(ctx, opts any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BranchGraph", reflect.TypeOf((*MockService)(nil).BranchGraph), ctx, opts)
}
+// LookupBranch mocks base method.
+func (m *MockService) LookupBranch(ctx context.Context, name string) (*spice.LookupBranchResponse, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "LookupBranch", ctx, name)
+ ret0, _ := ret[0].(*spice.LookupBranchResponse)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// LookupBranch indicates an expected call of LookupBranch.
+func (mr *MockServiceMockRecorder) LookupBranch(ctx, name any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LookupBranch", reflect.TypeOf((*MockService)(nil).LookupBranch), ctx, name)
+}
+
// RebaseRescue mocks base method.
func (m *MockService) RebaseRescue(ctx context.Context, req spice.RebaseRescueRequest) error {
m.ctrl.T.Helper()
diff --git a/internal/handler/restack/qa.go b/internal/handler/restack/qa.go
new file mode 100644
index 000000000..d522bc351
--- /dev/null
+++ b/internal/handler/restack/qa.go
@@ -0,0 +1,63 @@
+package restack
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "go.abhg.dev/gs/internal/ui"
+)
+
+// QuestionPrompter collects answers from the user for a list of
+// questions raised by the resolver.
+//
+// Implementations must return one answer per input question, in the
+// same order. If the user aborts, return a non-nil error.
+type QuestionPrompter interface {
+ AskAnswers(ctx context.Context, questions []string) ([]string, error)
+}
+
+// ViewPrompter is a QuestionPrompter backed by a [ui.View].
+//
+// If the view is not interactive (e.g., gs is being piped),
+// AskAnswers returns an error.
+type ViewPrompter struct {
+ View ui.View
+}
+
+var _ QuestionPrompter = (*ViewPrompter)(nil)
+
+// NewViewPrompter wraps view as a QuestionPrompter.
+func NewViewPrompter(view ui.View) *ViewPrompter {
+ return &ViewPrompter{View: view}
+}
+
+// AskAnswers prompts for one answer per question.
+//
+// Questions are presented one at a time so each answer can inform
+// the user's reading of the next prompt (questions may build on
+// each other).
+func (p *ViewPrompter) AskAnswers(
+ _ context.Context, questions []string,
+) ([]string, error) {
+ if len(questions) == 0 {
+ return nil, nil
+ }
+ if !ui.Interactive(p.View) {
+ return nil, errors.New(
+ "cannot prompt for answers: not running in interactive mode")
+ }
+
+ answers := make([]string, len(questions))
+ for i, q := range questions {
+ input := ui.NewInput().
+ WithTitle(fmt.Sprintf("Question %d of %d", i+1, len(questions))).
+ WithDescription(q).
+ WithValue(&answers[i])
+
+ if err := ui.Run(p.View, input); err != nil {
+ return nil, fmt.Errorf("prompt: %w", err)
+ }
+ }
+ return answers, nil
+}
diff --git a/internal/handler/restack/resolution_file.go b/internal/handler/restack/resolution_file.go
new file mode 100644
index 000000000..27abf44f5
--- /dev/null
+++ b/internal/handler/restack/resolution_file.go
@@ -0,0 +1,146 @@
+package restack
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "slices"
+
+ "go.abhg.dev/gs/internal/scriptrun"
+)
+
+// ResolutionFeatureName is the feature key used in the resolution
+// file path: .spice/resolutions/restack.json. Shared with any other
+// gs auto-resolve feature via the [scriptrun.QAPair] schema, so a
+// Q&A the user has answered once can be referenced across features.
+const ResolutionFeatureName = "restack"
+
+// MergePair identifies a single in-progress rebase by branch names.
+//
+// During a rebase, Ours is the base (the branch being rebased onto)
+// and Theirs is the branch whose commits are being replayed — the
+// same naming git itself uses for the 3-way merge that backs each
+// rebase step.
+type MergePair struct {
+ Ours string `json:"ours"`
+ Theirs string `json:"theirs"`
+}
+
+// ResolutionEntry holds the accumulated resolution instructions for
+// a specific (ours, theirs) pair.
+type ResolutionEntry struct {
+ MergingBranches MergePair `json:"merging_branches"`
+ ResolutionInstructions []scriptrun.QAPair `json:"resolution_instructions"`
+}
+
+// ResolutionFile is the on-disk format of the resolution state file.
+//
+// CurrentMerge is a transient pointer set by gs before each resolver
+// invocation. Resolutions is the persistent history.
+type ResolutionFile struct {
+ CurrentMerge *MergePair `json:"current_merge,omitempty"`
+ Resolutions []ResolutionEntry `json:"resolutions"`
+}
+
+// LoadResolutionFile reads the resolution file at path. If the file
+// does not exist, returns an empty ResolutionFile (not an error).
+//
+// Returns an error if the file exists but cannot be read or parsed.
+func LoadResolutionFile(path string) (*ResolutionFile, error) {
+ data, err := os.ReadFile(path)
+ switch {
+ case errors.Is(err, fs.ErrNotExist):
+ return &ResolutionFile{Resolutions: []ResolutionEntry{}}, nil
+ case err != nil:
+ return nil, fmt.Errorf("read %s: %w", path, err)
+ }
+
+ var f ResolutionFile
+ if err := json.Unmarshal(data, &f); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", path, err)
+ }
+ if f.Resolutions == nil {
+ f.Resolutions = []ResolutionEntry{}
+ }
+ return &f, nil
+}
+
+// Save writes the resolution file to path atomically: contents are
+// written to a temporary file in the same directory and then renamed
+// into place.
+func (f *ResolutionFile) Save(path string) error {
+ data, err := json.MarshalIndent(f, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal: %w", err)
+ }
+ data = append(data, '\n')
+
+ dir := filepath.Dir(path)
+ tmp, err := os.CreateTemp(dir, "restack-resolution.*.json.tmp")
+ if err != nil {
+ return fmt.Errorf("create temp: %w", err)
+ }
+ tmpPath := tmp.Name()
+
+ if _, err := tmp.Write(data); err != nil {
+ _ = tmp.Close()
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("write temp: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("close temp: %w", err)
+ }
+
+ if err := os.Rename(tmpPath, path); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("rename: %w", err)
+ }
+ return nil
+}
+
+// EntryFor returns the existing entry matching p, or nil if absent.
+func (f *ResolutionFile) EntryFor(p MergePair) *ResolutionEntry {
+ for i := range f.Resolutions {
+ if f.Resolutions[i].MergingBranches == p {
+ return &f.Resolutions[i]
+ }
+ }
+ return nil
+}
+
+// EnsureEntry returns the entry matching p, creating one with an
+// empty instruction list if it does not already exist.
+func (f *ResolutionFile) EnsureEntry(p MergePair) *ResolutionEntry {
+ if e := f.EntryFor(p); e != nil {
+ return e
+ }
+ f.Resolutions = append(f.Resolutions, ResolutionEntry{
+ MergingBranches: p,
+ ResolutionInstructions: []scriptrun.QAPair{},
+ })
+ return &f.Resolutions[len(f.Resolutions)-1]
+}
+
+// AppendInstructions appends Q&A pairs to the entry matching p. The
+// entry is created if it does not exist.
+func (f *ResolutionFile) AppendInstructions(p MergePair, qa ...scriptrun.QAPair) {
+ e := f.EnsureEntry(p)
+ e.ResolutionInstructions = append(e.ResolutionInstructions, qa...)
+}
+
+// PruneBranch removes every entry where either side of
+// merging_branches equals name. Returns the number of entries
+// removed.
+func (f *ResolutionFile) PruneBranch(name string) int {
+ before := len(f.Resolutions)
+ f.Resolutions = slices.DeleteFunc(f.Resolutions,
+ func(e ResolutionEntry) bool {
+ return e.MergingBranches.Ours == name ||
+ e.MergingBranches.Theirs == name
+ })
+ return before - len(f.Resolutions)
+}
diff --git a/internal/handler/restack/resolver.go b/internal/handler/restack/resolver.go
new file mode 100644
index 000000000..a8ea69e19
--- /dev/null
+++ b/internal/handler/restack/resolver.go
@@ -0,0 +1,167 @@
+package restack
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "go.abhg.dev/gs/internal/scriptrun"
+ "go.abhg.dev/gs/internal/silog"
+ "go.abhg.dev/gs/internal/spice/spicedir"
+)
+
+// Resolver attempts to resolve the in-progress rebase conflict.
+//
+// Successful return indicates the resolver ran to completion with a
+// well-formed response. The shape of the response indicates whether
+// the conflict was actually resolved.
+type Resolver interface {
+ Resolve(ctx context.Context, req *ResolveRequest) (*scriptrun.ResolveResponse, error)
+}
+
+// ResolveRequest describes the conflict that the resolver should
+// attempt to resolve.
+//
+// During a rebase, "ours" is the base (where we are replaying onto)
+// and "theirs" is the branch whose commits are being replayed. The
+// resolution-file entry keyed by (Base, Branch) accumulates Q&A
+// across rebases, so once the user has answered a question for a
+// given pair, the answer is reused on the next restack.
+type ResolveRequest struct {
+ // Operation identifies which gs subcommand drove this resolve
+ // (e.g. branch-restack, stack-restack). Forwarded to the script
+ // as GS_OPERATION via [scriptrun.EnvFor].
+ Operation scriptrun.Operation
+
+ // Base is the branch the rebase is replaying onto (also known
+ // as "ours" during the in-progress rebase).
+ Base string
+
+ // Branch is the branch whose commits are being replayed (also
+ // known as "theirs").
+ Branch string
+}
+
+// ScriptRunner is the subset of [scriptrun.Runner] used by the
+// resolver. It is named so tests can supply a fake.
+type ScriptRunner interface {
+ Run(ctx context.Context, req *scriptrun.RunRequest) (*scriptrun.RunResult, error)
+}
+
+var _ ScriptRunner = (*scriptrun.Runner)(nil)
+
+// ScriptResolver invokes a user-configured shell script and parses
+// the JSON document it produces on stdout.
+type ScriptResolver struct {
+ // Log receives diagnostic messages.
+ Log *silog.Logger
+
+ // Script is the resolver script body.
+ Script string
+
+ // Runner is the [ScriptRunner] used to execute Script.
+ Runner ScriptRunner
+
+ // RepoRoot is the directory containing the resolution file.
+ RepoRoot string
+}
+
+// ScriptResolveError indicates that the script ran but its output did
+// not conform to the expected JSON protocol. The captured stdout and
+// stderr are preserved so callers can surface them to the user.
+type ScriptResolveError struct {
+ // Stage describes what went wrong (e.g., "exit", "parse").
+ Stage string
+
+ // ExitCode is the script's exit code.
+ ExitCode int
+
+ // Stdout is the captured standard output of the script.
+ Stdout []byte
+
+ // Stderr is the captured standard error of the script.
+ Stderr []byte
+
+ // Err is the underlying error, if any.
+ Err error
+}
+
+func (e *ScriptResolveError) Error() string {
+ switch e.Stage {
+ case "exit":
+ return fmt.Sprintf(
+ "resolver exited with code %d", e.ExitCode)
+ case "parse":
+ return fmt.Sprintf(
+ "resolver output is not valid JSON: %v", e.Err)
+ default:
+ return fmt.Sprintf("resolver failed: %v", e.Err)
+ }
+}
+
+func (e *ScriptResolveError) Unwrap() error { return e.Err }
+
+// Resolve writes the current_merge pointer to the resolution file,
+// invokes the script, and parses its stdout as JSON.
+func (r *ScriptResolver) Resolve(
+ ctx context.Context, req *ResolveRequest,
+) (*scriptrun.ResolveResponse, error) {
+ if req == nil {
+ return nil, errors.New("nil resolve request")
+ }
+ if r.Script == "" {
+ return nil, errors.New("no resolver script configured")
+ }
+
+ pair := MergePair{Ours: req.Base, Theirs: req.Branch}
+ if err := r.writeCurrentMerge(pair); err != nil {
+ return nil, fmt.Errorf("update resolution file: %w", err)
+ }
+
+ res, err := r.Runner.Run(ctx, &scriptrun.RunRequest{
+ Script: r.Script,
+ Dir: r.RepoRoot,
+ Env: scriptrun.EnvFor(req.Operation, req.Branch, req.Base),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("run resolver: %w", err)
+ }
+
+ if res.ExitCode != 0 {
+ return nil, &ScriptResolveError{
+ Stage: "exit",
+ ExitCode: res.ExitCode,
+ Stdout: res.Stdout,
+ Stderr: res.Stderr,
+ }
+ }
+
+ resp, err := scriptrun.ParseResponse(res.Stdout)
+ if err != nil {
+ return nil, &ScriptResolveError{
+ Stage: "parse",
+ ExitCode: res.ExitCode,
+ Stdout: res.Stdout,
+ Stderr: res.Stderr,
+ Err: err,
+ }
+ }
+ return resp, nil
+}
+
+// writeCurrentMerge updates the resolution file's current_merge
+// pointer to pair, preserving existing resolutions. Creates the file
+// (and the parent .spice/resolutions directory) if absent.
+func (r *ScriptResolver) writeCurrentMerge(pair MergePair) error {
+ if err := spicedir.EnsureResolutionsDir(r.RepoRoot); err != nil {
+ return err
+ }
+ path := spicedir.ResolutionPath(r.RepoRoot, ResolutionFeatureName)
+ file, err := LoadResolutionFile(path)
+ if err != nil {
+ return err
+ }
+ file.CurrentMerge = &pair
+ file.EnsureEntry(pair)
+ return file.Save(path)
+}
diff --git a/internal/handler/restack/upstack.go b/internal/handler/restack/upstack.go
index 4b03cdad7..11d8b3419 100644
--- a/internal/handler/restack/upstack.go
+++ b/internal/handler/restack/upstack.go
@@ -5,8 +5,12 @@ import (
"context"
)
-// UpstackOptions holds options for restacking the upstack of a branch.
+// UpstackOptions holds options for restacking the upstack of a
+// branch. SkipStart is upstack-specific; the embedded [Options]
+// carry common knobs that apply to every restack command.
type UpstackOptions struct {
+ Options
+
// SkipStart indicates that the starting branch should not be restacked.
SkipStart bool `help:"Do not restack the starting branch"`
}
@@ -19,6 +23,7 @@ func (h *Handler) RestackUpstack(ctx context.Context, branch string, opts *Upsta
Branch: branch,
Scope: ScopeUpstack,
ContinueCommand: []string{"upstack", "restack"},
+ AutoResolve: opts.AutoResolve,
}
if opts.SkipStart {
req.Scope = ScopeUpstackExclusive
diff --git a/internal/spice/config.go b/internal/spice/config.go
index b6d8cc56a..c1bb2b219 100644
--- a/internal/spice/config.go
+++ b/internal/spice/config.go
@@ -331,6 +331,40 @@ func (c *Config) warnDeprecatedAlias(oldKey, newKey git.ConfigKey) {
)
}
+// RestackResolver returns the configured script for auto-resolving
+// rebase conflicts during restack operations, or empty if none is
+// set.
+//
+// Read from spice.restack.resolver.
+func (c *Config) RestackResolver() string {
+ values := c.items[git.ConfigKey(
+ _spiceSection+".restack.resolver",
+ ).Canonical()]
+ if len(values) == 0 {
+ return ""
+ }
+ return values[len(values)-1]
+}
+
+// RestackAutoResolve reports whether the configured resolver
+// should run automatically during restack operations.
+//
+// Read from spice.restack.autoResolve. Defaults to false.
+// Unparseable values are treated as false.
+func (c *Config) RestackAutoResolve() bool {
+ values := c.items[git.ConfigKey(
+ _spiceSection+".restack.autoResolve",
+ ).Canonical()]
+ if len(values) == 0 {
+ return false
+ }
+ v, err := strconv.ParseBool(values[len(values)-1])
+ if err != nil {
+ return false
+ }
+ return v
+}
+
func canonicalConfigKey(k string) git.ConfigKey {
if gitKey, ok := strings.CutPrefix(k, "@"); ok {
return git.ConfigKey(gitKey).Canonical()
diff --git a/main.go b/main.go
index d020ea4cd..3bfde42b8 100644
--- a/main.go
+++ b/main.go
@@ -36,6 +36,7 @@ import (
"go.abhg.dev/gs/internal/handler/submit"
"go.abhg.dev/gs/internal/handler/sync"
"go.abhg.dev/gs/internal/handler/track"
+ "go.abhg.dev/gs/internal/scriptrun"
"go.abhg.dev/gs/internal/secret"
"go.abhg.dev/gs/internal/sigstack"
"go.abhg.dev/gs/internal/silog"
@@ -171,7 +172,7 @@ func main() {
kong.Name(cmdName),
kong.Description("git-spice is a command line tool for stacking Git branches."),
kong.Resolvers(spiceConfig),
- kong.Bind(logger, &forges, &sigStack, &cmd),
+ kong.Bind(logger, &forges, &sigStack, &cmd, spiceConfig),
kong.BindTo(&cmd.Forge, (*forgeOptions)(nil)),
kong.BindTo(ctx, (*context.Context)(nil)),
kong.BindTo(spiceConfig, (*experiment.Enabler)(nil)),
@@ -494,15 +495,35 @@ func (cmd *mainCmd) AfterApply(
}),
kctx.BindSingletonProvider(func(
log *silog.Logger,
+ view ui.View,
worktree *git.Worktree,
store *state.Store,
svc *spice.Service,
+ cfg *spice.Config,
) (RestackHandler, error) {
+ repoRoot := worktree.RootDir()
+ var resolver restack.Resolver
+ if script := cfg.RestackResolver(); script != "" {
+ resolver = &restack.ScriptResolver{
+ Log: log,
+ Script: script,
+ Runner: &scriptrun.Runner{
+ Log: log,
+ Args: os.Args,
+ },
+ RepoRoot: repoRoot,
+ }
+ }
return &restack.Handler{
- Log: log,
- Worktree: worktree,
- Store: store,
- Service: svc,
+ Log: log,
+ Worktree: worktree,
+ Store: store,
+ Service: svc,
+ Resolver: resolver,
+ Prompter: restack.NewViewPrompter(view),
+ DefaultAutoResolve: cfg.RestackAutoResolve(),
+ RepoRoot: repoRoot,
+ MaxResolveIterations: cfg.ScriptResolveMaxIterations(),
}, nil
}),
kctx.BindSingletonProvider(func(
diff --git a/repo_restack.go b/repo_restack.go
index c86e1ba01..61870e50e 100644
--- a/repo_restack.go
+++ b/repo_restack.go
@@ -12,7 +12,9 @@ import (
"go.abhg.dev/gs/internal/text"
)
-type repoRestackCmd struct{}
+type repoRestackCmd struct {
+ AutoResolve bool `name:"auto-resolve" negatable:"" config:"restack.autoResolve" help:"Auto-resolve rebase conflicts using the configured resolver script"`
+}
func (*repoRestackCmd) Help() string {
return text.Dedent(`
@@ -21,7 +23,7 @@ func (*repoRestackCmd) Help() string {
`)
}
-func (*repoRestackCmd) Run(
+func (cmd *repoRestackCmd) Run(
ctx context.Context,
log *silog.Logger,
wt *git.Worktree,
@@ -48,6 +50,7 @@ func (*repoRestackCmd) Run(
Branch: store.Trunk(),
Scope: restack.ScopeUpstackExclusive,
ContinueCommand: []string{"repo", "restack"},
+ AutoResolve: &cmd.AutoResolve,
})
if err != nil {
return err
diff --git a/stack_restack.go b/stack_restack.go
index 7d7b4d689..06cd943ef 100644
--- a/stack_restack.go
+++ b/stack_restack.go
@@ -7,6 +7,7 @@ import (
"go.abhg.dev/gs/internal/cli"
"go.abhg.dev/gs/internal/git"
+ "go.abhg.dev/gs/internal/handler/restack"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
@@ -14,7 +15,8 @@ import (
)
type stackRestackCmd struct {
- Branch string `help:"Branch to restack the stack 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 stack of" placeholder:"NAME" predictor:"trackedBranches"`
}
func (*stackRestackCmd) Help() string {
@@ -88,5 +90,7 @@ func (cmd *stackRestackCmd) Run(
return err
}
- return handler.RestackStack(ctx, cmd.Branch, nil)
+ return handler.RestackStack(ctx, cmd.Branch, &restack.Options{
+ AutoResolve: &cmd.AutoResolve,
+ })
}
diff --git a/testdata/help/branch_restack.txt b/testdata/help/branch_restack.txt
index 2a9a3b393..2400ca50a 100644
--- a/testdata/help/branch_restack.txt
+++ b/testdata/help/branch_restack.txt
@@ -5,8 +5,15 @@ Restack a branch
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:
- --branch=NAME Branch to restack
+ --[no-]auto-resolve Auto-resolve rebase conflicts using the configured
+ resolver script (🔧 spice.restack.autoResolve)
+ --branch=NAME Branch to restack
Global Flags:
-h, --help Show help for the command
diff --git a/testdata/help/downstack_restack.txt b/testdata/help/downstack_restack.txt
index 0cd3860bb..f1bdcf42f 100644
--- a/testdata/help/downstack_restack.txt
+++ b/testdata/help/downstack_restack.txt
@@ -8,7 +8,9 @@ their respective bases, ensuring a linear history.
Use --branch to start at a different branch.
Flags:
- --branch=NAME Branch to restack the downstack of
+ --[no-]auto-resolve Auto-resolve rebase conflicts using the configured
+ resolver script (🔧 spice.restack.autoResolve)
+ --branch=NAME Branch to restack the downstack of
Global Flags:
-h, --help Show help for the command
diff --git a/testdata/help/repo_restack.txt b/testdata/help/repo_restack.txt
index 02bb09603..2a8429a00 100644
--- a/testdata/help/repo_restack.txt
+++ b/testdata/help/repo_restack.txt
@@ -1,10 +1,14 @@
-Usage: gs repo (r) restack (r)
+Usage: gs repo (r) restack (r) [flags]
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 Auto-resolve rebase conflicts using the configured
+ resolver script (🔧 spice.restack.autoResolve)
+
Global Flags:
-h, --help Show help for the command
--version Print version information and quit
diff --git a/testdata/help/stack_restack.txt b/testdata/help/stack_restack.txt
index 5b7b376d5..3e44258a5 100644
--- a/testdata/help/stack_restack.txt
+++ b/testdata/help/stack_restack.txt
@@ -8,7 +8,9 @@ ensuring a linear history.
Use --branch to rebase the stack of a different branch.
Flags:
- --branch=NAME Branch to restack the stack of
+ --[no-]auto-resolve Auto-resolve rebase conflicts using the configured
+ resolver script (🔧 spice.restack.autoResolve)
+ --branch=NAME Branch to restack the stack of
Global Flags:
-h, --help Show help for the command
diff --git a/testdata/help/upstack_restack.txt b/testdata/help/upstack_restack.txt
index 4ee4a6d73..e12bb3ce0 100644
--- a/testdata/help/upstack_restack.txt
+++ b/testdata/help/upstack_restack.txt
@@ -11,8 +11,10 @@ Use --skip-start to skip the starting branch, but still rebase all branches
above it.
Flags:
- --skip-start Do not restack the starting branch
- --branch=NAME Branch to restack the upstack of
+ --skip-start Do not restack the starting branch
+ --[no-]auto-resolve Auto-resolve rebase conflicts using the configured
+ resolver script (🔧 spice.restack.autoResolve)
+ --branch=NAME Branch to restack the upstack of
Global Flags:
-h, --help Show help for the command
diff --git a/testdata/script/restack_auto_resolve_assumptions.txt b/testdata/script/restack_auto_resolve_assumptions.txt
new file mode 100644
index 000000000..be6bd0f7d
--- /dev/null
+++ b/testdata/script/restack_auto_resolve_assumptions.txt
@@ -0,0 +1,50 @@
+# Auto-resolve script returns assumptions; they are logged at info
+# level and the rebase completes.
+
+as 'Test '
+at '2025-01-01T00: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 main-update-version.txt shared.txt
+git add shared.txt
+git commit -m 'main updates shared.txt'
+
+chmod 700 $WORK/resolver.sh
+git config spice.restack.resolver $WORK/resolver.sh
+git config spice.restack.autoResolve true
+
+gs bco feat-a
+gs branch restack
+stderr 'Auto-resolve: kept main updates because they touched the same line'
+stderr 'Auto-resolve: applied feat-a delta on top of the resolved content'
+stderr 'restacked on main'
+
+-- repo/shared.txt --
+base content
+-- repo/feat-a-version.txt --
+feat-a content
+-- repo/main-update-version.txt --
+main updated content
+-- repo/resolved.txt --
+resolved content
+-- resolver.sh --
+#!/bin/sh
+cp resolved.txt shared.txt
+cat <<'EOF'
+{
+ "assumptions": [
+ "kept main updates because they touched the same line",
+ "applied feat-a delta on top of the resolved content"
+ ]
+}
+EOF
diff --git a/testdata/script/restack_auto_resolve_disabled.txt b/testdata/script/restack_auto_resolve_disabled.txt
new file mode 100644
index 000000000..fed7530bc
--- /dev/null
+++ b/testdata/script/restack_auto_resolve_disabled.txt
@@ -0,0 +1,46 @@
+# When --no-auto-resolve overrides the configured default, the
+# rebase conflict surfaces normally (RebaseRescue path) even though
+# spice.restack.autoResolve=true.
+
+as 'Test '
+at '2025-01-01T00: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 main-update-version.txt shared.txt
+git add shared.txt
+git commit -m 'main updates shared.txt'
+
+chmod 700 $WORK/resolver.sh
+git config spice.restack.resolver $WORK/resolver.sh
+git config spice.restack.autoResolve true
+
+gs bco feat-a
+! gs branch restack --no-auto-resolve
+stderr 'gs rebase continue'
+
+# Worktree should be left in conflict state so the user can resolve
+# by hand.
+stderr 'conflict'
+
+-- repo/shared.txt --
+base content
+-- repo/feat-a-version.txt --
+feat-a content
+-- repo/main-update-version.txt --
+main updated content
+-- golden/resolved.txt --
+resolved content
+-- resolver.sh --
+#!/bin/sh
+echo 'resolver should not have run' >&2
+exit 99
diff --git a/testdata/script/restack_auto_resolve_env.txt b/testdata/script/restack_auto_resolve_env.txt
new file mode 100644
index 000000000..dd82c682f
--- /dev/null
+++ b/testdata/script/restack_auto_resolve_env.txt
@@ -0,0 +1,55 @@
+# The shared env contract (GS_OPERATION, GS_BRANCH, GS_BASE) is set
+# for the resolver. Verified by having the resolver echo the values
+# back as assumptions.
+
+as 'Test '
+at '2025-01-01T00: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 main-update-version.txt shared.txt
+git add shared.txt
+git commit -m 'main updates shared.txt'
+
+chmod 700 $WORK/resolver.sh
+git config spice.restack.resolver $WORK/resolver.sh
+git config spice.restack.autoResolve true
+
+gs bco feat-a
+gs branch restack
+stderr 'Auto-resolve: GS_OPERATION=branch-restack'
+stderr 'Auto-resolve: GS_BRANCH=feat-a'
+stderr 'Auto-resolve: GS_BASE=main'
+
+-- repo/shared.txt --
+base content
+-- repo/feat-a-version.txt --
+feat-a content
+-- repo/main-update-version.txt --
+main updated content
+-- repo/resolved.txt --
+resolved content
+-- resolver.sh --
+#!/bin/sh
+cp resolved.txt shared.txt
+op="${GS_OPERATION:-}"
+br="${GS_BRANCH:-}"
+ba="${GS_BASE:-}"
+cat <'
+at '2025-01-01T00: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 main-update-version.txt shared.txt
+git add shared.txt
+git commit -m 'main updates shared.txt'
+
+chmod 700 $WORK/resolver.sh
+git config spice.restack.resolver $WORK/resolver.sh
+git config spice.restack.autoResolve true
+
+gs bco feat-a
+! gs branch restack
+stderr 'auto-resolve: resolver'
+stderr 'gs rebase continue'
+stderr 'conflict'
+
+-- repo/shared.txt --
+base content
+-- repo/feat-a-version.txt --
+feat-a content
+-- repo/main-update-version.txt --
+main updated content
+-- resolver.sh --
+#!/bin/sh
+echo 'this is not valid json'
diff --git a/testdata/script/restack_auto_resolve_iteration_cap.txt b/testdata/script/restack_auto_resolve_iteration_cap.txt
new file mode 100644
index 000000000..67865b64b
--- /dev/null
+++ b/testdata/script/restack_auto_resolve_iteration_cap.txt
@@ -0,0 +1,55 @@
+# spice.scriptResolve.maxIterations bounds the Q&A loop. A resolver
+# that always returns a question hits the configured cap and the
+# conflict is surfaced via RebaseRescue.
+
+as 'Test '
+at '2025-01-01T00: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 main-update-version.txt shared.txt
+git add shared.txt
+git commit -m 'main updates shared.txt'
+
+chmod 700 $WORK/resolver.sh
+git config spice.restack.resolver $WORK/resolver.sh
+git config spice.restack.autoResolve true
+git config spice.scriptResolve.maxIterations 3
+
+env ROBOT_INPUT=$WORK/robot.golden ROBOT_OUTPUT=$WORK/robot.actual
+
+gs bco feat-a
+! gs branch restack
+stderr 'exceeded iteration cap \(3\)'
+stderr 'gs rebase continue'
+
+-- repo/shared.txt --
+base content
+-- repo/feat-a-version.txt --
+feat-a content
+-- repo/main-update-version.txt --
+main updated content
+-- robot.golden --
+===
+> Auto-resolve question 1: Which side wins?
+"left"
+===
+> Auto-resolve question 1: Which side wins?
+"left"
+===
+> Auto-resolve question 1: Which side wins?
+"left"
+-- resolver.sh --
+#!/bin/sh
+cat <<'EOF'
+{"questions": ["Which side wins?"]}
+EOF
diff --git a/testdata/script/restack_auto_resolve_persists_across_restacks.txt b/testdata/script/restack_auto_resolve_persists_across_restacks.txt
new file mode 100644
index 000000000..fcf08d8ef
--- /dev/null
+++ b/testdata/script/restack_auto_resolve_persists_across_restacks.txt
@@ -0,0 +1,72 @@
+# Resolution Q&A persists in .spice/resolutions/restack.json across
+# successive restacks of the same branch. A resolver that escalates
+# only on its first run is allowed; subsequent runs read the saved
+# answer and proceed without re-prompting.
+
+as 'Test '
+at '2025-01-01T00: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 main-update-version.txt shared.txt
+git add shared.txt
+git commit -m 'main updates shared.txt'
+
+chmod 700 $WORK/resolver.sh
+git config spice.restack.resolver $WORK/resolver.sh
+git config spice.restack.autoResolve true
+
+env ROBOT_INPUT=$WORK/robot.golden ROBOT_OUTPUT=$WORK/robot.actual
+
+gs bco feat-a
+gs branch restack
+stderr 'restacked on main'
+exists $WORK/repo/.spice/resolutions/restack.json
+
+# Second restack of the same conflicting pair: drive main forward one
+# more time so the rebase conflicts again. The resolver script now
+# checks the resolution file for an existing answer and short-circuits.
+git checkout main
+cp main-update2.txt shared.txt
+git add shared.txt
+git commit -m 'main updates shared.txt again'
+
+gs bco feat-a
+gs branch restack
+stderr 'restacked on main'
+
+# A single answer was recorded; the second restack did not re-prompt.
+grep -count=1 '"answer": "left"' $WORK/repo/.spice/resolutions/restack.json
+
+-- repo/shared.txt --
+base content
+-- repo/feat-a-version.txt --
+feat-a content
+-- repo/main-update-version.txt --
+main updated content
+-- repo/main-update2.txt --
+main updated content v2
+-- repo/resolved.txt --
+resolved content
+-- robot.golden --
+===
+> Auto-resolve question 1: Which side wins?
+"left"
+-- resolver.sh --
+#!/bin/sh
+RES=".spice/resolutions/restack.json"
+if [ -f "$RES" ] && grep -q '"answer": "left"' "$RES"; then
+ cp resolved.txt shared.txt
+ echo '{"assumptions": ["used saved answer"]}'
+else
+ echo '{"questions": ["Which side wins?"]}'
+fi
diff --git a/testdata/script/restack_auto_resolve_success.txt b/testdata/script/restack_auto_resolve_success.txt
new file mode 100644
index 000000000..c654d764b
--- /dev/null
+++ b/testdata/script/restack_auto_resolve_success.txt
@@ -0,0 +1,62 @@
+# Auto-resolve completes a single-conflict rebase in one iteration
+# when the resolver script writes the resolved file and emits an
+# empty JSON response.
+
+as 'Test '
+at '2025-01-01T00:00:00Z'
+
+cd repo
+git init
+git add shared.txt
+git commit -m 'Initial commit'
+gs repo init
+
+# Create feat-a on top of main with its version of shared.txt.
+cp feat-a-version.txt shared.txt
+git add shared.txt
+gs bc feat-a -m 'feat-a changes shared.txt'
+
+# Move main forward with a conflicting change to the same line, so
+# rebasing feat-a onto main will conflict.
+git checkout main
+cp main-update-version.txt shared.txt
+git add shared.txt
+git commit -m 'main updates shared.txt'
+
+# Configure the resolver and turn auto-resolve on. The resolver is a
+# shebang script in the WORK dir that copies the canonical resolved
+# content over the conflicted file and emits '{}' on stdout to
+# signal "everything resolved".
+chmod 700 $WORK/resolver.sh
+git config spice.restack.resolver $WORK/resolver.sh
+git config spice.restack.autoResolve true
+
+# The branch's recorded base hash is now stale relative to main, so
+# 'branch restack' rebases feat-a onto the new main HEAD. With
+# auto-resolve on, the rebase conflict is intercepted, the resolver
+# fixes shared.txt, and the rebase resumes.
+gs bco feat-a
+gs branch restack
+stderr 'restacked on main'
+
+# feat-a now has the canonical resolved content on top of the new
+# main HEAD.
+git show feat-a:shared.txt
+cmp stdout $WORK/golden/resolved.txt
+
+# Resolution file written under .spice/resolutions/.
+exists $WORK/repo/.spice/resolutions/restack.json
+! exists $WORK/repo/.spice_resolution.json
+
+-- repo/shared.txt --
+base content
+-- repo/feat-a-version.txt --
+feat-a content
+-- repo/main-update-version.txt --
+main updated content
+-- golden/resolved.txt --
+resolved content
+-- resolver.sh --
+#!/bin/sh
+cp "$WORK/golden/resolved.txt" shared.txt
+echo '{}'
diff --git a/testdata/script/restack_auto_resolve_unresolved_no_questions.txt b/testdata/script/restack_auto_resolve_unresolved_no_questions.txt
new file mode 100644
index 000000000..01f2c9186
--- /dev/null
+++ b/testdata/script/restack_auto_resolve_unresolved_no_questions.txt
@@ -0,0 +1,43 @@
+# When the resolver reports unresolved files but no questions, the
+# loop cannot make progress: a warn message names the unresolved files
+# and the conflict is surfaced via RebaseRescue for manual handling.
+
+as 'Test '
+at '2025-01-01T00: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 main-update-version.txt shared.txt
+git add shared.txt
+git commit -m 'main updates shared.txt'
+
+chmod 700 $WORK/resolver.sh
+git config spice.restack.resolver $WORK/resolver.sh
+git config spice.restack.autoResolve true
+
+gs bco feat-a
+! gs branch restack
+stderr 'auto-resolve: resolver reported unresolved files with no questions'
+stderr 'shared.txt'
+stderr 'gs rebase continue'
+
+-- repo/shared.txt --
+base content
+-- repo/feat-a-version.txt --
+feat-a content
+-- repo/main-update-version.txt --
+main updated content
+-- resolver.sh --
+#!/bin/sh
+cat <<'EOF'
+{"unresolved_files": ["shared.txt"]}
+EOF
diff --git a/upstack_restack.go b/upstack_restack.go
index 4def1ada7..f47f9648d 100644
--- a/upstack_restack.go
+++ b/upstack_restack.go
@@ -13,9 +13,9 @@ import (
)
type upstackRestackCmd struct {
- restack.UpstackOptions
-
- Branch string `help:"Branch to restack the upstack of" placeholder:"NAME" predictor:"trackedBranches"`
+ SkipStart bool `help:"Do not restack the starting branch"`
+ 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 upstack of" placeholder:"NAME" predictor:"trackedBranches"`
}
func (*upstackRestackCmd) Help() string {
@@ -62,5 +62,8 @@ func (cmd *upstackRestackCmd) Run(
return err
}
- return handler.RestackUpstack(ctx, cmd.Branch, &cmd.UpstackOptions)
+ return handler.RestackUpstack(ctx, cmd.Branch, &restack.UpstackOptions{
+ SkipStart: cmd.SkipStart,
+ Options: restack.Options{AutoResolve: &cmd.AutoResolve},
+ })
}