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}, + }) }