From ae6604caad7d9d547db09968c8d511befc475414 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 20 Feb 2026 19:21:16 -0800 Subject: [PATCH 1/5] add merge context (branch log, diff stats, per-file diffs) to improve conflict resolution quality --- src/commands/merge.ts | 37 +++++++++++++++++++++++++++++++++++-- src/git.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/commands/merge.ts b/src/commands/merge.ts index d7a24c1..834914e 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -29,13 +29,46 @@ export async function action(branch: string) { try { await vm.ensure((msg) => { spin.text = msg }) + // 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.log(`${base}..${branch}`, root) + const branchDiffStat = await git.diffStat(`${base}..${branch}`, root) + for (const file of conflicts) { spin.text = `Resolving ${file}` + + const oursDiff = await git.fileDiff(base, "HEAD", file, root) + const theirsDiff = await git.fileDiff(base, branch, file, root) const content = await Bun.file(join(root, file)).text() - const resolved = await vm.claudePipe( + const context = [ + `## Merge Context`, + `Merging branch \`${branch}\` into \`${current}\``, + ``, + `### Commits on \`${branch}\`:`, + branchLog || "(no commits)", + ``, + `### Overall changes on \`${branch}\`:`, + branchDiffStat || "(no changes)", + ``, + `---`, + `## Conflicted File: ${file}`, + ``, + `### Changes made on current branch (\`${current}\`):`, + oursDiff || "(no changes to this file)", + ``, + `### Changes made on incoming branch (\`${branch}\`):`, + theirsDiff || "(no changes to this file)", + ``, + `### File with conflict markers:`, content, - "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.", + ].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) { diff --git a/src/git.ts b/src/git.ts index f7db35a..babcb4f 100644 --- a/src/git.ts +++ b/src/git.ts @@ -126,6 +126,33 @@ 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. */ +export async function log(range: string, cwd: string): Promise { + const result = await $`git log --oneline ${range}`.cwd(cwd).nothrow().quiet() + return result.text().trim() +} + +/** Get a diff stat summary for a revision range. */ +export async function diffStat(range: string, cwd: string): Promise { + const result = await $`git diff --stat ${range}`.cwd(cwd).nothrow().quiet() + return result.text().trim() +} + +/** Get the diff for a specific file between two refs. */ +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() + return result.text().trim() +} + /** Check if a branch has commits beyond main. */ export async function hasNewCommits(worktreePath: string): Promise { const main = await mainBranch(worktreePath) From 386954b3c47fafaeec81e3cef1f5bb5fadd9090d Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 21 Feb 2026 07:54:23 -0800 Subject: [PATCH 2/5] Truncate large diffs in merge context and add error handling to git log/diff helpers --- src/commands/merge.ts | 22 +++++++++++++++------- src/git.ts | 11 ++++++++++- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 834914e..5a62075 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -6,6 +6,12 @@ import { spinner } from "../spinner.ts" import { die } from "../fmt.ts" import { action as closeAction } from "./close.ts" +function truncate(text: string, maxLines = 200): string { + 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() const session = await state.getSession(root, branch) @@ -33,14 +39,16 @@ export async function action(branch: string) { spin.text = "Gathering merge context" const current = await git.currentBranch(root) const base = await git.mergeBase("HEAD", branch, root) - const branchLog = await git.log(`${base}..${branch}`, root) + const branchLog = await git.commitLog(`${base}..${branch}`, root) const branchDiffStat = await git.diffStat(`${base}..${branch}`, root) for (const file of conflicts) { spin.text = `Resolving ${file}` - const oursDiff = await git.fileDiff(base, "HEAD", file, root) - const theirsDiff = await git.fileDiff(base, branch, file, root) + const [oursDiff, theirsDiff] = await Promise.all([ + git.fileDiff(base, "HEAD", file, root), + git.fileDiff(base, branch, file, root), + ]) const content = await Bun.file(join(root, file)).text() const context = [ @@ -48,19 +56,19 @@ export async function action(branch: string) { `Merging branch \`${branch}\` into \`${current}\``, ``, `### Commits on \`${branch}\`:`, - branchLog || "(no commits)", + (branchLog && truncate(branchLog, 50)) || "(no commits)", ``, `### Overall changes on \`${branch}\`:`, - branchDiffStat || "(no changes)", + (branchDiffStat && truncate(branchDiffStat, 100)) || "(no changes)", ``, `---`, `## Conflicted File: ${file}`, ``, `### Changes made on current branch (\`${current}\`):`, - oursDiff || "(no changes to this file)", + (oursDiff && truncate(oursDiff)) || "(no changes to this file)", ``, `### Changes made on incoming branch (\`${branch}\`):`, - theirsDiff || "(no changes to this file)", + (theirsDiff && truncate(theirsDiff)) || "(no changes to this file)", ``, `### File with conflict markers:`, content, diff --git a/src/git.ts b/src/git.ts index babcb4f..700a265 100644 --- a/src/git.ts +++ b/src/git.ts @@ -136,20 +136,29 @@ export async function mergeBase(ref1: string, ref2: string, cwd: string): Promis } /** Get a one-line-per-commit log for a revision range. */ -export async function log(range: string, cwd: string): Promise { +export async function commitLog(range: string, cwd: string): Promise { const result = await $`git log --oneline ${range}`.cwd(cwd).nothrow().quiet() + if (result.exitCode !== 0) { + throw new Error(`Failed to get log for range "${range}"`) + } return result.text().trim() } /** Get a diff stat summary for a revision range. */ export async function diffStat(range: string, cwd: string): Promise { const result = await $`git diff --stat ${range}`.cwd(cwd).nothrow().quiet() + if (result.exitCode !== 0) { + throw new Error(`Failed to get diff stat for range "${range}"`) + } return result.text().trim() } /** Get the diff for a specific file between two refs. */ 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) { + throw new Error(`Failed to get diff for "${file}" between "${ref1}" and "${ref2}"`) + } return result.text().trim() } From c6b6f52b1f260c92e024e025fa2dda2d9521dfb1 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 21 Feb 2026 08:30:51 -0800 Subject: [PATCH 3/5] skip complex conflicts in merge auto-resolution --- src/commands/merge.ts | 48 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 5a62075..26408e5 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -6,6 +6,8 @@ import { spinner } from "../spinner.ts" import { die } from "../fmt.ts" import { action as closeAction } from "./close.ts" +const MAX_DIFF_LINES = 300 + function truncate(text: string, maxLines = 200): string { const lines = text.split("\n") if (lines.length <= maxLines) return text @@ -42,13 +44,41 @@ export async function action(branch: string) { const branchLog = await git.commitLog(`${base}..${branch}`, root) const branchDiffStat = await git.diffStat(`${base}..${branch}`, root) - for (const file of conflicts) { + // Fetch diffs for all conflicted files and partition by complexity + spin.text = "Checking conflict complexity" + const fileDiffs = await Promise.all( + conflicts.map(async (file) => { + const [oursDiff, theirsDiff] = await Promise.all([ + git.fileDiff(base, "HEAD", file, root), + git.fileDiff(base, branch, file, root), + ]) + return { file, oursDiff, theirsDiff } + }), + ) + + const resolvable: typeof fileDiffs = [] + const skipped: string[] = [] + for (const entry of fileDiffs) { + const oursLines = entry.oursDiff ? entry.oursDiff.split("\n").length : 0 + const theirsLines = entry.theirsDiff ? entry.theirsDiff.split("\n").length : 0 + if (oursLines > MAX_DIFF_LINES || theirsLines > MAX_DIFF_LINES) { + skipped.push(entry.file) + } else { + resolvable.push(entry) + } + } + + if (resolvable.length === 0) { + await git.abortMerge(root) + 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}`) + process.exit(1) + } + + for (const { file, oursDiff, theirsDiff } of resolvable) { spin.text = `Resolving ${file}` - const [oursDiff, theirsDiff] = await Promise.all([ - git.fileDiff(base, "HEAD", file, root), - git.fileDiff(base, branch, file, root), - ]) const content = await Bun.file(join(root, file)).text() const context = [ @@ -87,6 +117,14 @@ export async function action(branch: string) { 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("\nResolve them manually, then run: git commit --no-edit") + return + } + await git.commitMerge(root) spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`) } catch (err) { From 376f918a66dce5094bc0e2fc9043f870b44b8034 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 21 Feb 2026 08:36:44 -0800 Subject: [PATCH 4/5] refactor merge: extract lineCount helper, hoist preamble, and gracefully handle git errors --- src/commands/merge.ts | 43 +++++++++++++++++++++++++------------------ src/git.ts | 12 ++++-------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 26408e5..00f7ace 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -8,10 +8,15 @@ import { action as closeAction } from "./close.ts" const MAX_DIFF_LINES = 300 -function truncate(text: string, maxLines = 200): string { - 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)` +function lineCount(text: string): number { + if (!text) return 0 + return text.split("\n").length +} + +function truncate(text: string, maxLines = MAX_DIFF_LINES): string { + const count = lineCount(text) + if (count <= maxLines) return text + return text.split("\n").slice(0, maxLines).join("\n") + `\n... (truncated, ${count - maxLines} more lines)` } export async function action(branch: string) { @@ -59,9 +64,7 @@ export async function action(branch: string) { const resolvable: typeof fileDiffs = [] const skipped: string[] = [] for (const entry of fileDiffs) { - const oursLines = entry.oursDiff ? entry.oursDiff.split("\n").length : 0 - const theirsLines = entry.theirsDiff ? entry.theirsDiff.split("\n").length : 0 - if (oursLines > MAX_DIFF_LINES || theirsLines > MAX_DIFF_LINES) { + if (lineCount(entry.oursDiff) > MAX_DIFF_LINES || lineCount(entry.theirsDiff) > MAX_DIFF_LINES) { skipped.push(entry.file) } else { resolvable.push(entry) @@ -69,27 +72,31 @@ export async function action(branch: string) { } if (resolvable.length === 0) { - await git.abortMerge(root) 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}`) - process.exit(1) + console.log("\nResolve them manually, then run: git add && git commit --no-edit") + return } + const preamble = [ + `## Merge Context`, + `Merging branch \`${branch}\` into \`${current}\``, + ``, + `### Commits on \`${branch}\`:`, + (branchLog && truncate(branchLog, 50)) || "(no commits)", + ``, + `### Overall changes on \`${branch}\`:`, + (branchDiffStat && truncate(branchDiffStat, 100)) || "(no changes)", + ].join("\n") + for (const { file, oursDiff, theirsDiff } of resolvable) { spin.text = `Resolving ${file}` const content = await Bun.file(join(root, file)).text() const context = [ - `## Merge Context`, - `Merging branch \`${branch}\` into \`${current}\``, - ``, - `### Commits on \`${branch}\`:`, - (branchLog && truncate(branchLog, 50)) || "(no commits)", - ``, - `### Overall changes on \`${branch}\`:`, - (branchDiffStat && truncate(branchDiffStat, 100)) || "(no changes)", + preamble, ``, `---`, `## Conflicted File: ${file}`, @@ -121,7 +128,7 @@ export async function action(branch: string) { 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("\nResolve them manually, then run: git commit --no-edit") + console.log("\nResolve them manually, then run: git add && git commit --no-edit") return } diff --git a/src/git.ts b/src/git.ts index 700a265..faee459 100644 --- a/src/git.ts +++ b/src/git.ts @@ -135,21 +135,17 @@ export async function mergeBase(ref1: string, ref2: string, cwd: string): Promis return result.text().trim() } -/** Get a one-line-per-commit log for a revision range. */ +/** 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) { - throw new Error(`Failed to get log for range "${range}"`) - } + if (result.exitCode !== 0) return "" return result.text().trim() } -/** Get a diff stat summary for a revision range. */ +/** 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) { - throw new Error(`Failed to get diff stat for range "${range}"`) - } + if (result.exitCode !== 0) return "" return result.text().trim() } From db9f40dac90cff03862a94f8e043e8e1c821e5a0 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 21 Feb 2026 08:46:58 -0800 Subject: [PATCH 5/5] improve conflict complexity analysis and merge error messaging --- src/commands/merge.ts | 67 ++++++++++++++++++++++++++++++------------- src/git.ts | 6 ++-- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 00f7ace..6e542bf 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -7,16 +7,40 @@ import { die } from "../fmt.ts" import { action as closeAction } from "./close.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 -function lineCount(text: string): number { - if (!text) return 0 - return text.split("\n").length +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 { - const count = lineCount(text) - if (count <= maxLines) return text - return text.split("\n").slice(0, maxLines).join("\n") + `\n... (truncated, ${count - maxLines} more lines)` + 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) { @@ -49,22 +73,23 @@ export async function action(branch: string) { const branchLog = await git.commitLog(`${base}..${branch}`, root) const branchDiffStat = await git.diffStat(`${base}..${branch}`, root) - // Fetch diffs for all conflicted files and partition by complexity + // Read conflicted files, fetch diffs, and partition by complexity spin.text = "Checking conflict complexity" - const fileDiffs = await Promise.all( + const fileEntries = await Promise.all( conflicts.map(async (file) => { - const [oursDiff, theirsDiff] = await Promise.all([ + 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, oursDiff, theirsDiff } + return { file, content, oursDiff, theirsDiff, ...analyzeConflicts(content) } }), ) - const resolvable: typeof fileDiffs = [] + const resolvable: typeof fileEntries = [] const skipped: string[] = [] - for (const entry of fileDiffs) { - if (lineCount(entry.oursDiff) > MAX_DIFF_LINES || lineCount(entry.theirsDiff) > MAX_DIFF_LINES) { + for (const entry of fileEntries) { + if (entry.regions > MAX_CONFLICT_REGIONS || entry.conflictedLines > MAX_CONFLICTED_LINES) { skipped.push(entry.file) } else { resolvable.push(entry) @@ -75,7 +100,9 @@ export async function action(branch: string) { 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("\nResolve them manually, then run: git add && git commit --no-edit") + 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 } @@ -84,17 +111,15 @@ export async function action(branch: string) { `Merging branch \`${branch}\` into \`${current}\``, ``, `### Commits on \`${branch}\`:`, - (branchLog && truncate(branchLog, 50)) || "(no commits)", + (branchLog && truncate(branchLog, MAX_LOG_LINES)) || "(no commits)", ``, `### Overall changes on \`${branch}\`:`, - (branchDiffStat && truncate(branchDiffStat, 100)) || "(no changes)", + (branchDiffStat && truncate(branchDiffStat, MAX_STAT_LINES)) || "(no changes)", ].join("\n") - for (const { file, oursDiff, theirsDiff } of resolvable) { + for (const { file, content, oursDiff, theirsDiff } of resolvable) { spin.text = `Resolving ${file}` - const content = await Bun.file(join(root, file)).text() - const context = [ preamble, ``, @@ -128,7 +153,9 @@ export async function action(branch: string) { 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("\nResolve them manually, then run: git add && git commit --no-edit") + 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 } diff --git a/src/git.ts b/src/git.ts index faee459..b6e78d4 100644 --- a/src/git.ts +++ b/src/git.ts @@ -149,12 +149,10 @@ export async function diffStat(range: string, cwd: string): Promise { return result.text().trim() } -/** Get the diff for a specific file between two refs. */ +/** 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) { - throw new Error(`Failed to get diff for "${file}" between "${ref1}" and "${ref2}"`) - } + if (result.exitCode !== 0) return "" return result.text().trim() }