Merge branch 'conflict-resolution'

# Conflicts:
#	src/git.ts
This commit is contained in:
Chris Wanstrath 2026-04-10 10:13:03 -07:00
commit 5c378ab239
2 changed files with 60 additions and 10 deletions

View File

@ -81,6 +81,29 @@ export async function teardownSession(root: string, branch: string, worktree: st
await state.removeSession(root, branch) await state.removeSession(root, branch)
} }
/** Generated files to skip AI resolution — accept theirs and move on (basename match so `packages/foo/bun.lock` is also covered). */
const SKIP_RESOLVE = new Set([
"bun.lock",
"bun.lockb",
"Cargo.lock",
"composer.lock",
"Gemfile.lock",
"go.sum",
"mix.lock",
"package-lock.json",
"Pipfile.lock",
"pnpm-lock.yaml",
"Podfile.lock",
"poetry.lock",
"pubspec.lock",
"flake.lock",
"gradle.lockfile",
"npm-shrinkwrap.json",
"Package.resolved",
"uv.lock",
"yarn.lock",
])
/** Resolve conflict markers in files using Claude, then stage them. */ /** Resolve conflict markers in files using Claude, then stage them. */
export async function resolveConflicts( export async function resolveConflicts(
files: string[], files: string[],
@ -90,15 +113,24 @@ export async function resolveConflicts(
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i] const file = files[i]
onFile(file, i + 1, files.length) onFile(file, i + 1, files.length)
const content = await Bun.file(join(cwd, file)).text()
if (SKIP_RESOLVE.has(basename(file))) {
await git.checkoutTheirs(file, cwd)
await git.stageFile(file, cwd)
continue
}
const content = await Bun.file(join(cwd, file)).text().catch(() => {
throw new Error(`Failed to read conflicted file: ${file}`)
})
const resolved = await vm.claudePipe( const resolved = await vm.claudePipe(
content, content,
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.", "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
) )
if (resolved.exitCode !== 0) { if (resolved.exitCode !== 0 || !resolved.stdout.trim()) {
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`) throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`)
} }
await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n") await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n")
@ -145,6 +177,7 @@ export async function mergeAndClose(branch: string, opts?: { force?: boolean }):
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
spin.fail(message) spin.fail(message)
if (session) await vm.clearActivity(session.worktree, branch) // process.exit below skips finally
await git.abortMerge(root) await git.abortMerge(root)
process.exit(1) process.exit(1)
} finally { } finally {

View File

@ -2,6 +2,12 @@ import { existsSync } from "fs"
import { rm } from "fs/promises" import { rm } from "fs/promises"
import { $ } from "bun" import { $ } from "bun"
/** Format a git error with a fallback for empty stderr. */
function gitError(action: string, stderr: Buffer | string): Error {
const msg = stderr.toString().trim()
return new Error(`${action}: ${msg || "(no output)"}`)
}
/** Get the repo root from a working directory. */ /** Get the repo root from a working directory. */
export async function repoRoot(cwd?: string): Promise<string> { export async function repoRoot(cwd?: string): Promise<string> {
const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").nothrow().quiet() const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").nothrow().quiet()
@ -72,7 +78,7 @@ export async function createWorktree(branch: string, worktreePath: string, cwd:
} }
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
if (switchedFromBranch) await checkout(branch, cwd).catch(() => {}) if (switchedFromBranch) await checkout(branch, cwd).catch(() => {})
throw new Error(`Failed to create worktree for "${branch}": ${result.stderr.toString().trim()}`) throw gitError(`Failed to create worktree for "${branch}"`, result.stderr)
} }
return { branchCreated: exists !== "local" } return { branchCreated: exists !== "local" }
} }
@ -98,7 +104,7 @@ export async function deleteLocalBranch(branch: string, cwd: string): Promise<vo
export async function checkout(branch: string, cwd: string): Promise<void> { export async function checkout(branch: string, cwd: string): Promise<void> {
const result = await $`git checkout ${branch}`.cwd(cwd).nothrow().quiet() const result = await $`git checkout ${branch}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Error(`Failed to checkout branch "${branch}": ${result.stderr.toString().trim()}`) throw gitError(`Failed to checkout branch "${branch}"`, result.stderr)
} }
} }
@ -113,7 +119,7 @@ export async function merge(branch: string, cwd: string): Promise<string[]> {
if (files.length > 0) return files if (files.length > 0) return files
// Not a conflict — some other merge failure // Not a conflict — some other merge failure
throw new Error(`Failed to merge branch "${branch}": ${result.stderr.toString().trim()}`) throw gitError(`Failed to merge branch "${branch}"`, result.stderr)
} }
/** Return the staged diff as text. */ /** Return the staged diff as text. */
@ -125,20 +131,31 @@ export async function diffStaged(cwd: string): Promise<string> {
export async function commit(message: string, cwd: string): Promise<void> { export async function commit(message: string, cwd: string): Promise<void> {
const result = await $`git commit -m ${message}`.cwd(cwd).nothrow().quiet() const result = await $`git commit -m ${message}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Error(`Failed to commit: ${result.stderr.toString().trim()}`) throw gitError("Failed to commit", result.stderr)
}
}
/** Accept "theirs" version of a conflicted file. */
export async function checkoutTheirs(file: string, cwd: string): Promise<void> {
const result = await $`git checkout --theirs -- ${file}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw gitError(`Failed to checkout theirs for ${file}`, result.stderr)
} }
} }
/** Stage a file. */ /** Stage a file. */
export async function stageFile(file: string, cwd: string): Promise<void> { export async function stageFile(file: string, cwd: string): Promise<void> {
await $`git add ${file}`.cwd(cwd).nothrow().quiet() const result = await $`git add ${file}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw gitError(`Failed to stage ${file}`, result.stderr)
}
} }
/** Finalize a merge commit after resolving conflicts. */ /** Finalize a merge commit after resolving conflicts. */
export async function commitMerge(cwd: string): Promise<void> { export async function commitMerge(cwd: string): Promise<void> {
const result = await $`git commit --no-edit`.cwd(cwd).nothrow().quiet() const result = await $`git commit --no-edit`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Error(`Failed to commit merge: ${result.stderr.toString().trim()}`) throw gitError("Failed to commit merge", result.stderr)
} }
} }
@ -194,7 +211,7 @@ export async function rebaseContinue(cwd: string): Promise<string[]> {
const files = unmerged.trim().split("\n").filter(Boolean) const files = unmerged.trim().split("\n").filter(Boolean)
if (files.length > 0) return files if (files.length > 0) return files
throw new Error(`Rebase --continue failed: ${result.stderr.toString().trim()}`) throw gitError("Rebase --continue failed", result.stderr)
} }
/** Abort an in-progress rebase. */ /** Abort an in-progress rebase. */