// Git-format patches, as `git diff` and `jj diff --git` write them, read into files and their hunks. /** One file's change. */ export type FileDiff = { /** Where it is after the change; where it was, for a deletion. */ path: string; /** Where it was before, when it was renamed or copied. */ from: string | null; status: "added" | "deleted" | "modified" | "renamed" | "copied"; /** Its modes either side, as far as the patch says: such as `100755` once a script is executable. */ modes: { old: string | null; new: string | null }; binary: boolean; hunks: Hunk[]; }; /** * A stretch of the file: the line it starts at on either side, and its lines, each marked `" "`, `"-"` or `"+"`, or * `"\\"` when the line before it has no newline. */ export type Hunk = { old: number; new: number; lines: string[] }; const HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; /** The files `patch` changes, in its order; whatever surrounds them, such as a commit's header, is skipped. */ export function parsePatch(patch: string): FileDiff[] { const lines = patch.split("\n"); const files: FileDiff[] = []; let i = 0; while (i < lines.length) { const header = lines[i++]; if (!header.startsWith("diff --git ")) continue; const [a, b] = headerPaths(header.slice("diff --git ".length)); let old: string | null = a; let neu: string | null = b; let status: FileDiff["status"] = "modified"; const modes: FileDiff["modes"] = { old: null, new: null }; let binary = false; const hunks: Hunk[] = []; for (; i < lines.length && !lines[i].startsWith("diff --git "); i++) { const line = lines[i]; const hunk = line.match(HUNK); if (hunk) { let oldLeft = hunk[2] === undefined ? 1 : Number(hunk[2]); let newLeft = hunk[4] === undefined ? 1 : Number(hunk[4]); const body: string[] = []; while (i + 1 < lines.length && (oldLeft > 0 || newLeft > 0 || lines[i + 1][0] === "\\")) { const next = lines[++i]; // `diff.suppressBlankEmpty` writes an empty context line without its mark const mark = next === "" ? " " : next[0]; if (mark === " ") oldLeft--, newLeft--; else if (mark === "-") oldLeft--; else if (mark === "+") newLeft--; else if (mark !== "\\") { i--; break; } body.push(next === "" ? " " : next); } hunks.push({ old: Number(hunk[1]), new: Number(hunk[3]), lines: body }); continue; } const [, key, value] = line.match( /^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|copy from|copy to|index) (.*)$/, ) ?? []; if (line.startsWith("--- ")) old = sidePath(line.slice(4), "a/"); else if (line.startsWith("+++ ")) neu = sidePath(line.slice(4), "b/"); else if (key === "new file mode") (status = "added"), (modes.new = value), (old = null); else if (key === "deleted file mode") (status = "deleted"), (modes.old = value), (neu = null); else if (key === "old mode") modes.old = value; else if (key === "new mode") modes.new = value; else if (key === "rename from") (status = "renamed"), (old = unquote(value)); else if (key === "rename to") neu = unquote(value); else if (key === "copy from") (status = "copied"), (old = unquote(value)); else if (key === "copy to") neu = unquote(value); else if (key === "index") { const mode = value.split(" ")[1]; if (mode) modes.old ??= mode, modes.new ??= mode; } else if (line.startsWith("Binary files ") || line === "GIT binary patch") binary = true; } if (status === "modified" && old !== null && neu !== null && old !== neu) status = "renamed"; files.push({ path: neu ?? old ?? b, from: status === "renamed" || status === "copied" ? old : null, status, modes, binary, hunks, }); } return files; } /** The two paths of `diff --git a/x b/y`, which are ambiguous unquoted; patches say them again where it matters. */ function headerPaths(rest: string): [string, string] { if (rest.startsWith('"')) { const [a, end] = quoted(rest); const after = rest.slice(end + 2); return [strip(a, "a/"), strip(after.startsWith('"') ? quoted(after)[0] : after, "b/")]; } if (rest.endsWith('"')) { const at = rest.lastIndexOf(' "'); return [strip(rest.slice(0, at), "a/"), strip(quoted(rest.slice(at + 1))[0], "b/")]; } // unless one was renamed or copied, which says so on its own lines, both sides are the same path const half = (rest.length - 1) / 2; if (Number.isInteger(half) && rest.slice(2, half) === rest.slice(half + 3)) { return [rest.slice(2, half), rest.slice(half + 3)]; } const at = rest.indexOf(" b/"); return [strip(rest.slice(0, at), "a/"), rest.slice(at + 3)]; } /** A `---` or `+++` line's path, or `null` for `/dev/null`; git ends those with spaces in them with a tab. */ function sidePath(text: string, prefix: string): string | null { const path = unquote(text.replace(/\t$/, "")); return path === "/dev/null" ? null : strip(path, prefix); } const strip = (path: string, prefix: string) => path.startsWith(prefix) ? path.slice(prefix.length) : path; const unquote = (text: string) => text.startsWith('"') ? quoted(text)[0] : text; const ESCAPES: Record = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92, }; /** A C-quoted string at the start of `text`, as git quotes unusual paths, and the index of its closing quote. */ function quoted(text: string): [string, number] { const bytes: number[] = []; const encoder = new TextEncoder(); let i = 1; for (; i < text.length && text[i] !== '"'; i++) { if (text[i] !== "\\") { bytes.push(...encoder.encode(text[i])); continue; } const octal = text.slice(i + 1, i + 4); if (/^[0-7]{3}$/.test(octal)) { bytes.push(parseInt(octal, 8)); i += 3; } else { i++; bytes.push(ESCAPES[text[i]] ?? text.charCodeAt(i)); } } return [new TextDecoder().decode(new Uint8Array(bytes)), i]; } /** `file` with only `context` unchanged lines around each change, for files too long to send whole. */ export function narrow(file: FileDiff, context: number): FileDiff { const hunks: Hunk[] = []; for (const hunk of file.hunks) { let oldLine = hunk.old; let newLine = hunk.new; const changed = hunk.lines.map((line) => line[0] === "-" || line[0] === "+"); // a missing newline belongs to the line before it const near = hunk.lines.map((line, i) => line[0] === "\\" || changed.slice(Math.max(0, i - context), i + context + 1).some(Boolean) ); let current: Hunk | null = null; for (const [i, line] of hunk.lines.entries()) { if (near[i]) { current ??= hunks[hunks.push({ old: oldLine, new: newLine, lines: [] }) - 1]; current.lines.push(line); } else current = null; if (line[0] === " " || line[0] === "-") oldLine++; if (line[0] === " " || line[0] === "+") newLine++; } } return { ...file, hunks }; }