From 0c5e44bb5d4c6459d24c56196e9c698decc3dfb8 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 20 Feb 2026 16:01:26 -0800 Subject: [PATCH] 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()