import { spawn } from 'node:child_process'; export type GitStatusSnapshot = { branch?: string; staged: number; unstaged: number; untracked: number; conflict: number; ahead: number; behind: number; }; export type PullRequestSnapshot = { number?: number | string; }; export interface GitStatusSource { gitStatus?: GitStatusSnapshot; pullRequest?: PullRequestSnapshot; } export type CommandResult = { stdout: string; stderr: string; exitCode: number | null; }; type CommandRunner = ( command: string, args: readonly string[], options: { cwd: string; signal: AbortSignal }, ) => Promise; type TimerHandle = unknown; type Clock = { setInterval(callback: () => void, ms: number): TimerHandle; clearInterval(handle: TimerHandle): void; }; export interface GitStatusCacheOptions { cwd: () => string; onChange?: () => void; refreshIntervalMs?: number; gitTimeoutMs?: number; ghTimeoutMs?: number; runner?: CommandRunner; clock?: Clock; includeGitStatus: boolean; includePullRequest: boolean; } type SnapshotStateResult = | { kind: 'ok'; status: GitStatusSnapshot; } | { kind: 'not-a-repo'; } | { kind: 'transient'; }; const BRANCH_HEAD_PREFIX = '# branch.head '; const BRANCH_AB_PREFIX = '# branch.ab '; const GIT_STATUS_ARGS = ['--no-optional-locks', 'status', '--porcelain=v2', '--branch'] as const; const GH_PR_LIST_ARGS = [ 'pr', 'list', '--state', 'open', '--json', 'number', '--limit', '1', ] as const; const DEFAULT_REFRESH_INTERVAL_MS = 8_000; const DEFAULT_GIT_TIMEOUT_MS = 1_500; const DEFAULT_GH_TIMEOUT_MS = 3_000; function createEmptyGitStatus(): GitStatusSnapshot { return { branch: undefined, staged: 0, unstaged: 0, untracked: 0, conflict: 0, ahead: 0, behind: 0, }; } function positiveCount(value: number): number { return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; } function normalizeBranchHead(value: string): string { const branch = value.trim(); return branch === '(detached)' ? 'detached' : branch; } function parseBranchAheadBehind(line: string, status: GitStatusSnapshot): void { const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line); if (!match) { return; } const [, ahead, behind] = match; if (ahead === undefined || behind === undefined) { return; } status.ahead = Number.parseInt(ahead, 10); status.behind = Number.parseInt(behind, 10); } function addTrackedStatusCounts(status: GitStatusSnapshot, xy: string): void { if (xy.length !== 2) { return; } if (xy[0] !== '.') { status.staged += 1; } if (xy[1] !== '.') { status.unstaged += 1; } } export function parseGitStatusPorcelainV2(output: string): GitStatusSnapshot { const status = createEmptyGitStatus(); for (const rawLine of output.split('\n')) { const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; if (!line) { continue; } if (line.startsWith(BRANCH_HEAD_PREFIX)) { status.branch = normalizeBranchHead(line.slice(BRANCH_HEAD_PREFIX.length)) || undefined; continue; } if (line.startsWith(BRANCH_AB_PREFIX)) { parseBranchAheadBehind(line, status); continue; } if (line.startsWith('1 ') || line.startsWith('2 ')) { addTrackedStatusCounts(status, line.slice(2, 4)); continue; } if (line.startsWith('u ')) { status.conflict += 1; continue; } if (line.startsWith('? ')) { status.untracked += 1; } } return status; } export function formatGitStatusChanges(status: GitStatusSnapshot | undefined): string | undefined { if (!status) { return undefined; } const parts: string[] = []; const indicators: Array<[string, number]> = [ ['!', positiveCount(status.conflict)], ['+', positiveCount(status.staged)], ['~', positiveCount(status.unstaged)], ['?', positiveCount(status.untracked)], ['↑', positiveCount(status.ahead)], ['↓', positiveCount(status.behind)], ]; for (const [prefix, count] of indicators) { if (count > 0) { parts.push(`${prefix}${count}`); } } return parts.length > 0 ? parts.join(' ') : undefined; } export function isBranchDirty(status: GitStatusSnapshot | undefined): boolean { if (!status) { return false; } return ( status.staged > 0 || status.unstaged > 0 || status.untracked > 0 || status.conflict > 0 || status.ahead > 0 || status.behind > 0 ); } type PullRequestFetchResult = | { kind: 'found'; snapshot: PullRequestSnapshot } | { kind: 'none' } | { kind: 'transient' }; function parsePullRequestListJson(stdout: string): PullRequestFetchResult { let parsed: unknown; try { parsed = JSON.parse(stdout.trim()); } catch { return { kind: 'transient' }; } if (!Array.isArray(parsed)) return { kind: 'transient' }; if (parsed.length === 0) return { kind: 'none' }; const first = parsed[0]; if (!first || typeof first !== 'object' || Array.isArray(first)) { return { kind: 'transient' }; } const number = (first as Record).number; if (typeof number !== 'number' && typeof number !== 'string') { return { kind: 'transient' }; } return { kind: 'found', snapshot: { number } }; } export function formatPullRequest( pullRequest: PullRequestSnapshot | undefined, ): string | undefined { const value = pullRequest?.number; if (Number.isSafeInteger(value) && Number(value) > 0) { return `PR #${value}`; } if (typeof value === 'string') { const trimmed = value.trim(); if (/^[1-9]\d*$/.test(trimmed)) { return `PR #${trimmed}`; } } return undefined; } function gitStatusSnapshotsEqual( left: GitStatusSnapshot | undefined, right: GitStatusSnapshot | undefined, ): boolean { return ( left?.branch === right?.branch && left?.staged === right?.staged && left?.unstaged === right?.unstaged && left?.untracked === right?.untracked && left?.conflict === right?.conflict && left?.ahead === right?.ahead && left?.behind === right?.behind ); } function pullRequestSnapshotsEqual( left: PullRequestSnapshot | undefined, right: PullRequestSnapshot | undefined, ): boolean { return left?.number === right?.number; } function parseCommandResult( stdout: string, stderr: string, exitCode: number | null, ): CommandResult { return { stdout, stderr, exitCode, }; } function defaultRunner( command: string, args: readonly string[], options: { cwd: string; signal: AbortSignal }, ): Promise { return new Promise((resolve, reject) => { let child; try { child = spawn(command, [...args], { cwd: options.cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); } catch (error) { reject(error); return; } let stdout = ''; let stderr = ''; let settled = false; const onAbort = () => { try { child.kill('SIGTERM'); } catch { // Ignore. } }; const finish = (result: CommandResult | Error) => { if (settled) { return; } settled = true; options.signal.removeEventListener('abort', onAbort); if (result instanceof Error) { reject(result); } else { resolve(result); } }; child.stdout?.on('data', (chunk: Buffer | string) => { stdout += typeof chunk === 'string' ? chunk : chunk.toString('utf-8'); }); child.stderr?.on('data', (chunk: Buffer | string) => { stderr += typeof chunk === 'string' ? chunk : chunk.toString('utf-8'); }); child.on('error', finish); child.on('close', (code) => finish(parseCommandResult(stdout, stderr, code))); if (options.signal.aborted) { onAbort(); finish(new Error('aborted')); return; } options.signal.addEventListener('abort', onAbort, { once: true }); }); } function defaultClock(): Clock { return { setInterval(callback, ms) { const handle = setInterval(callback, ms); (handle as { unref?: () => void }).unref?.(); return handle; }, clearInterval(handle) { clearInterval(handle as ReturnType); }, }; } export class GitStatusCache { private readonly cwd: () => string; private readonly runner: CommandRunner; private readonly clock: Clock; private readonly refreshIntervalMs: number; private readonly gitTimeoutMs: number; private readonly ghTimeoutMs: number; private readonly onChange: (() => void) | undefined; private readonly includeGitStatus: boolean; private readonly includePullRequest: boolean; private intervalHandle: TimerHandle | undefined; private readonly inflightControllers = new Set(); private disposed = false; private refreshInFlight: Promise | undefined; private statusSnapshot: GitStatusSnapshot | undefined; private pullRequestSnapshot: PullRequestSnapshot | undefined; private lastSeenBranch: string | undefined; constructor(options: GitStatusCacheOptions) { this.cwd = options.cwd; this.runner = options.runner ?? defaultRunner; this.clock = options.clock ?? defaultClock(); this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS; this.gitTimeoutMs = options.gitTimeoutMs ?? DEFAULT_GIT_TIMEOUT_MS; this.ghTimeoutMs = options.ghTimeoutMs ?? DEFAULT_GH_TIMEOUT_MS; this.onChange = options.onChange; this.includeGitStatus = options.includeGitStatus; this.includePullRequest = options.includePullRequest; if (this.includeGitStatus) { this.intervalHandle = this.clock.setInterval(() => { void this.refresh(); }, this.refreshIntervalMs); void this.refresh(); } } getGitInfo(): GitStatusSource { return { gitStatus: this.statusSnapshot, pullRequest: this.pullRequestSnapshot, }; } refresh(): Promise { if (this.disposed) { return Promise.resolve(); } if (this.refreshInFlight) { return this.refreshInFlight; } const run = this.runRefresh() .finally(() => { this.refreshInFlight = undefined; }) .catch(() => undefined); this.refreshInFlight = run; return run; } private async runRefresh(): Promise { if (!this.includeGitStatus) { return; } const previousStatusSnapshot = this.statusSnapshot; const previousPullRequestSnapshot = this.pullRequestSnapshot; const result = await this.fetchGitStatus(); if (this.disposed) { return; } if (result.kind === 'transient') { return; } if (result.kind === 'not-a-repo') { this.statusSnapshot = undefined; this.pullRequestSnapshot = undefined; this.lastSeenBranch = undefined; this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot); return; } const status = result.status; this.statusSnapshot = status; const branch = typeof status.branch === 'string' ? status.branch : undefined; const isValidBranch = !!branch && branch !== 'detached'; const branchChanged = branch !== this.lastSeenBranch; if (branchChanged) { this.pullRequestSnapshot = undefined; } this.lastSeenBranch = branch; if (!isValidBranch || !this.includePullRequest) { if (!isValidBranch) { this.pullRequestSnapshot = undefined; } this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot); return; } if (this.includePullRequest) { const pullRequest = await this.fetchPullRequest(branch); if (this.disposed) { return; } if (pullRequest.kind === 'found') { this.pullRequestSnapshot = pullRequest.snapshot; } else if (pullRequest.kind === 'none' || branchChanged) { this.pullRequestSnapshot = undefined; } } this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot); } private emitChangeIfSnapshotsChanged( previousStatusSnapshot: GitStatusSnapshot | undefined, previousPullRequestSnapshot: PullRequestSnapshot | undefined, ): void { if (this.disposed) { return; } if ( gitStatusSnapshotsEqual(previousStatusSnapshot, this.statusSnapshot) && pullRequestSnapshotsEqual(previousPullRequestSnapshot, this.pullRequestSnapshot) ) { return; } try { this.onChange?.(); } catch { // Rendering hooks should not break refreshes. } } private async fetchGitStatus(): Promise { if (!this.includeGitStatus) { return { kind: 'transient' }; } const result = await this.runCommandSafely('git', GIT_STATUS_ARGS, this.gitTimeoutMs); if (!result) { return { kind: 'transient' }; } if (result.exitCode !== 0) { return { kind: 'not-a-repo' }; } return { kind: 'ok', status: parseGitStatusPorcelainV2(result.stdout), }; } private async fetchPullRequest(branch: string): Promise { const result = await this.runCommandSafely( 'gh', [...GH_PR_LIST_ARGS, '--head', branch], this.ghTimeoutMs, ); if (!result || result.exitCode !== 0) { return { kind: 'transient' }; } return parsePullRequestListJson(result.stdout); } private async runCommandSafely( command: string, args: readonly string[], timeoutMs: number, ): Promise { if (this.disposed) { return undefined; } const controller = new AbortController(); this.inflightControllers.add(controller); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { return await this.runner(command, args, { cwd: this.cwd(), signal: controller.signal }); } catch { return undefined; } finally { clearTimeout(timeoutId); this.inflightControllers.delete(controller); } } invalidate(): void { if (this.disposed) { return; } const previousStatusSnapshot = this.statusSnapshot; const previousPullRequestSnapshot = this.pullRequestSnapshot; this.statusSnapshot = undefined; this.pullRequestSnapshot = undefined; this.lastSeenBranch = undefined; this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot); } dispose(): void { if (this.disposed) { return; } this.disposed = true; if (this.intervalHandle !== undefined) { this.clock.clearInterval(this.intervalHandle); this.intervalHandle = undefined; } for (const controller of this.inflightControllers) { controller.abort(); } this.inflightControllers.clear(); } }