import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; export interface TurnDiffFileSummary { readonly path: string; readonly additions: number; readonly deletions: number; } export function parseTurnDiffFilesFromUnifiedDiff( diff: string, ): ReadonlyArray { const normalized = diff.replace(/\r\n/g, "\n").trim(); if (normalized.length === 0) { return []; } const parsedPatches = parsePatchFiles(normalized); const files = parsedPatches.flatMap((patch) => patch.files.map((file) => ({ path: file.name, additions: file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0), deletions: file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0), })), ); return files.toSorted((left, right) => left.path.localeCompare(right.path)); } const SIMPLE_ESCAPES: Record = { "\\": 0x5c, '"': 0x22, a: 0x07, b: 0x08, f: 0x0c, n: 0x0a, r: 0x0d, t: 0x09, v: 0x0b, }; function stripGitPathQuoting(value: string): string { if (!(value.startsWith('"') && value.endsWith('"') && value.length >= 2)) { return value; } // Git C-style quoting: octal escapes are raw UTF-8 BYTES (e.g. `ä` is // `\303\244`), so decode into a byte buffer first and UTF-8-decode the // whole path at the end — per-escape String.fromCharCode would yield // mojibake that never matches attribution map keys. const inner = value.slice(1, -1); const bytes: Array = []; const encoder = new TextEncoder(); for (let index = 0; index < inner.length; index += 1) { const char = inner[index]!; if (char !== "\\" || index === inner.length - 1) { bytes.push(...encoder.encode(char)); continue; } const next = inner[index + 1]!; const simple = SIMPLE_ESCAPES[next]; if (simple !== undefined) { bytes.push(simple); index += 1; continue; } const octal = /^[0-7]{1,3}/.exec(inner.slice(index + 1))?.[0]; if (octal) { bytes.push(parseInt(octal, 8) & 0xff); index += octal.length; continue; } bytes.push(...encoder.encode(char)); } return new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(bytes)); } /** * Parse the post-image path out of a `diff --git a/ b/` header. * * Only trustworthy when both paths are quoted or the two paths are equal * (no rename): for unquoted renamed paths containing spaces the split is * ambiguous, so return null and let the caller keep the section. */ function resolveDiffGitHeaderPath(headerLine: string): string | null { const spec = headerLine.slice("diff --git ".length).trim(); const quotedMatch = /^("(?:[^"\\]|\\.)*"|\S+) ("(?:[^"\\]|\\.)*"|\S+)$/.exec(spec); if (quotedMatch) { const source = stripGitPathQuoting(quotedMatch[1]!); const target = stripGitPathQuoting(quotedMatch[2]!); if (source.startsWith("a/") && target.startsWith("b/")) { return target.slice(2); } } // Paths with spaces are unquoted in this header; recover the symmetric // a/X b/X form (covers everything but renames of space-containing paths). if (spec.length % 2 === 1) { const midpoint = (spec.length - 1) / 2; const source = spec.slice(0, midpoint); const target = spec.slice(midpoint + 1); if (source.startsWith("a/") && target.startsWith("b/") && source.slice(2) === target.slice(2)) { return target.slice(2); } } return null; } /** * Extract the post-image path of one `diff --git` section, preferring the * `+++ b/` line and falling back to `--- a/` for deletions. Binary and * mode-only sections have neither, so fall back to rename/copy metadata and * finally the `diff --git` header itself. Returns null when the section is * unparseable; callers should keep such sections. */ function resolveDiffSectionPath(section: string): string | null { // `--- a/` precedes `+++ b/` in a section, so resolve the post-image path // in a full pass before falling back — for renames the two differ, and the // attribution map is keyed by the post-image path. let fallbackSource: string | null = null; let renameTarget: string | null = null; let headerPath: string | null = null; for (const line of section.split("\n")) { if (line.startsWith("+++ ")) { const target = stripGitPathQuoting(line.slice(4).trim()); if (target !== "/dev/null") { return target.startsWith("b/") ? target.slice(2) : target; } } else if (line.startsWith("--- ") && fallbackSource === null) { const source = stripGitPathQuoting(line.slice(4).trim()); if (source !== "/dev/null" && source.startsWith("a/")) { fallbackSource = source.slice(2); } } else if ( (line.startsWith("rename to ") || line.startsWith("copy to ")) && renameTarget === null ) { renameTarget = stripGitPathQuoting(line.slice(line.indexOf(" to ") + 4)); } else if (line.startsWith("diff --git ") && headerPath === null) { headerPath = resolveDiffGitHeaderPath(line); } } return fallbackSource ?? renameTarget ?? headerPath; } function splitUnifiedDiffSections(diff: string): ReadonlyArray { const normalized = diff.replace(/\r\n/g, "\n"); if (normalized.trim().length === 0) { return []; } const sections: Array = []; let currentStart = -1; const lines = normalized.split("\n"); const flush = (endExclusive: number) => { if (currentStart >= 0) { sections.push(lines.slice(currentStart, endExclusive).join("\n")); } }; for (let index = 0; index < lines.length; index += 1) { if (lines[index]!.startsWith("diff --git ")) { flush(index); currentStart = index; } } flush(lines.length); return sections; } /** * List the resolvable post-image paths of a unified diff's file sections. * Unparseable sections are omitted. */ export function listUnifiedDiffPaths(diff: string): ReadonlyArray { const paths: Array = []; for (const section of splitUnifiedDiffSections(diff)) { const path = resolveDiffSectionPath(section); if (path !== null) { paths.push(path); } } return paths; } /** * Remove file sections from a unified diff by path. * * Sections whose path cannot be determined are kept (fail toward showing). */ export function filterUnifiedDiffFiles( diff: string, shouldKeepPath: (path: string) => boolean, ): string { const sections = splitUnifiedDiffSections(diff); if (sections.length === 0) { return diff; } const kept = sections.filter((section) => { const path = resolveDiffSectionPath(section); return path === null || shouldKeepPath(path); }); if (kept.length === sections.length) { return diff; } return kept.join("\n"); }