Ctrl K
ComunidadGuías…

utils/rangeDiff.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { log } from "./cli.ts";
import { $ } from "./shell.ts";

type ComputeIncrementalDiffParams = {
  baseBranch: string;
  beforeSha: string;
  headSha: string;
  /** tips the base branch was force-pushed away from, which either version may still be built on */
  formerBaseTips: string[];
};

/**
 * computes the incremental diff between two versions of a PR using range-diff
 * on virtual squash commits created via `git commit-tree`.
 *
 * each PR version is squashed into a single synthetic commit (merge-base → tip tree),
 * then range-diff compares those two single-commit ranges. this:
 * - isolates each version's net effect (base branch noise eliminated via per-version merge bases)
 * - avoids commit-matching issues that raw range-diff has with rebases/squashes/reordering
 * - creates only loose git objects, no branches or refs (unlike temp-branch squash approaches)
 *
 * unlike fetchAndFormatPrDiff/formatFilesWithLineNumbers, this output has no line numbers.
 * range-diff compares *patches* (diffs-of-diffs), not file trees — its hunk headers are
 * `@@ file.ts` breadcrumbs, not positional `@@ -X,Y +A,B @@` markers. reconstructing
 * line numbers would require cross-referencing with the v2 diff or content-matching against
 * file trees, both of which are fragile (duplicate lines, hunk boundary shifts after rebase).
 * a structured interdiff approach (diff two parsed patches, compare only +/- keys via Myers)
 * could approximate line numbers but loses semantic precision: range-diff understands patch
 * structure natively (rename detection, hunk-aware matching, dual-prefix inner/outer changes),
 * while flat key-sequence comparison can misalign duplicate lines and can't distinguish
 * "new addition to the PR" from "existing code newly modified by the PR". range-diff is the
 * right abstraction here — the incremental diff answers "how did the changeset evolve?",
 * not "where in the file is this?", and forcing positional line numbers onto it would be
 * semantically misleading.
 *
 * alternatives considered:
 * - plain git diff (two-tree or three-dot): includes base branch changes, no PR isolation
 * - patch-text diffing (interdiff / diff-of-diffs): fragile, hunk offset noise on rebase
 * - range-diff on raw commit ranges: confused by commit reorganization across force-pushes
 */
export type IncrementalDiffResult =
  /** range-diff ran and produced a delta */
  | { status: "ok"; diff: string }
  /** range-diff ran and the two versions are identical */
  | { status: "empty" }
  /** range-diff could not run — most often `beforeSha` has no ancestry to
   *  merge-base against on a shallow clone. distinct from "empty" on purpose:
   *  they were the same `null` before, so `checkout_pr` could not tell the
   *  agent whether there was nothing to see or whether we had simply failed.
   *  see #1139. */
  | { status: "unavailable"; reason: string };

export function computeIncrementalDiff(
  params: ComputeIncrementalDiffParams
): IncrementalDiffResult {
  try {
    const tips = [`origin/${params.baseBranch}`, ...params.formerBaseTips];
    const oldBase = versionBase({ sha: params.beforeSha, tips });
    const newBase = versionBase({ sha: params.headSha, tips });
    const replayed =
      oldBase === newBase ? null : replay({ sha: params.beforeSha, from: oldBase, onto: newBase });
    // $1/$2=the old version's base and tree (newBase and the replayed tree once replayed),
    // $3=newBase, $4=headSha. one commit per side, so a creation factor this high always pairs
    // them; the default 60 calls a reworked delta a rewrite and prints no patch
    const raw = $(
      "sh",
      [
        "-c",
        "git range-diff --no-color --creation-factor=1000 " +
          '"$1..$(git commit-tree "$2^{tree}" -p "$1" -m x)" ' +
          '"$3..$(git commit-tree "$4^{tree}" -p "$3" -m x)"',
        "--",
        replayed ? newBase : oldBase,
        replayed ?? params.beforeSha,
        newBase,
        params.headSha,
      ],
      { log: false }
    );

    const processed = postProcessRangeDiff(raw);
    return processed === null ? { status: "empty" } : { status: "ok", diff: processed };
  } catch (e) {
    const reason = e instanceof Error ? e.message : String(e);
    // `log.warning`, not `log.debug`: debug is gated on LOG_LEVEL/ACTIONS_STEP_DEBUG,
    // neither of which is set in production — so 174 of 744 IncrementalReview runs
    // lost their incremental diff completely silently. see #1139.
    log.warning(`» incremental diff unavailable: range-diff failed: ${reason}`);
    return { status: "unavailable", reason };
  }
}

function git(args: string[]): string {
  return $("git", args, { log: false }).trim();
}

/**
 * the base a version was built on. after the base branch is force-pushed (a restacked stacked PR)
 * a version still on the old branch has its merge base with `origin/<base>` fall back to the fork
 * point, dragging the whole old base branch into the delta, so the candidate leaving the fewest
 * commits under `sha` wins. a former tip that was never fetched has no merge base and drops out.
 */
function versionBase(params: { sha: string; tips: string[] }): string {
  let best: { tip: string; sha: string; commits: number } | undefined;
  for (const tip of params.tips) {
    let sha: string;
    try {
      sha = git(["merge-base", params.sha, tip]);
    } catch {
      continue;
    }
    const commits = Number(git(["rev-list", "--count", `${sha}..${params.sha}`]));
    if (!best || commits < best.commits) best = { tip, sha, commits };
  }
  if (!best) throw new Error(`no merge base between ${params.sha} and the base branch`);
  if (best.tip !== params.tips[0]) {
    log.info(`» incremental diff: ${params.sha.slice(0, 7)} sits on former base tip ${best.tip}`);
  }
  return best.sha;
}

/**
 * the old version's tree replayed onto the new base, so whatever the new base already carries drops
 * out: a parent PR squash-merged into it (no merge base reaches those commits), or main's own changes
 * near the PR's lines. null when the replay conflicts, and each version is compared on its own base.
 */
function replay(params: { sha: string; from: string; onto: string }): string | null {
  try {
    return git([
      "merge-tree",
      "--write-tree",
      `--merge-base=${params.from}`,
      params.onto,
      params.sha,
    ]);
  } catch {
    log.info(
      `» incremental diff: ${params.sha.slice(0, 7)} does not replay onto the new base; comparing each on its own`
    );
    return null;
  }
}

function isDiffPrefix(ch: string): boolean {
  return ch === " " || ch === "+" || ch === "-";
}

/**
 * transforms git range-diff output into a clean incremental diff.
 *
 * range-diff content lines have two prefix characters:
 *   1st (outer): range-diff level — space (same in both), + (new only), - (old only)
 *   2nd (inner): original diff level — space (context), + (added), - (removed)
 *
 * stripping the inner prefix produces a standard unified-diff-like output where
 * +/- means "changed between PR versions" rather than "changed vs base branch".
 *
 * uses a streaming approach: a ring buffer of before-context lines is flushed when
 * a change is hit, then afterCount lines of after-context are emitted directly.
 * nearest preceding ## / @@ headers are force-included when outside the context window,
 * and so is the outer `@@ <file>[: <function>]` hunk header, which is often the only
 * line naming the file a change belongs to.
 */
export function postProcessRangeDiff(raw: string, contextLines = 3): string | null {
  if (!raw.trim()) return null;
  if (/^\d+:\s+\w+\s+=\s+\d+:/m.test(raw)) return null;

  type Line = { prefix: string; from: number; to: number; seq: number };

  const beforeBuf: Line[] = [];
  let lastOuterHdr: Line | null = null;
  let outerHdrEmitted = true;
  let lastFileHdr: Line | null = null;
  let lastHunkHdr: Line | null = null;
  let fileHdrEmitted = true;
  let hunkHdrEmitted = true;

  let out = "";
  let afterRemaining = 0;
  let lastEmittedSeq = -2;
  let seq = 0;
  let hasChanges = false;

  function emit(line: Line) {
    if (lastEmittedSeq >= 0 && line.seq > lastEmittedSeq + 1) out += (out ? "\n" : "") + "...";
    out += (out ? "\n" : "") + line.prefix + raw.slice(line.from, line.to);
    lastEmittedSeq = line.seq;
    if (lastOuterHdr?.seq === line.seq) outerHdrEmitted = true;
    if (lastFileHdr?.seq === line.seq) fileHdrEmitted = true;
    if (lastHunkHdr?.seq === line.seq) hunkHdrEmitted = true;
  }

  function flushBefore() {
    const headers = [
      outerHdrEmitted ? null : lastOuterHdr,
      fileHdrEmitted ? null : lastFileHdr,
      hunkHdrEmitted ? null : lastHunkHdr,
    ].filter((line): line is Line => line !== null);
    // in line order, so emitting a header never skips the context above it
    const pending = [...headers, ...beforeBuf.filter((line) => line.seq > lastEmittedSeq)].sort(
      (a, b) => a.seq - b.seq
    );
    for (const [i, line] of pending.entries()) {
      if (pending[i - 1]?.seq !== line.seq) emit(line);
    }
    beforeBuf.length = 0;
  }

  function isFileHeader(pos: number): boolean {
    return raw.startsWith("## ", pos) && !raw.startsWith("## Commit message", pos);
  }

  function markFileHeader(line: Line, emitted: boolean) {
    lastFileHdr = line;
    fileHdrEmitted = emitted;
    lastHunkHdr = null;
    hunkHdrEmitted = true;
  }

  let cursor = 0;
  while (cursor < raw.length) {
    const eol = raw.indexOf("\n", cursor);
    const lineEnd = eol === -1 ? raw.length : eol;

    if (raw.charCodeAt(cursor) >= 48 && raw.charCodeAt(cursor) <= 57) {
      cursor = lineEnd + 1;
      continue;
    }

    if (raw.startsWith("    @@", cursor)) {
      // range-diff labels an outer hunk with its nearest section, which names no file when
      // that section is its own metadata or commit message
      const label = raw.slice(cursor + 4, lineEnd);
      lastOuterHdr =
        label === "@@ Metadata" || label === "@@ Commit message"
          ? null
          : { prefix: "", from: cursor + 4, to: lineEnd, seq };
      outerHdrEmitted = false;
      lastFileHdr = null;
      fileHdrEmitted = true;
      lastHunkHdr = null;
      hunkHdrEmitted = true;
      beforeBuf.length = 0;
      afterRemaining = 0;
      seq++;
    } else if (lineEnd - cursor >= 5 && raw.startsWith("    ", cursor)) {
      const prefix = raw[cursor + 4];
      if (isDiffPrefix(prefix)) {
        const contentPos = cursor + 5;
        const isOuterChange = prefix !== " ";
        let line: Line;
        let isChange = false;

        if (contentPos >= lineEnd) {
          line = { prefix, from: lineEnd, to: lineEnd, seq };
        } else if (isDiffPrefix(raw[contentPos])) {
          isChange = isOuterChange;
          line = { prefix, from: contentPos + 1, to: lineEnd, seq };
          // git prints a file header as inner context, `     ## <file> ##`
          if (raw[contentPos] === " " && isFileHeader(contentPos + 1)) {
            markFileHeader(line, isChange);
          }
        } else {
          line = { prefix, from: contentPos, to: lineEnd, seq };
          if (isFileHeader(contentPos)) {
            markFileHeader(line, false);
          } else if (
            raw.startsWith("@@", contentPos) &&
            !raw.startsWith("@@ Metadata", contentPos)
          ) {
            lastHunkHdr = line;
            hunkHdrEmitted = false;
          }
        }

        if (isChange) {
          hasChanges = true;
          flushBefore();
          emit(line);
          afterRemaining = contextLines;
        } else if (afterRemaining > 0) {
          emit(line);
          afterRemaining--;
        } else {
          if (beforeBuf.length >= contextLines) beforeBuf.shift();
          beforeBuf.push(line);
        }

        seq++;
      }
    }

    cursor = lineEnd + 1;
  }

  return hasChanges ? out : null;
}