/** * src/http/git-log.ts (W3 quick-wins d) — read-only recent-commit log. * * The server stays a byte-shuttle: this is an out-of-band side-channel that runs * `git log` in a directory and PARSES its output into CommitLogEntry[]. Parsing * lives ONLY here; public/git-log.ts is render-only (mirrors diff.ts / gh.ts). * * Delimiter design (robust against nasty subjects): the format is * %h %x1f %ct %x1f %s with -z (records separated by NUL) * so a US (0x1f) field separator + a NUL (0x00) record separator can never be * corrupted by a subject containing tabs, spaces or newlines. We never parse the * fragile `--oneline` shape. * * Security (mirrors diff.ts): * - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS. * - `repoPath` is the cwd, never interpolated into argv; `n` is coerced to an * int and clamped BEFORE reaching argv. Path→repo validation is the route's * job (isValidGitDir, SEC-H7), exactly like /projects/diff. * - parseGitLog NEVER throws: malformed records are skipped; getGitLog is * best-effort and returns an empty result rather than rejecting. * - subjects are carried verbatim and rendered as inert text (textContent) in * the FE — never HTML. */ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import type { CommitLogEntry, GitLogResult } from '../types.js' const execFileAsync = promisify(execFile) /** Hard cap on the number of commits a single request may return (DoS bound). */ export const GIT_LOG_MAX = 50 /** Default number of commits when the caller does not specify `n`. */ export const GIT_LOG_DEFAULT = 20 /** Cap a single commit subject so a pathological message can't bloat the payload. */ const SUBJECT_MAX_LEN = 500 /** Bound the captured stdout (DoS guard); 1 MB is ample for ≤50 subjects. */ const GIT_LOG_MAX_BUFFER = 1024 * 1024 /** Field separator (US, 0x1f) and record separator (NUL, 0x00). */ const FIELD_SEP = '\x1f' const RECORD_SEP = '\x00' /** * Clamp a possibly-junk `n` to an integer in [1, GIT_LOG_MAX]. Non-numeric / * missing → GIT_LOG_DEFAULT. Never throws. */ export function clampLogCount(n: unknown): number { const parsed = typeof n === 'number' ? n : Number.parseInt(String(n ?? ''), 10) if (!Number.isFinite(parsed)) return GIT_LOG_DEFAULT const floored = Math.floor(parsed) if (floored < 1) return 1 if (floored > GIT_LOG_MAX) return GIT_LOG_MAX return floored } /** * Parse `git log -z --format=%h%x1f%ct%x1f%s` output into CommitLogEntry[]. * Records are NUL-separated; fields are US-separated. A record missing a field * or with a non-numeric timestamp is skipped (never throws). `max` caps the * returned entries and drives the `truncated` flag (records === max ⇒ there may * be more). Empty stdout → an empty, non-truncated result. */ export function parseGitLog(stdout: string, max: number): GitLogResult { if (typeof stdout !== 'string' || stdout.length === 0) { return { commits: [], truncated: false } } const records = stdout.split(RECORD_SEP).filter((r) => r.length > 0) const commits: CommitLogEntry[] = [] for (const record of records) { const parts = record.split(FIELD_SEP) if (parts.length < 3) continue // malformed — missing a field const hash = (parts[0] ?? '').trim() const secs = Number.parseInt(parts[1] ?? '', 10) // Subject may (in theory) contain a US char; re-join the tail so it is intact. const subject = parts.slice(2).join(FIELD_SEP) if (hash === '' || !Number.isFinite(secs) || secs < 0) continue commits.push({ hash, at: secs * 1000, subject: subject.length > SUBJECT_MAX_LEN ? subject.slice(0, SUBJECT_MAX_LEN) : subject, }) } const truncated = commits.length >= max && max > 0 return { commits: max > 0 ? commits.slice(0, max) : commits, truncated } } export interface GetGitLogOptions { n?: number timeoutMs: number } /** * Read a repo's recent commits (newest first) as a structured GitLogResult. * `repoPath` must already be a validated absolute git directory (route layer, * SEC-H7). Best-effort: any git failure yields an empty result, never throws. */ export async function getGitLog(repoPath: string, opts: GetGitLogOptions): Promise { const n = clampLogCount(opts.n) try { const { stdout } = await execFileAsync( 'git', ['log', '--no-color', '-z', '-n', String(n), '--format=%h%x1f%ct%x1f%s'], { cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER }, ) const parsed = parseGitLog(stdout, n) return await markUnpushed(repoPath, parsed, opts.timeoutMs) } catch { return { commits: [], truncated: false } } } /** Cap on how many unpushed commits we enumerate — a runaway branch must not * turn one log read into an unbounded buffer. */ const UNPUSHED_MAX = 200 /** * w6/G4 — tag the commits that exist only locally, and name the upstream so the * client can draw the boundary between pushed and unpushed history. * * Marking is done HERE, from the authoritative `rev-list` set, and never inferred * on the client from row order: `git log` is date-ordered, so merging an older * branch interleaves unpushed commits below pushed ones and "the first N rows are * unpushed" silently mislabels them as pushed — the same class of confident lie * the sync band exists to prevent. * * No upstream / detached / empty repo → the input is returned untouched (no * `upstream`, no marks), so the client draws no boundary. Never throws. */ async function markUnpushed( repoPath: string, result: GitLogResult, timeoutMs: number, ): Promise { if (result.commits.length === 0) return result let upstream: string try { const { stdout } = await execFileAsync( 'git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER }, ) upstream = stdout.trim() if (upstream === '') return result } catch { return result // no upstream configured / detached — nothing to compare against } let fullHashes: string[] try { const { stdout } = await execFileAsync( 'git', ['rev-list', `--max-count=${UNPUSHED_MAX}`, '@{u}..HEAD'], { cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER }, ) fullHashes = stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '') } catch { return result } // `hash` is abbreviated (%h) while rev-list yields full SHAs, and the two can // disagree on width, so match by prefix rather than equality. Bounded by // GIT_LOG_MAX × UNPUSHED_MAX string compares — trivial, and always correct. const commits = result.commits.map((c) => fullHashes.some((full) => full.startsWith(c.hash)) ? { ...c, unpushed: true } : c, ) return { ...result, commits, upstream } }