From 0c5e44bb5d4c6459d24c56196e9c698decc3dfb8 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 20 Feb 2026 16:01:26 -0800 Subject: [PATCH 1/3] Add `rebase` command with AI-assisted conflict resolution --- src/cli.ts | 9 ++++++ src/commands/rebase.ts | 63 ++++++++++++++++++++++++++++++++++++++++++ src/git.ts | 29 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 src/commands/rebase.ts diff --git a/src/cli.ts b/src/cli.ts index d23a44a..7d75527 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { action as reviewAction } from "./commands/review.ts" import { action as shellAction } from "./commands/shell.ts" import { action as closeAction } from "./commands/close.ts" import { action as mergeAction } from "./commands/merge.ts" +import { action as rebaseAction } from "./commands/rebase.ts" import { action as saveAction } from "./commands/save.ts" import { action as diffAction } from "./commands/diff.ts" import { action as showAction } from "./commands/show.ts" @@ -95,6 +96,14 @@ program .description("Merge a branch into main and close the session") .action(mergeAction) +// ── sandlot rebase ─────────────────────────────────────────────────── + +program + .command("rebase") + .argument("", "branch name") + .description("Rebase a branch onto the latest main") + .action(rebaseAction) + // ── sandlot save ───────────────────────────────────────────────────── program diff --git a/src/commands/rebase.ts b/src/commands/rebase.ts new file mode 100644 index 0000000..dac9385 --- /dev/null +++ b/src/commands/rebase.ts @@ -0,0 +1,63 @@ +import { join } from "path" +import { $ } from "bun" +import * as git from "../git.ts" +import * as vm from "../vm.ts" +import { spinner } from "../spinner.ts" +import { die } from "../fmt.ts" +import { requireSession } from "./helpers.ts" + +export async function action(branch: string) { + const { root, session } = await requireSession(branch) + const worktree = session.worktree + + if (await git.isDirty(worktree)) { + die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`) + } + + const main = await git.mainBranch(root) + const spin = spinner("Fetching origin", branch) + + try { + await $`git -C ${root} fetch origin ${main}`.nothrow().quiet() + spin.text = `Rebasing onto origin/${main}` + + let conflicts = await git.rebase(`origin/${main}`, worktree) + if (conflicts.length === 0) { + spin.succeed(`Rebased ${branch} onto ${main}`) + return + } + + // Resolve conflicts with Claude, looping for each rebased commit + console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Claude...`) + await vm.ensure((msg) => { spin.text = msg }) + + let round = 1 + while (conflicts.length > 0) { + for (const file of conflicts) { + spin.text = `Resolving ${file} (round ${round})` + const content = await Bun.file(join(worktree, file)).text() + + const resolved = await vm.claudePipe( + content, + "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.", + ) + + if (resolved.exitCode !== 0) { + throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`) + } + + await Bun.write(join(worktree, file), resolved.stdout + "\n") + await git.stageFile(file, worktree) + } + + conflicts = await git.rebaseContinue(worktree) + round++ + } + + spin.succeed(`Rebased ${branch} onto ${main} (resolved ${round - 1} conflict round(s))`) + } catch (err) { + spin.fail(String((err as Error).message ?? err)) + await git.rebaseAbort(worktree) + process.exit(1) + } +} diff --git a/src/git.ts b/src/git.ts index f7db35a..eb609b4 100644 --- a/src/git.ts +++ b/src/git.ts @@ -119,6 +119,35 @@ export async function abortMerge(cwd: string): Promise { await $`git merge --abort`.cwd(cwd).nothrow().quiet() } +/** Rebase the current branch onto another. Returns conflicted file paths, or empty array if clean. */ +export async function rebase(onto: string, cwd: string): Promise { + const result = await $`git rebase ${onto}`.cwd(cwd).nothrow().quiet() + if (result.exitCode === 0) return [] + + const unmerged = await $`git diff --name-only --diff-filter=U`.cwd(cwd).nothrow().quiet().text() + const files = unmerged.trim().split("\n").filter(Boolean) + if (files.length > 0) return files + + throw new Error(`Failed to rebase onto "${onto}": ${result.stderr.toString().trim()}`) +} + +/** Continue a rebase after resolving conflicts. Returns conflicted files for the next commit, or empty if done. */ +export async function rebaseContinue(cwd: string): Promise { + const result = await $`git -c core.editor=true rebase --continue`.cwd(cwd).nothrow().quiet() + if (result.exitCode === 0) return [] + + const unmerged = await $`git diff --name-only --diff-filter=U`.cwd(cwd).nothrow().quiet().text() + const files = unmerged.trim().split("\n").filter(Boolean) + if (files.length > 0) return files + + throw new Error(`Rebase --continue failed: ${result.stderr.toString().trim()}`) +} + +/** Abort an in-progress rebase. */ +export async function rebaseAbort(cwd: string): Promise { + await $`git rebase --abort`.cwd(cwd).nothrow().quiet() +} + /** Check if a worktree has uncommitted changes. */ export async function isDirty(worktreePath: string): Promise { const result = await $`git -C ${worktreePath} status --porcelain`.nothrow().quiet() From 603c92b595318dcc12658172111348caa9c54a8b Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 20 Feb 2026 18:54:25 -0800 Subject: [PATCH 2/3] refactor conflict resolution into shared helper and add rebase round limit --- src/commands/helpers.ts | 25 ++++++++++++++++++ src/commands/merge.ts | 20 ++------------- src/commands/rebase.ts | 56 ++++++++++++++++++----------------------- 3 files changed, 52 insertions(+), 49 deletions(-) diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 9f0d1d5..02de7a8 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -1,3 +1,4 @@ +import { join } from "path" import { $ } from "bun" import * as git from "../git.ts" import * as vm from "../vm.ts" @@ -16,6 +17,30 @@ export async function requireSession(branch: string): Promise<{ root: string; se return { root, session } } +/** Resolve conflict markers in files using Claude, then stage them. */ +export async function resolveConflicts( + files: string[], + cwd: string, + onFile: (file: string) => void, +): Promise { + for (const file of files) { + onFile(file) + const content = await Bun.file(join(cwd, file)).text() + + const resolved = await vm.claudePipe( + content, + "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.", + ) + + if (resolved.exitCode !== 0) { + throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`) + } + + await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n") + await git.stageFile(file, cwd) + } +} + /** Stage all changes, generate a commit message, and commit. Returns true on success. */ export async function saveChanges(worktree: string, branch: string, message?: string): Promise { const spin = spinner("Staging changes", branch) diff --git a/src/commands/merge.ts b/src/commands/merge.ts index d7a24c1..2561de4 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -1,10 +1,10 @@ -import { join } from "path" import * as git from "../git.ts" import * as vm from "../vm.ts" import * as state from "../state.ts" import { spinner } from "../spinner.ts" import { die } from "../fmt.ts" import { action as closeAction } from "./close.ts" +import { resolveConflicts } from "./helpers.ts" export async function action(branch: string) { const root = await git.repoRoot() @@ -28,23 +28,7 @@ export async function action(branch: string) { try { await vm.ensure((msg) => { spin.text = msg }) - - for (const file of conflicts) { - spin.text = `Resolving ${file}` - const content = await Bun.file(join(root, file)).text() - - const resolved = await vm.claudePipe( - content, - "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.", - ) - - if (resolved.exitCode !== 0) { - throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`) - } - - await Bun.write(join(root, file), resolved.stdout + "\n") - await git.stageFile(file, root) - } + await resolveConflicts(conflicts, root, (file) => { spin.text = `Resolving ${file}` }) await git.commitMerge(root) spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`) diff --git a/src/commands/rebase.ts b/src/commands/rebase.ts index dac9385..3689a1a 100644 --- a/src/commands/rebase.ts +++ b/src/commands/rebase.ts @@ -1,10 +1,11 @@ -import { join } from "path" import { $ } from "bun" import * as git from "../git.ts" import * as vm from "../vm.ts" import { spinner } from "../spinner.ts" import { die } from "../fmt.ts" -import { requireSession } from "./helpers.ts" +import { requireSession, resolveConflicts } from "./helpers.ts" + +const MAX_REBASE_ROUNDS = 10 export async function action(branch: string) { const { root, session } = await requireSession(branch) @@ -17,46 +18,39 @@ export async function action(branch: string) { const main = await git.mainBranch(root) const spin = spinner("Fetching origin", branch) + await $`git -C ${root} fetch origin ${main}`.nothrow().quiet() + spin.text = `Rebasing onto origin/${main}` + + let conflicts = await git.rebase(`origin/${main}`, worktree) + if (conflicts.length === 0) { + spin.succeed(`Rebased ${branch} onto ${main}`) + return + } + + spin.stop() + console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Claude...`) + const spin2 = spinner("Starting container", branch) + try { - await $`git -C ${root} fetch origin ${main}`.nothrow().quiet() - spin.text = `Rebasing onto origin/${main}` - - let conflicts = await git.rebase(`origin/${main}`, worktree) - if (conflicts.length === 0) { - spin.succeed(`Rebased ${branch} onto ${main}`) - return - } - - // Resolve conflicts with Claude, looping for each rebased commit - console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Claude...`) - await vm.ensure((msg) => { spin.text = msg }) + await vm.ensure((msg) => { spin2.text = msg }) let round = 1 while (conflicts.length > 0) { - for (const file of conflicts) { - spin.text = `Resolving ${file} (round ${round})` - const content = await Bun.file(join(worktree, file)).text() - - const resolved = await vm.claudePipe( - content, - "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.", - ) - - if (resolved.exitCode !== 0) { - throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`) - } - - await Bun.write(join(worktree, file), resolved.stdout + "\n") - await git.stageFile(file, worktree) + if (round > MAX_REBASE_ROUNDS) { + throw new Error(`Exceeded ${MAX_REBASE_ROUNDS} conflict resolution rounds — aborting rebase`) } + await resolveConflicts(conflicts, worktree, (file) => { + spin2.text = `Resolving ${file} (round ${round})` + }) + conflicts = await git.rebaseContinue(worktree) round++ } - spin.succeed(`Rebased ${branch} onto ${main} (resolved ${round - 1} conflict round(s))`) + spin2.succeed(`Rebased ${branch} onto ${main} (resolved ${round - 1} conflict round(s))`) } catch (err) { - spin.fail(String((err as Error).message ?? err)) + spin2.fail(String((err as Error).message ?? err)) await git.rebaseAbort(worktree) process.exit(1) } From 0932fd5ef54f85beacb419c7d4bdae7ac3bbda64 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 20 Feb 2026 20:12:44 -0800 Subject: [PATCH 3/3] Rename spinner variables and add rebase-in-progress guard with improved error messages --- src/commands/rebase.ts | 18 +++++++++--------- src/git.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/commands/rebase.ts b/src/commands/rebase.ts index 3689a1a..ff7b220 100644 --- a/src/commands/rebase.ts +++ b/src/commands/rebase.ts @@ -16,23 +16,23 @@ export async function action(branch: string) { } const main = await git.mainBranch(root) - const spin = spinner("Fetching origin", branch) + const fetchSpin = spinner("Fetching origin", branch) await $`git -C ${root} fetch origin ${main}`.nothrow().quiet() - spin.text = `Rebasing onto origin/${main}` + fetchSpin.text = `Rebasing onto origin/${main}` let conflicts = await git.rebase(`origin/${main}`, worktree) if (conflicts.length === 0) { - spin.succeed(`Rebased ${branch} onto ${main}`) + fetchSpin.succeed(`Rebased ${branch} onto ${main}`) return } - spin.stop() + fetchSpin.stop() console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Claude...`) - const spin2 = spinner("Starting container", branch) + const resolveSpin = spinner("Starting container", branch) try { - await vm.ensure((msg) => { spin2.text = msg }) + await vm.ensure((msg) => { resolveSpin.text = msg }) let round = 1 while (conflicts.length > 0) { @@ -41,16 +41,16 @@ export async function action(branch: string) { } await resolveConflicts(conflicts, worktree, (file) => { - spin2.text = `Resolving ${file} (round ${round})` + resolveSpin.text = `Resolving ${file} (round ${round})` }) conflicts = await git.rebaseContinue(worktree) round++ } - spin2.succeed(`Rebased ${branch} onto ${main} (resolved ${round - 1} conflict round(s))`) + resolveSpin.succeed(`Rebased ${branch} onto ${main} (resolved ${round - 1} conflict round(s))`) } catch (err) { - spin2.fail(String((err as Error).message ?? err)) + resolveSpin.fail(String((err as Error).message ?? err)) await git.rebaseAbort(worktree) process.exit(1) } diff --git a/src/git.ts b/src/git.ts index eb609b4..1f5c475 100644 --- a/src/git.ts +++ b/src/git.ts @@ -121,6 +121,12 @@ export async function abortMerge(cwd: string): Promise { /** Rebase the current branch onto another. Returns conflicted file paths, or empty array if clean. */ export async function rebase(onto: string, cwd: string): Promise { + // Bail early if a rebase is already in progress + const inProgress = await $`git -C ${cwd} rev-parse --verify --quiet REBASE_HEAD`.nothrow().quiet() + if (inProgress.exitCode === 0) { + throw new Error(`A rebase is already in progress. Run "git -C ${cwd} rebase --abort" to cancel it first.`) + } + const result = await $`git rebase ${onto}`.cwd(cwd).nothrow().quiet() if (result.exitCode === 0) return [] @@ -128,7 +134,9 @@ export async function rebase(onto: string, cwd: string): Promise { const files = unmerged.trim().split("\n").filter(Boolean) if (files.length > 0) return files - throw new Error(`Failed to rebase onto "${onto}": ${result.stderr.toString().trim()}`) + // No conflicts but rebase still failed — include stderr for diagnostics + const stderr = result.stderr.toString().trim() + throw new Error(`Rebase onto "${onto}" failed: ${stderr || "(no output from git)"}`) } /** Continue a rebase after resolving conflicts. Returns conflicted files for the next commit, or empty if done. */