app/agent/threeWayMerge.ts
zzmq.dev@gmail.com/zz / app / agent / threeWayMerge.ts
@ 31943d52
Download
Ladder Summary
- file The module provides a line‑level three‑way merge utility that compares a set of proposed file contents against the current `HEAD` of a Git repository using Git’s own `merge-file` (with `--diff3`) to produce clean results or conflict markers. It exports the `MergeResult` interface (describing the clean flag, merged files and any conflicted files) and the `threeWayMerge` function, which determines which proposal files overlap with changes made since a given base commit, runs the merge in a temporary directory, and returns the merged contents together with any conflict information. The internal helper `changedPaths` (not exported) is used to detect files altered by other commits.
// A real line-level 3-way merge (git's own `merge-file`, the same
// machinery `git merge`/`git rebase` use), not symbol-name comparison.
// Replaces asking a model to regenerate whole files from two divergent
// blobs -- unreliable even in the trivial additive case (observed
// directly: gpt-oss-120b dropped the same export identically on every
// retry when asked to "reconcile" two full versions). Most conflicts
// aren't real conflicts at all once you look at lines instead of
// names: two proposals touching different files, or different lines
// of the same file, merge cleanly with zero model involvement. Only a
// genuine overlapping-line conflict needs any judgment at all, and
// even then the model only ever sees the few marked lines that
// actually conflict, not the whole file.
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileContentAtCommit } from "../engine/ingest.js";
export interface MergeResult {
clean: boolean;
/** Final content for every proposal file that merged cleanly (including ones no other change touched at all). */
files: { path: string; content: string }[];
/** Files with a real line-level overlap -- content includes git's own <<<<<<</=======/>>>>>>> (and ||||||| base, via --diff3) markers. */
conflicts: { path: string; markedContent: string }[];
}
function changedPaths(repoDir: string, fromSha: string, toSha: string): Set<string> {
if (fromSha === toSha) return new Set();
const out = execFileSync("git", ["-C", repoDir, "diff", "--name-only", fromSha, toSha], { encoding: "utf8" });
return new Set(out.split("\n").filter(Boolean));
}
export function threeWayMerge(repoDir: string, baseSha: string, proposalFiles: { path: string; content: string }[]): MergeResult {
const headSha = execFileSync("git", ["-C", repoDir, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
if (baseSha === headSha) {
// Nothing landed since this proposal was generated -- no merge to do.
return { clean: true, files: proposalFiles, conflicts: [] };
}
const changedByOthers = changedPaths(repoDir, baseSha, headSha);
const files: { path: string; content: string }[] = [];
const conflicts: { path: string; markedContent: string }[] = [];
const tmp = mkdtempSync(join(tmpdir(), "zz-merge-"));
try {
for (const f of proposalFiles) {
if (!changedByOthers.has(f.path)) {
// Nobody else touched this path -- the proposal's version applies cleanly on top of the current tree.
files.push(f);
continue;
}
const basePath = join(tmp, "base");
const oursPath = join(tmp, "ours");
const theirsPath = join(tmp, "theirs");
writeFileSync(basePath, fileContentAtCommit(repoDir, baseSha, f.path));
writeFileSync(oursPath, fileContentAtCommit(repoDir, headSha, f.path));
writeFileSync(theirsPath, f.content);
try {
// -p: write the merge result to stdout instead of mutating
// `ours` in place. --diff3 adds the base content into the
// conflict markers too, so a model (or a human) resolving one
// can see what changed on each side, not just the two results.
const merged = execFileSync("git", ["merge-file", "-p", "--diff3", oursPath, basePath, theirsPath], { encoding: "utf8" });
files.push({ path: f.path, content: merged });
} catch (err) {
const e = err as { status?: number; stdout?: string };
if (e.status === 1 && typeof e.stdout === "string") {
conflicts.push({ path: f.path, markedContent: e.stdout });
} else {
throw err;
}
}
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
return { clean: conflicts.length === 0, files, conflicts };
}