Merge branch 'improve-conflict-context'

# Conflicts:
#	src/commands/merge.ts
This commit is contained in:
Chris Wanstrath 2026-02-21 08:53:45 -08:00
commit daf436f90e
2 changed files with 160 additions and 2 deletions

View File

@ -4,7 +4,43 @@ import * as state from "../state.ts"
import { spinner } from "../spinner.ts" import { spinner } from "../spinner.ts"
import { die } from "../fmt.ts" import { die } from "../fmt.ts"
import { action as closeAction } from "./close.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) { export async function action(branch: string) {
const root = await git.repoRoot() const root = await git.repoRoot()
@ -28,7 +64,99 @@ export async function action(branch: string) {
try { try {
await vm.ensure((msg) => { spin.text = msg }) 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 <files> && 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 <files> && git commit --no-edit")
console.log(" Or abort with: git merge --abort")
return
}
await git.commitMerge(root) await git.commitMerge(root)
spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`) spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`)

View File

@ -163,6 +163,36 @@ export async function isDirty(worktreePath: string): Promise<boolean> {
return result.text().trim().length > 0 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<string> {
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<string> {
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<string> {
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<string> {
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. */ /** Check if a branch has commits beyond main. */
export async function hasNewCommits(worktreePath: string): Promise<boolean> { export async function hasNewCommits(worktreePath: string): Promise<boolean> {
const main = await mainBranch(worktreePath) const main = await mainBranch(worktreePath)