Skip to content
Clone
main
241 lines 8.0 KB
/**
* 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<string, string> = { 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'
}