diff --git a/src/cli.ts b/src/cli.ts index 460d14b..4bcc5a5 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" @@ -96,6 +97,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/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 new file mode 100644 index 0000000..ff7b220 --- /dev/null +++ b/src/commands/rebase.ts @@ -0,0 +1,57 @@ +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, resolveConflicts } from "./helpers.ts" + +const MAX_REBASE_ROUNDS = 10 + +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 fetchSpin = spinner("Fetching origin", branch) + + await $`git -C ${root} fetch origin ${main}`.nothrow().quiet() + fetchSpin.text = `Rebasing onto origin/${main}` + + let conflicts = await git.rebase(`origin/${main}`, worktree) + if (conflicts.length === 0) { + fetchSpin.succeed(`Rebased ${branch} onto ${main}`) + return + } + + fetchSpin.stop() + console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Claude...`) + const resolveSpin = spinner("Starting container", branch) + + try { + await vm.ensure((msg) => { resolveSpin.text = msg }) + + let round = 1 + while (conflicts.length > 0) { + if (round > MAX_REBASE_ROUNDS) { + throw new Error(`Exceeded ${MAX_REBASE_ROUNDS} conflict resolution rounds — aborting rebase`) + } + + await resolveConflicts(conflicts, worktree, (file) => { + resolveSpin.text = `Resolving ${file} (round ${round})` + }) + + conflicts = await git.rebaseContinue(worktree) + round++ + } + + resolveSpin.succeed(`Rebased ${branch} onto ${main} (resolved ${round - 1} conflict round(s))`) + } catch (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 f7db35a..1f5c475 100644 --- a/src/git.ts +++ b/src/git.ts @@ -119,6 +119,43 @@ 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 { + // 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 [] + + 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 + + // 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. */ +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()