/** * src/http/diff.ts (N-diff-be, B1) — read-only structured git diff. * * The server stays a byte-shuttle: this is an out-of-band side-channel that runs * `git diff` in a directory and PARSES its text into DiffFile/DiffLine. Parsing * lives ONLY here (public/diff.ts is render-only — review #2). The frontend never * re-derives diff structure; it only renders these objects with textContent. * * Security (SP4, §B1.4): * - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS (SEC-M9). * - the trailing `--` terminates options so a path can't be read as a flag. * (path → repo three-way validation lives in the ROUTE layer, SEC-H7.) * - parsers NEVER throw: garbage lines degrade to `context`; getDiff is * best-effort and returns an empty result rather than rejecting (house style). * - diff content is carried verbatim in DiffLine.text — the FE renders it as * inert text (AC-B1.4), never HTML. * * FR-B1.9 (`?base=`) is intentionally deferred to P2 (review #13): it needs * a `git rev-parse --verify` allow-list before any revision reaches the CLI. */ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import type { Config, DiffFile, DiffHunk, DiffLine, DiffLineKind, DiffResult, FileStatus, } from '../types.js' const execFileAsync = promisify(execFile) // ── numstat (pure) ────────────────────────────────────────────────────────── /** One `git diff --numstat` row: `\t\t`; binary = `-\t-`. */ export interface NumstatEntry { added: number removed: number binary: boolean } /** Non-negative integer or 0 for `-`/junk (never NaN). */ function toCount(field: string): number { const n = Number.parseInt(field, 10) return Number.isFinite(n) && n >= 0 ? n : 0 } /** * Expand a numstat path field into its old/new forms. A rename is shown either * as `old => new` or, with a shared prefix/suffix, as `pre/{old => new}/suf`. * Non-renames return the same path for both. */ function expandNumstatPath(raw: string): { oldPath: string; newPath: string } { const braced = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(raw) if (braced !== null) { const [, pre = '', oldMid = '', newMid = '', suf = ''] = braced const collapse = (s: string): string => (pre + s + suf).replace(/\/{2,}/g, '/') return { oldPath: collapse(oldMid), newPath: collapse(newMid) } } const arrow = raw.split(' => ') if (arrow.length === 2) { return { oldPath: arrow[0]?.trim() ?? raw, newPath: arrow[1]?.trim() ?? raw } } return { oldPath: raw, newPath: raw } } /** * Parse `git diff --numstat` output into a path → counts map. Renames are keyed * under BOTH old and new paths so the unified-diff parser can find them by * whichever path it derived. Malformed lines are skipped; never throws. */ export function parseNumstat(out: string): Map { const map = new Map() if (typeof out !== 'string') return map for (const line of out.split('\n')) { if (line.trim() === '') continue const parts = line.split('\t') if (parts.length < 3) continue const addStr = parts[0] ?? '' const remStr = parts[1] ?? '' const rawPath = parts.slice(2).join('\t') const binary = addStr === '-' && remStr === '-' const entry: NumstatEntry = { added: binary ? 0 : toCount(addStr), removed: binary ? 0 : toCount(remStr), binary, } const { oldPath, newPath } = expandNumstatPath(rawPath) map.set(newPath, entry) if (oldPath !== newPath) map.set(oldPath, entry) } return map } // ── unified diff (pure) ────────────────────────────────────────────────────── /** Strip git's `a/`/`b/` prefix and optional C-quoting; `/dev/null` is kept. */ function stripDiffPath(raw: string): string { let s = raw.trim() if (s === '/dev/null') return s if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) { s = s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') } if (s.startsWith('a/') || s.startsWith('b/')) s = s.slice(2) return s } /** Best-effort path extraction from a `diff --git a/x b/y` header line. The * authoritative paths come from ---/+++/rename lines, which override this. */ function parseDiffGitLine(line: string): { oldPath: string; newPath: string } { const rest = line.slice('diff --git '.length) const sep = rest.indexOf(' b/') if (sep !== -1) { return { oldPath: stripDiffPath(rest.slice(0, sep)), newPath: stripDiffPath(rest.slice(sep + 1)) } } const p = stripDiffPath(rest) return { oldPath: p, newPath: p } } /** Classify one in-hunk line by its leading marker; unknown → context (spec). */ function classifyHunkLine(line: string): DiffLine { const marker = line.charAt(0) if (marker === '+') return { kind: 'added', text: line.slice(1) } if (marker === '-') return { kind: 'removed', text: line.slice(1) } if (marker === ' ') return { kind: 'context', text: line.slice(1) } if (marker === '\\') return { kind: 'meta', text: line.slice(1).trim() } return { kind: 'context', text: line } } interface BlockState { oldPath: string newPath: string binary: boolean isNew: boolean isDeleted: boolean isRename: boolean } /** Apply one pre-hunk header line to the accumulating block state. */ function applyHeaderLine(st: BlockState, line: string): void { if (line.startsWith('diff --git ')) { const p = parseDiffGitLine(line) st.oldPath = p.oldPath st.newPath = p.newPath } else if (line.startsWith('new file')) st.isNew = true else if (line.startsWith('deleted file')) st.isDeleted = true else if (line.startsWith('rename from ')) { st.oldPath = stripDiffPath(line.slice('rename from '.length)) st.isRename = true } else if (line.startsWith('rename to ')) { st.newPath = stripDiffPath(line.slice('rename to '.length)) st.isRename = true } else if (line.startsWith('copy from ')) st.oldPath = stripDiffPath(line.slice('copy from '.length)) else if (line.startsWith('copy to ')) st.newPath = stripDiffPath(line.slice('copy to '.length)) else if (line.startsWith('--- ')) applyOldPath(st, line.slice(4)) else if (line.startsWith('+++ ')) applyNewPath(st, line.slice(4)) else if (line.startsWith('Binary files')) st.binary = true } function applyOldPath(st: BlockState, raw: string): void { if (raw.trim() === '/dev/null') st.isNew = true else st.oldPath = stripDiffPath(raw) } function applyNewPath(st: BlockState, raw: string): void { if (raw.trim() === '/dev/null') st.isDeleted = true else st.newPath = stripDiffPath(raw) } function deriveStatus(st: BlockState, binary: boolean): FileStatus { if (st.isRename) return 'renamed' if (st.isNew) return 'added' if (st.isDeleted) return 'deleted' if (binary) return 'binary' return 'modified' } function countKind(hunks: readonly DiffHunk[], kind: DiffLineKind): number { let n = 0 for (const h of hunks) for (const l of h.lines) if (l.kind === kind) n += 1 return n } function finalizeFile( st: BlockState, hunks: DiffHunk[], numstat?: Map, ): DiffFile { const stat = numstat?.get(st.newPath) ?? numstat?.get(st.oldPath) const binary = st.binary || stat?.binary === true const added = stat?.added ?? countKind(hunks, 'added') const removed = stat?.removed ?? countKind(hunks, 'removed') return { oldPath: st.oldPath, newPath: st.newPath, status: deriveStatus(st, binary), added, removed, binary, hunks, } } /** Parse one `diff --git` block (header lines + hunks) into a DiffFile. */ function parseFileBlock(block: readonly string[], numstat?: Map): DiffFile | null { const st: BlockState = { oldPath: '', newPath: '', binary: false, isNew: false, isDeleted: false, isRename: false, } const hunks: DiffHunk[] = [] let current: DiffHunk | null = null for (const line of block) { if (line.startsWith('@@')) { current = { header: line, lines: [] } hunks.push(current) } else if (current === null) { applyHeaderLine(st, line) } else { current.lines.push(classifyHunkLine(line)) } } if (st.oldPath === '' && st.newPath === '') return null return finalizeFile(st, hunks, numstat) } /** * Parse a full unified `git diff` patch into DiffFile[]. `numstat` (optional) * supplies authoritative +/- counts and binary flags; without it counts are * derived from the hunk bodies. Empty / non-diff input → []; never throws. */ export function parseUnifiedDiff(patch: string, numstat?: Map): DiffFile[] { if (typeof patch !== 'string' || patch.length === 0) return [] const lines = patch.replace(/\n$/, '').split('\n') const files: DiffFile[] = [] let i = 0 while (i < lines.length) { if (lines[i]?.startsWith('diff --git ') !== true) { i += 1 continue } const start = i i += 1 while (i < lines.length && lines[i]?.startsWith('diff --git ') !== true) i += 1 const file = parseFileBlock(lines.slice(start, i), numstat) if (file !== null) files.push(file) } return files } // ── getDiff (git runner) ───────────────────────────────────────────────────── /** Just the diff limits getDiff needs; the full Config satisfies this Pick. */ export interface GetDiffOptions { staged: boolean cfg: Pick } interface ExecErrorShape { code?: string stdout?: string } function asExecError(err: unknown): ExecErrorShape { if (err === null || typeof err !== 'object') return {} const e = err as { code?: unknown; stdout?: unknown } return { code: typeof e.code === 'string' ? e.code : undefined, stdout: typeof e.stdout === 'string' ? e.stdout : undefined, } } interface GitOutput { out: string truncated: boolean } /** * Run a read-only git command, capturing stdout. Bounded by timeout + maxBuffer * (DoS guard). On a maxBuffer overflow the partial stdout is returned with * `truncated:true`; any other failure yields empty output (best-effort). */ async function runGit( cwd: string, args: readonly string[], timeoutMs: number, maxBytes: number, ): Promise { try { const { stdout } = await execFileAsync('git', args, { cwd, timeout: timeoutMs, maxBuffer: maxBytes, }) return { out: stdout, truncated: stdout.length >= maxBytes } } catch (err: unknown) { const { code, stdout } = asExecError(err) if (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') { return { out: stdout ?? '', truncated: true } } return { out: '', truncated: false } } } /** Minimal unquote of a C-quoted porcelain path (`"a\"b"` → `a"b`). */ function unquotePorcelain(raw: string): string { const s = raw.trim() if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) { return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') } return s } /** Untracked files (`git status --porcelain` `??` rows) as `untracked` DiffFiles. * Content is not diffed (we avoid the `--no-index` two-operand trap, L3). */ async function listUntracked(cwd: string, timeoutMs: number, maxBytes: number): Promise { const { out } = await runGit(cwd, ['status', '--porcelain', '--'], timeoutMs, maxBytes) const files: DiffFile[] = [] for (const line of out.split('\n')) { if (!line.startsWith('?? ')) continue const p = unquotePorcelain(line.slice(3)) if (p === '') continue files.push({ oldPath: p, newPath: p, status: 'untracked', added: 0, removed: 0, binary: false, hunks: [], }) } return files } /** * Read a repo's diff (working tree or `--staged`) as structured DiffResult. * `repoPath` must already be a validated absolute git directory (route layer, * SEC-H7). Best-effort: git failures yield an empty result rather than throwing. */ export async function getDiff(repoPath: string, opts: GetDiffOptions): Promise { const { staged, cfg } = opts const { diffTimeoutMs: timeout, diffMaxBytes: maxBytes, diffMaxFiles } = cfg const stagedArg = staged ? ['--staged'] : [] const patch = await runGit(repoPath, ['diff', '--no-color', ...stagedArg, '--'], timeout, maxBytes) const num = await runGit(repoPath, ['diff', '--numstat', ...stagedArg, '--'], timeout, maxBytes) const files = parseUnifiedDiff(patch.out, parseNumstat(num.out)) if (!staged) { files.push(...(await listUntracked(repoPath, timeout, maxBytes))) } let truncated = patch.truncated || num.truncated const bounded = files.length > diffMaxFiles ? ((truncated = true), files.slice(0, diffMaxFiles)) : files return { files: bounded, staged, truncated } }