From 8cbeede82ba025c56f7b9812b4d16ef0f85212fe Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 19 Feb 2026 09:50:01 -0800 Subject: [PATCH] Auto-resolve merge conflicts using Claude when merging branches --- src/cli.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++-- src/git.ts | 30 +++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 60e53ad..44ed2f1 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -143,8 +143,50 @@ program .action(async (branch: string) => { const root = await git.repoRoot() - await git.merge(branch, root) - console.log(`Merged ${branch} into current branch`) + const conflicts = await git.merge(branch, root) + + if (conflicts.length === 0) { + console.log(`Merged ${branch} into current branch`) + await closeAction(branch) + return + } + + // Resolve conflicts with Claude + console.log(`Merge conflicts in ${conflicts.length} file(s). Resolving with Claude...`) + const spin = spinner("Starting container") + + 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 tmpPath = join(homedir(), '.sandlot', '.conflict-tmp') + await Bun.write(tmpPath, content) + + const resolved = await vm.exec( + join(homedir(), '.sandlot'), + 'cat /sandlot/.conflict-tmp | claude -p "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text."', + ) + + await Bun.file(tmpPath).unlink().catch(() => {}) + + 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 git.commitMerge(root) + spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`) + } catch (err) { + spin.fail(String((err as Error).message ?? err)) + await git.abortMerge(root) + process.exit(1) + } await closeAction(branch) }) diff --git a/src/git.ts b/src/git.ts index f2f04ae..5196985 100644 --- a/src/git.ts +++ b/src/git.ts @@ -81,10 +81,34 @@ export async function checkout(branch: string, cwd: string): Promise { } } -/** Merge a branch into the current branch. */ -export async function merge(branch: string, cwd: string): Promise { +/** Merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */ +export async function merge(branch: string, cwd: string): Promise { const result = await $`git merge ${branch}`.cwd(cwd).nothrow().quiet() + if (result.exitCode === 0) return [] + + // Check for unmerged (conflicted) files + 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 + + // Not a conflict — some other merge failure + throw new Error(`Failed to merge branch "${branch}": ${result.stderr.toString().trim()}`) +} + +/** Stage a file. */ +export async function stageFile(file: string, cwd: string): Promise { + await $`git add ${file}`.cwd(cwd).nothrow().quiet() +} + +/** Finalize a merge commit after resolving conflicts. */ +export async function commitMerge(cwd: string): Promise { + const result = await $`git commit --no-edit`.cwd(cwd).nothrow().quiet() if (result.exitCode !== 0) { - throw new Error(`Failed to merge branch "${branch}": ${result.stderr.toString().trim()}`) + throw new Error(`Failed to commit merge: ${result.stderr.toString().trim()}`) } } + +/** Abort an in-progress merge. */ +export async function abortMerge(cwd: string): Promise { + await $`git merge --abort`.cwd(cwd).nothrow().quiet() +}