zz_

app/agent/threeWayMerge.ts

zzmq.dev@gmail.com/zz / app / agent / threeWayMerge.ts @ 5e5720fb 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 };
}