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