/** * Unified-diff parsing for the commit view. * * The backend exposes per-commit diffs as raw patch text * (`GET /repos/{s}/{r}/diff/raw?base=&head=`) plus a numstat list, and there is * no structured JSON diff — so we parse the patch here. File sections are kept * as raw text and hunks are parsed lazily: an initial-import commit can carry * megabytes of patch, and rendering every line of every file at once would * freeze the page. */ import { getLocale, translate } from '@app/i18n' export type DiffFileStatus = 'added' | 'deleted' | 'renamed' | 'copied' | 'modified' | 'binary' export interface DiffFile { path: string oldPath: string | null status: DiffFileStatus /** Patch section for this file, parsed on demand by `parseHunks`. */ raw: string additions: number deletions: number } export interface DiffLine { kind: 'context' | 'add' | 'del' oldNo: number | null newNo: number | null text: string } export interface DiffHunk { header: string lines: DiffLine[] } /** Git's empty tree — the base for root commits (mirrors the server). */ export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' /** `diff --git` appears at the start of a file section; the header line of the * whole patch is empty, so we only split on real boundaries. */ export function splitDiffFiles(text: string): DiffFile[] { if (!text.trim()) return [] const chunks = text.split(/(?=^diff --git )/m).filter((chunk) => chunk.startsWith('diff --git ')) return chunks.map((chunk) => { const header = /^diff --git a\/(.*?) b\/(.*?)$/m.exec(chunk) let path = header ? unquoteGitPath(header[2] ?? '') : '' let oldPath: string | null = header ? unquoteGitPath(header[1] ?? '') : null const plus = /^\+\+\s+(.*)$/m.exec(chunk) ?? /^\+\+\+\s+(.*)$/m.exec(chunk) const minus = /^---\s+(.*)$/m.exec(chunk) const plusTarget = plus?.[1]?.trim() ?? '' const minusTarget = minus?.[1]?.trim() ?? '' if (plusTarget && plusTarget !== '/dev/null') path = stripAB(plusTarget) if (minusTarget && minusTarget !== '/dev/null') oldPath = stripAB(minusTarget) const renamed = /^rename from (.*)$/m.exec(chunk) if (renamed) oldPath = unquoteGitPath(renamed[1].trim()) let status: DiffFileStatus = 'modified' if (/^new file mode /m.test(chunk) || minusTarget === '/dev/null') status = 'added' else if (/^deleted file mode /m.test(chunk) || plusTarget === '/dev/null') status = 'deleted' else if (/^rename from /m.test(chunk)) status = 'renamed' else if (/^copy from /m.test(chunk)) status = 'copied' else if (/^Binary files /m.test(chunk)) status = 'binary' // An added file has no predecessor to show. if (status === 'added') oldPath = null let additions = 0 let deletions = 0 for (const line of chunk.split('\n')) { if (line.startsWith('+++') || line.startsWith('---')) continue if (line.startsWith('+')) additions += 1 else if (line.startsWith('-')) deletions += 1 } return { path: path || translate(getLocale(), 'repo.unknownPath'), oldPath, status, raw: chunk, additions, deletions, } }) } /** `a/foo.py` / `"a/foo.py"` → `foo.py`. Git always prefixes the a/ b/ side, * and the prefix can hide inside the quotes, so unquote first. */ function stripAB(value: string): string { const token = value.split(/\s/)[0] ?? '' const unquoted = unquoteGitPath(token) return unquoted === token ? token.replace(/^[ab]\//, '') : unquoted.replace(/^[ab]\//, '') } /** * Git C-quotes paths with unusual bytes: `"caf\303\251.png"`, `"a\tb"`. * Octal escapes are raw bytes, so decode them as UTF-8; anything we cannot * parse is returned untouched rather than guessed at. */ export function unquoteGitPath(value: string): string { if (!value.startsWith('"') || !value.endsWith('"')) return value const inner = value.slice(1, -1) if (!inner.includes('\\')) return inner const bytes: number[] = [] for (let i = 0; i < inner.length; i += 1) { const char = inner[i] if (char !== '\\') { bytes.push(...new TextEncoder().encode(char)) continue } const next = inner[i + 1] const octal = /^\\([0-7]{1,3})/.exec(inner.slice(i)) if (octal) { bytes.push(Number.parseInt(octal[1], 8)) i += octal[1].length continue } const simple: Record = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', '"': '"', '\\': '\\' } if (next && simple[next] !== undefined) { bytes.push(...new TextEncoder().encode(simple[next])) i += 1 continue } bytes.push(...new TextEncoder().encode(next ?? '')) i += 1 } return new TextDecoder('utf-8', { fatal: false }).decode(new Uint8Array(bytes)) } /** Line cap so one giant file cannot take the page down. */ export const MAX_DIFF_LINES = 1500 export function parseHunks(raw: string, cap = MAX_DIFF_LINES): { hunks: DiffHunk[]; truncated: boolean } { const hunks: DiffHunk[] = [] let current: DiffHunk | null = null let oldNo = 0 let newNo = 0 let used = 0 let truncated = false for (const line of raw.split('\n')) { if (line.startsWith('@@')) { const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line) oldNo = match ? Number(match[1]) : 0 newNo = match ? Number(match[2]) : 0 current = { header: line, lines: [] } hunks.push(current) continue } if (!current) continue if ( line.startsWith('index ') || line.startsWith('\\ ') || /^(new|old|deleted|rename|copy|Binary|---|\+\+\+) /.test(line) ) { continue } if (line.startsWith('+')) { current.lines.push({ kind: 'add', oldNo: null, newNo: newNo++, text: line.slice(1) }) used += 1 } else if (line.startsWith('-')) { current.lines.push({ kind: 'del', oldNo: oldNo++, newNo: null, text: line.slice(1) }) used += 1 } else { // Trailing-whitespace-stripped context lines arrive as an empty string. current.lines.push({ kind: 'context', oldNo: oldNo++, newNo: newNo++, text: line.startsWith(' ') ? line.slice(1) : line, }) used += 1 } if (used >= cap) { truncated = true break } } return { hunks, truncated } } /** numstat rows keyed by path — authoritative +/- counts (patch counting above * is only a fallback for files numstat does not list). */ export interface NumstatRow { path: string old_path?: string | null additions: number deletions: number } export function applyNumstat(files: DiffFile[], rows: NumstatRow[]): DiffFile[] { if (!rows.length) return files const byPath = new Map(rows.map((row) => [row.path, row])) return files.map((file) => { const row = byPath.get(file.path) ?? (file.oldPath ? byPath.get(file.oldPath) : undefined) if (!row) return file return { ...file, additions: row.additions, deletions: row.deletions } }) } /* ---- split (side-by-side) rendering ------------------------------------- */ export interface SplitRow { left: DiffLine | null right: DiffLine | null } /** * Pair a hunk's linear lines into side-by-side rows: a deletion followed by an * addition becomes one row, context repeats on both sides, and lone adds/dels * leave the opposite cell blank. */ export function toSplitRows(lines: DiffLine[]): SplitRow[] { const rows: SplitRow[] = [] for (let i = 0; i < lines.length; i += 1) { const line = lines[i] if (line.kind === 'del') { const next = lines[i + 1] if (next?.kind === 'add') { rows.push({ left: line, right: next }) i += 1 } else { rows.push({ left: line, right: null }) } continue } if (line.kind === 'add') { rows.push({ left: null, right: line }) continue } rows.push({ left: line, right: line }) } return rows } export type DiffStyle = 'unified' | 'split' /** Anything the server does not recognise behaves as unified there too. */ export function normalizeDiffStyle(value: string | null | undefined): DiffStyle { return value === 'split' ? 'split' : 'unified' }