/** * Minimal unified-diff parser. * * Takes the raw text produced by `git diff`, `glab mr diff --raw`, or * `gh pr diff` and turns it into a structured, per-file model that the * review panel can navigate and render. */ export type DiffLineType = "add" | "del" | "ctx"; export interface DiffLine { type: DiffLineType; text: string; oldLine?: number; newLine?: number; } export interface Hunk { header: string; lines: DiffLine[]; } export type FileStatus = "added" | "deleted" | "renamed" | "modified"; export interface FileDiff { /** Path shown to the user (new path, or old path for deletions). */ path: string; oldPath: string; newPath: string; status: FileStatus; additions: number; deletions: number; hunks: Hunk[]; /** Raw `diff --git ...` block text, suitable for renderDiff(). */ raw: string; binary: boolean; } const FILE_HEADER_RE = /^diff --git a\/(.*) b\/(.*)$/; const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; function stripTrailingCR(line: string): string { return line.endsWith("\r") ? line.slice(0, -1) : line; } /** Split raw diff text into per-file blocks, keeping each block's own text. */ function splitFileBlocks(diffText: string): string[] { const lines = diffText.split("\n").map(stripTrailingCR); const blocks: string[][] = []; for (const line of lines) { if (FILE_HEADER_RE.test(line)) blocks.push([line]); else if (blocks.length > 0) blocks[blocks.length - 1]!.push(line); } return blocks.map((block) => block.join("\n")); } function parseFileBlock(block: string): FileDiff | undefined { const lines = block.split("\n"); const headerMatch = FILE_HEADER_RE.exec(lines[0] ?? ""); if (!headerMatch) return undefined; const oldPath = headerMatch[1]!; const newPath = headerMatch[2]!; let status: FileStatus = oldPath === newPath ? "modified" : "renamed"; let binary = false; for (const line of lines) { if (line.startsWith("new file mode")) status = "added"; else if (line.startsWith("deleted file mode")) status = "deleted"; else if (line.startsWith("Binary files")) binary = true; } const hunks: Hunk[] = []; let additions = 0; let deletions = 0; let currentHunk: Hunk | undefined; let oldLine = 0; let newLine = 0; for (const line of lines) { const hunkMatch = HUNK_HEADER_RE.exec(line); if (hunkMatch) { oldLine = Number(hunkMatch[1]); newLine = Number(hunkMatch[3]); currentHunk = { header: line, lines: [] }; hunks.push(currentHunk); continue; } if (!currentHunk) continue; if (line.startsWith("+") && !line.startsWith("+++")) { currentHunk.lines.push({ type: "add", text: line.slice(1), newLine: newLine++ }); additions++; } else if (line.startsWith("-") && !line.startsWith("---")) { currentHunk.lines.push({ type: "del", text: line.slice(1), oldLine: oldLine++ }); deletions++; } else if (line.startsWith(" ")) { currentHunk.lines.push({ type: "ctx", text: line.slice(1), oldLine: oldLine++, newLine: newLine++ }); } else if (line.startsWith("\\")) { // "\ No newline at end of file" — ignore. } } const path = status === "deleted" ? oldPath : newPath; return { path, oldPath, newPath, status, additions, deletions, hunks, raw: block, binary }; } export function parseDiff(diffText: string): FileDiff[] { if (!diffText.trim()) return []; return splitFileBlocks(diffText) .map(parseFileBlock) .filter((file): file is FileDiff => file !== undefined); } /** Stable content hash for a file's diff, used to detect "diff changed since last viewed". */ export function hashFileDiff(file: FileDiff): string { let hash = 0; const input = file.raw; for (let i = 0; i < input.length; i++) { hash = (Math.imul(31, hash) + input.charCodeAt(i)) | 0; } return hash.toString(36); }