diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 2561de4..704df82 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -4,7 +4,43 @@ 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" + +const MAX_DIFF_LINES = 300 +const MAX_LOG_LINES = 50 +const MAX_STAT_LINES = 100 +const MAX_CONFLICT_REGIONS = 15 +const MAX_CONFLICTED_LINES = 200 + +interface ConflictAnalysis { + regions: number + conflictedLines: number +} + +function analyzeConflicts(content: string): ConflictAnalysis { + let regions = 0 + let conflictedLines = 0 + let inConflict = false + + for (const line of content.split("\n")) { + if (line.startsWith("<<<<<<<")) { + regions++ + inConflict = true + } else if (line.startsWith(">>>>>>>")) { + inConflict = false + } else if (inConflict) { + conflictedLines++ + } + } + + return { regions, conflictedLines } +} + +function truncate(text: string, maxLines = MAX_DIFF_LINES): string { + if (!text) return text + const lines = text.split("\n") + if (lines.length <= maxLines) return text + return lines.slice(0, maxLines).join("\n") + `\n... (truncated, ${lines.length - maxLines} more lines)` +} export async function action(branch: string) { const root = await git.repoRoot() @@ -28,7 +64,99 @@ export async function action(branch: string) { try { await vm.ensure((msg) => { spin.text = msg }) - await resolveConflicts(conflicts, root, (file) => { spin.text = `Resolving ${file}` }) + + // Gather merge context + spin.text = "Gathering merge context" + const current = await git.currentBranch(root) + const base = await git.mergeBase("HEAD", branch, root) + const branchLog = await git.commitLog(`${base}..${branch}`, root) + const branchDiffStat = await git.diffStat(`${base}..${branch}`, root) + + // Read conflicted files, fetch diffs, and partition by complexity + spin.text = "Checking conflict complexity" + const fileEntries = await Promise.all( + conflicts.map(async (file) => { + const [content, oursDiff, theirsDiff] = await Promise.all([ + Bun.file(join(root, file)).text(), + git.fileDiff(base, "HEAD", file, root), + git.fileDiff(base, branch, file, root), + ]) + return { file, content, oursDiff, theirsDiff, ...analyzeConflicts(content) } + }), + ) + + const resolvable: typeof fileEntries = [] + const skipped: string[] = [] + for (const entry of fileEntries) { + if (entry.regions > MAX_CONFLICT_REGIONS || entry.conflictedLines > MAX_CONFLICTED_LINES) { + skipped.push(entry.file) + } else { + resolvable.push(entry) + } + } + + if (resolvable.length === 0) { + spin.fail("All conflicts are too complex for auto-resolution") + console.log("\nThe following files need manual resolution:") + for (const file of skipped) console.log(` - ${file}`) + console.log("\nYour repo is in a MERGING state. To finish:") + console.log(" Resolve the files, then run: git add && git commit --no-edit") + console.log(" Or abort with: git merge --abort") + return + } + + const preamble = [ + `## Merge Context`, + `Merging branch \`${branch}\` into \`${current}\``, + ``, + `### Commits on \`${branch}\`:`, + (branchLog && truncate(branchLog, MAX_LOG_LINES)) || "(no commits)", + ``, + `### Overall changes on \`${branch}\`:`, + (branchDiffStat && truncate(branchDiffStat, MAX_STAT_LINES)) || "(no changes)", + ].join("\n") + + for (const { file, content, oursDiff, theirsDiff } of resolvable) { + spin.text = `Resolving ${file}` + + const context = [ + preamble, + ``, + `---`, + `## Conflicted File: ${file}`, + ``, + `### Changes made on current branch (\`${current}\`):`, + (oursDiff && truncate(oursDiff)) || "(no changes to this file)", + ``, + `### Changes made on incoming branch (\`${branch}\`):`, + (theirsDiff && truncate(theirsDiff)) || "(no changes to this file)", + ``, + `### File with conflict markers:`, + content, + ].join("\n") + + const resolved = await vm.claudePipe( + context, + "Resolve the merge conflicts in the file shown at the end. Use the diffs and commit history to understand the intent of each side. Preserve all non-conflicting changes from both sides. Output ONLY the resolved file content — 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) + } + + if (skipped.length > 0) { + spin.succeed(`Resolved ${resolvable.length} of ${conflicts.length} conflict(s)`) + console.log("\nThe following files are too complex for auto-resolution:") + for (const file of skipped) console.log(` - ${file}`) + console.log("\nYour repo is in a MERGING state. To finish:") + console.log(" Resolve the remaining files, then run: git add && git commit --no-edit") + console.log(" Or abort with: git merge --abort") + return + } await git.commitMerge(root) spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`) diff --git a/src/git.ts b/src/git.ts index 1f5c475..62519d3 100644 --- a/src/git.ts +++ b/src/git.ts @@ -163,6 +163,36 @@ export async function isDirty(worktreePath: string): Promise { return result.text().trim().length > 0 } +/** Find the merge base (common ancestor) between two refs. */ +export async function mergeBase(ref1: string, ref2: string, cwd: string): Promise { + const result = await $`git merge-base ${ref1} ${ref2}`.cwd(cwd).nothrow().quiet() + if (result.exitCode !== 0) { + throw new Error(`Could not find merge base between "${ref1}" and "${ref2}"`) + } + return result.text().trim() +} + +/** Get a one-line-per-commit log for a revision range. Returns empty string on failure. */ +export async function commitLog(range: string, cwd: string): Promise { + const result = await $`git log --oneline ${range}`.cwd(cwd).nothrow().quiet() + if (result.exitCode !== 0) return "" + return result.text().trim() +} + +/** Get a diff stat summary for a revision range. Returns empty string on failure. */ +export async function diffStat(range: string, cwd: string): Promise { + const result = await $`git diff --stat ${range}`.cwd(cwd).nothrow().quiet() + if (result.exitCode !== 0) return "" + return result.text().trim() +} + +/** Get the diff for a specific file between two refs. Returns empty string on failure. */ +export async function fileDiff(ref1: string, ref2: string, file: string, cwd: string): Promise { + const result = await $`git diff ${ref1} ${ref2} -- ${file}`.cwd(cwd).nothrow().quiet() + if (result.exitCode !== 0) return "" + return result.text().trim() +} + /** Check if a branch has commits beyond main. */ export async function hasNewCommits(worktreePath: string): Promise { const main = await mainBranch(worktreePath)