import { spawn } from "node:child_process"; import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { profileCount, profileDuration, profileNow, profileSample, profileTextBytes } from "../perf/profiler.ts"; export interface ModifiedFileEntry { path: string; insertions?: number; deletions?: number; } export interface GitBranchStatus { branch: string; insertions?: number; deletions?: number; modifiedFiles?: ModifiedFileEntry[]; } interface ModifiedFileEntryWithMtime extends ModifiedFileEntry { modifiedAt: number; order: number; } interface StatusFileEntry extends ModifiedFileEntryWithMtime { xy: string; } type DiffStats = { insertions: number; deletions: number; }; export type GitBranchFetcher = () => GitBranchStatus | null; const BRANCH_FETCH_INTERVAL_MS = 5000; const GIT_COMMAND_TIMEOUT_MS = 1000; const MAX_UNTRACKED_STAT_BYTES = 1024 * 1024; const MAX_UNTRACKED_INSERTION_STATS = 10; function gitCommandMetric(args: readonly string[]): string { if (args[0] === "rev-parse") return "revParseBranch"; if (args[0] === "status") return "statusPorcelain"; if (args[0] === "diff" && args.includes("--cached") && args.includes("--numstat")) return "diffCachedNumstat"; if (args[0] === "diff" && args.includes("--numstat")) return "diffNumstat"; if (args[0] === "diff" && args.includes("--cached") && args.includes("--shortstat")) return "diffCachedShortstat"; if (args[0] === "diff" && args.includes("--shortstat")) return "diffShortstat"; return "other"; } function sameFileList(a: readonly ModifiedFileEntry[] | undefined, b: readonly ModifiedFileEntry[] | undefined): boolean { const left = a ?? []; const right = b ?? []; if (left.length !== right.length) return false; return left.every((value, index) => { const other = right[index]; return value.path === other?.path && value.insertions === other?.insertions && value.deletions === other?.deletions; }); } function runGit(cwd: string, args: string[]): Promise { const metric = gitCommandMetric(args); const start = profileNow(); profileCount(`git.command.${metric}.calls`); return new Promise((resolve) => { let settled = false; let timeout: ReturnType | undefined; const finish = (output: string) => { if (settled) return; settled = true; if (timeout) clearTimeout(timeout); profileDuration(`git.command.${metric}.ms`, start); profileTextBytes(`git.command.${metric}.output.bytes`, output); resolve(output); }; try { const p = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }); const chunks: string[] = []; p.stdout.on("data", (d: Buffer) => { chunks.push(d.toString("utf8")); }); p.on("close", (code: number) => { if (code !== 0) profileCount(`git.command.${metric}.nonzero`); finish(code === 0 ? chunks.join("") : ""); }); p.on("error", () => { profileCount(`git.command.${metric}.error`); finish(""); }); timeout = setTimeout(() => { profileCount(`git.command.${metric}.timeout`); try { p.kill(); } catch {} finish(""); }, GIT_COMMAND_TIMEOUT_MS); } catch { profileCount(`git.command.${metric}.spawnError`); finish(""); } }); } function parseShortstat(statText: string): { insertions: number; deletions: number } { const insMatch = statText.match(/(\d+) insertion/); const delMatch = statText.match(/(\d+) deletion/); return { insertions: insMatch ? parseInt(insMatch[1], 10) : 0, deletions: delMatch ? parseInt(delMatch[1], 10) : 0, }; } function addDiffStats(map: Map, path: string, stats: DiffStats): void { const previous = map.get(path); map.set(path, { insertions: (previous?.insertions ?? 0) + stats.insertions, deletions: (previous?.deletions ?? 0) + stats.deletions, }); } function parseNumstatPath(pathText: string): string { const raw = pathText.trim(); const arrowIndex = raw.lastIndexOf(" -> "); if (arrowIndex !== -1) return unquoteGitPath(raw.slice(arrowIndex + 4)); return unquoteGitPath(raw); } function parseNumstatMap(output: string): Map { const statsByPath = new Map(); for (const line of output.split("\n")) { const match = line.match(/^(\d+|-)\s+(\d+|-)\s+(.+)$/); if (!match || match[1] === "-" || match[2] === "-") continue; const path = parseNumstatPath(match[3] ?? ""); if (!path) continue; addDiffStats(statsByPath, path, { insertions: parseInt(match[1], 10) || 0, deletions: parseInt(match[2], 10) || 0, }); } return statsByPath; } function unquoteGitPath(path: string): string { const trimmed = path.trim(); if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return trimmed; try { return JSON.parse(trimmed) as string; } catch { return trimmed.slice(1, -1); } } function parseStatusPath(line: string): string { const raw = line.slice(3).trim(); const arrowIndex = raw.lastIndexOf(" -> "); return unquoteGitPath(arrowIndex === -1 ? raw : raw.slice(arrowIndex + 4)); } async function fileModifiedAt(cwd: string, path: string): Promise { try { return (await stat(join(cwd, path))).mtimeMs; } catch { return 0; } } async function countUntrackedInsertions(cwd: string, path: string): Promise<{ insertions: number; deletions: number } | undefined> { const start = profileNow(); profileCount("git.untrackedStats.calls"); try { const fullPath = join(cwd, path); const stats = await stat(fullPath); if (!stats.isFile() || stats.size > MAX_UNTRACKED_STAT_BYTES) { profileCount("git.untrackedStats.skipLargeOrNonFile"); return undefined; } profileSample("git.untrackedStats.fileBytes", stats.size); const text = await readFile(fullPath, "utf8"); if (text.length === 0) return { insertions: 0, deletions: 0 }; const newlineCount = text.match(/\n/g)?.length ?? 0; const insertions = newlineCount + (text.endsWith("\n") ? 0 : 1); return { insertions, deletions: 0 }; } catch { profileCount("git.untrackedStats.error"); return undefined; } finally { profileDuration("git.untrackedStats.ms", start); } } function diffStatsForEntry(entry: StatusFileEntry, unstagedStatsByPath: Map, stagedStatsByPath: Map): DiffStats | undefined { if (entry.xy === "??") return undefined; const staged = entry.xy[0] !== " " && entry.xy[0] !== "?"; const unstaged = entry.xy[1] !== " " && entry.xy[1] !== "?"; let insertions = 0; let deletions = 0; let matched = false; const add = (stats: DiffStats | undefined) => { if (!stats) return; insertions += stats.insertions; deletions += stats.deletions; matched = true; }; if (staged) add(stagedStatsByPath.get(entry.path)); if (unstaged || !staged) add(unstagedStatsByPath.get(entry.path)); return matched ? { insertions, deletions } : undefined; } async function parseStatusEntries(cwd: string, status: string): Promise { const lines = status.split("\n").map((line) => line.trimEnd()).filter(Boolean); const entries = await Promise.all(lines.map(async (line, order): Promise => { const xy = line.slice(0, 2); const path = parseStatusPath(line); if (!path) return null; return { xy, path, modifiedAt: await fileModifiedAt(cwd, path), order, }; })); return entries .filter((entry): entry is StatusFileEntry => entry !== null) .sort((a, b) => b.modifiedAt - a.modifiedAt || a.order - b.order); } async function parseModifiedFilesWithStats(cwd: string, status: string, unstagedNumstat: string, stagedNumstat: string): Promise { const start = profileNow(); try { const entries = await parseStatusEntries(cwd, status); profileSample("git.status.entries.count", entries.length); const unstagedStatsByPath = parseNumstatMap(unstagedNumstat); const stagedStatsByPath = parseNumstatMap(stagedNumstat); const modifiedFiles: ModifiedFileEntry[] = []; let untrackedStatsRemaining = MAX_UNTRACKED_INSERTION_STATS; for (const entry of entries) { let stats = diffStatsForEntry(entry, unstagedStatsByPath, stagedStatsByPath); if (entry.xy === "??" && untrackedStatsRemaining > 0) { untrackedStatsRemaining--; stats = await countUntrackedInsertions(cwd, entry.path); } modifiedFiles.push({ path: entry.path, insertions: stats?.insertions || undefined, deletions: stats?.deletions || undefined, }); } profileSample("git.modifiedFiles.count", modifiedFiles.length); return modifiedFiles; } finally { profileDuration("git.modifiedFiles.parse.ms", start); } } export function createGitBranchFetcher(cwd: string, onUpdate?: () => void): GitBranchFetcher { let cachedBranch: GitBranchStatus | null = null; let branchLastFetch = 0; let branchFetchInFlight = false; function setCachedBranch(next: GitBranchStatus | null): void { const previous = cachedBranch; cachedBranch = next; if ( previous?.branch !== next?.branch || previous?.insertions !== next?.insertions || previous?.deletions !== next?.deletions || !sameFileList(previous?.modifiedFiles, next?.modifiedFiles) ) { profileCount("git.cache.changed"); onUpdate?.(); return; } profileCount("git.cache.unchanged"); } async function refreshBranch(): Promise { profileCount("git.refresh.start"); const start = profileNow(); try { const [branchOutput, unstagedStat, stagedStat, status] = await Promise.all([ runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]), runGit(cwd, ["diff", "--shortstat"]), runGit(cwd, ["diff", "--cached", "--shortstat"]), runGit(cwd, ["status", "--porcelain=v1"]), ]); const branch = branchOutput.trim(); if (!branch) { profileCount("git.refresh.noBranch"); setCachedBranch(null); return; } const unstaged = parseShortstat(unstagedStat); const staged = parseShortstat(stagedStat); const hasModifiedFiles = status.trim().length > 0; profileSample("git.status.bytes", status.length); const [unstagedNumstat, stagedNumstat] = hasModifiedFiles ? await Promise.all([ runGit(cwd, ["diff", "--numstat"]), runGit(cwd, ["diff", "--cached", "--numstat"]), ]) : ["", ""]; const modifiedFiles = hasModifiedFiles ? await parseModifiedFilesWithStats(cwd, status, unstagedNumstat, stagedNumstat) : []; const insertions = unstaged.insertions + staged.insertions; const deletions = unstaged.deletions + staged.deletions; profileSample("git.diff.insertions.count", insertions); profileSample("git.diff.deletions.count", deletions); setCachedBranch({ branch, insertions: insertions || undefined, deletions: deletions || undefined, modifiedFiles, }); } finally { branchFetchInFlight = false; profileDuration("git.refresh.ms", start); } } return () => { profileCount("git.fetch.calls"); const now = Date.now(); if (branchFetchInFlight) { profileCount("git.fetch.inFlightSkip"); return cachedBranch; } if (now - branchLastFetch < BRANCH_FETCH_INTERVAL_MS) { profileCount("git.fetch.cacheHit"); return cachedBranch; } profileCount("git.fetch.scheduleRefresh"); branchFetchInFlight = true; branchLastFetch = now; void refreshBranch(); return cachedBranch; }; }