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() }