1// Parse `git diff`/`git log -p` output into files and hunks.2import type { DiffFile, Hunk, Stats } from './types.ts';34const MAX_LINES_PER_FILE = 3000;56function unquote(p: string): string {7 if (!p.startsWith('"')) return p;8 // C-style quoted path; decode octal escapes as UTF-8 bytes.9 const bytes: number[] = [];10 const escapes: Record<string, number> = { n: 10, t: 9, '"': 34, '\\': 92 };11 for (let i = 1; i < p.length - 1; i++) {12 const c = p[i];13 if (c !== '\\') {14 bytes.push(...Buffer.from(c));15 continue;16 }17 const n = p[++i];18 if (/[0-7]/.test(n)) {19 bytes.push(parseInt(p.slice(i, i + 3), 8));20 i += 2;21 } else bytes.push(escapes[n] ?? n.charCodeAt(0));22 }23 return Buffer.from(bytes).toString();24}2526function stripPrefix(p: string): string | null {27 if (p === '/dev/null') return null;28 return unquote(p).replace(/^[ab]\//, '');29}3031export function parseDiff(text: string): DiffFile[] {32 const files: DiffFile[] = [];33 let f: DiffFile | null = null;34 let h: Hunk | null = null;35 let oldNo = 0;36 let newNo = 0;37 const lines = text.split('\n');38 if (lines.at(-1) === '') lines.pop();39 for (const line of lines) {40 if (line.startsWith('diff --git ')) {41 const rest = line.slice(11);42 const half = (rest.length - 1) / 2;43 const guess = Number.isInteger(half) && rest.slice(2, half) === rest.slice(half + 3) ? rest.slice(2, half) : null;44 f = { oldPath: guess, newPath: guess, path: '', status: 'M', binary: false, hunks: [], add: 0, del: 0, truncated: false, nlines: 0, mode: null };45 files.push(f);46 h = null;47 continue;48 }49 if (!f) continue;50 if (!h) {51 if (line.startsWith('new file mode')) {52 f.status = 'A';53 f.mode = line.slice(14);54 } else if (line.startsWith('deleted file mode')) f.status = 'D';55 else if (line.startsWith('rename from ')) {56 f.status = 'R';57 f.oldPath = unquote(line.slice(12));58 } else if (line.startsWith('rename to ')) f.newPath = unquote(line.slice(10));59 else if (line.startsWith('copy from ')) {60 f.status = 'C';61 f.oldPath = unquote(line.slice(10));62 } else if (line.startsWith('copy to ')) f.newPath = unquote(line.slice(8));63 else if (line.startsWith('Binary files ')) f.binary = true;64 else if (line.startsWith('index ')) {65 const m = /^index ([0-9a-f]+)\.\.([0-9a-f]+)/.exec(line);66 if (m) f.oids = [m[1], m[2]];67 }68 else if (line.startsWith('--- ')) f.oldPath = stripPrefix(line.slice(4)) ?? f.oldPath;69 else if (line.startsWith('+++ ')) {70 const p = stripPrefix(line.slice(4));71 if (p) f.newPath = p;72 }73 }74 if (line.startsWith('@@')) {75 const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);76 oldNo = m ? Number(m[1]) : 0;77 newNo = m ? Number(m[2]) : 0;78 h = { header: line, lines: [] };79 f.hunks.push(h);80 continue;81 }82 if (!h) continue;83 const t = line[0];84 if (t === '+') f.add++;85 else if (t === '-') f.del++;86 if ((f.nlines ?? 0) >= MAX_LINES_PER_FILE) {87 f.truncated = true;88 continue;89 }90 f.nlines = (f.nlines ?? 0) + 1;91 if (t === '+') h.lines.push({ t, text: line.slice(1), o: null, n: newNo++ });92 else if (t === '-') h.lines.push({ t, text: line.slice(1), o: oldNo++, n: null });93 else if (t === '\\') h.lines.push({ t, text: line, o: null, n: null });94 else h.lines.push({ t: ' ', text: line.slice(1), o: oldNo++, n: newNo++ });95 }96 for (const x of files) x.path = (x.status === 'D' ? x.oldPath : x.newPath ?? x.oldPath) ?? '';97 return files;98}99100export function diffStats(files: DiffFile[]): Stats {101 return {102 files: files.length,103 add: files.reduce((s, f) => s + f.add, 0),104 del: files.reduce((s, f) => s + f.del, 0),105 };106}