import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const CACHE_TTL_MS = 30_000; const GIT_TIMEOUT_MS = 2_000; // Cap stdout size so a pathological repo can't blow the buffer. Branch + status // porcelain + diverge counts never approach this on a real repo. const GIT_MAX_BUFFER = 1024 * 1024; export interface GitMeta { branch: string | null; is_dirty: boolean; ahead: number; behind: number; } interface CacheEntry { at: number; value: GitMeta | null; } const cache = new Map(); // Runs a single git invocation with a hard 2s timeout. Returns null on any // failure (non-zero exit, timeout, git not installed) so callers can decide // how to degrade. Stderr is intentionally swallowed; we don't surface git's // error text to the model or UI. async function runGit(args: string[], cwd: string): Promise { try { const { stdout } = await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS, windowsHide: true, maxBuffer: GIT_MAX_BUFFER, }); return stdout.toString(); } catch { return null; } } export async function getGitMeta(rootPath: string): Promise { const cached = cache.get(rootPath); const now = Date.now(); if (cached && now - cached.at < CACHE_TTL_MS) { return cached.value; } // Three calls in parallel. rev-parse establishes repo + branch name; // status --porcelain detects dirtiness with no false-positives from formatting; // rev-list --left-right --count compares HEAD to upstream and is allowed to // fail silently (returns null → ahead/behind = 0) when no upstream is set. const [branchOut, statusOut, divergedOut] = await Promise.all([ runGit(['rev-parse', '--abbrev-ref', 'HEAD'], rootPath), runGit(['status', '--porcelain'], rootPath), runGit(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], rootPath), ]); // If rev-parse fails, this isn't a git repo (or git isn't installed). Cache // the null result so the next 30s of requests don't re-probe. if (branchOut === null) { cache.set(rootPath, { at: now, value: null }); return null; } const branch = branchOut.trim() || null; const is_dirty = statusOut !== null && statusOut.trim().length > 0; let ahead = 0; let behind = 0; if (divergedOut !== null) { const match = divergedOut.trim().match(/^(\d+)\s+(\d+)/); if (match) { ahead = Number(match[1]); behind = Number(match[2]); } } const value: GitMeta = { branch, is_dirty, ahead, behind }; cache.set(rootPath, { at: now, value }); return value; } export function invalidateGitMetaCache(rootPath?: string): void { if (rootPath) { cache.delete(rootPath); } else { cache.clear(); } }