import { existsSync, type FSWatcher, readFileSync, statSync, watch } from "fs"; import { dirname, join, resolve } from "path"; /** * Find the git HEAD path by walking up from cwd. * Handles both regular git repos (.git is a directory) and worktrees (.git is a file). */ function findGitHeadPath(): string | null { let dir = process.cwd(); while (true) { const gitPath = join(dir, ".git"); if (existsSync(gitPath)) { try { const stat = statSync(gitPath); if (stat.isFile()) { const content = readFileSync(gitPath, "utf8").trim(); if (content.startsWith("gitdir: ")) { const gitDir = content.slice(8); const headPath = resolve(dir, gitDir, "HEAD"); if (existsSync(headPath)) return headPath; } } else if (stat.isDirectory()) { const headPath = join(gitPath, "HEAD"); if (existsSync(headPath)) return headPath; } } catch { return null; } } const parent = dirname(dir); if (parent === dir) return null; dir = parent; } } /** * Provides git branch and extension statuses - data not otherwise accessible to extensions. * Token stats, model info available via ctx.sessionManager and ctx.model. */ export class FooterDataProvider { private extensionStatuses = new Map(); private cachedBranch: string | null | undefined = undefined; private gitWatcher: FSWatcher | null = null; private branchChangeCallbacks = new Set<() => void>(); private availableProviderCount = 0; constructor() { this.setupGitWatcher(); } /** Current git branch, null if not in repo, "detached" if detached HEAD */ getGitBranch(): string | null { if (this.cachedBranch !== undefined) return this.cachedBranch; try { const gitHeadPath = findGitHeadPath(); if (!gitHeadPath) { this.cachedBranch = null; return null; } const content = readFileSync(gitHeadPath, "utf8").trim(); this.cachedBranch = content.startsWith("ref: refs/heads/") ? content.slice(16) : "detached"; } catch { this.cachedBranch = null; } return this.cachedBranch; } /** Extension status texts set via ctx.ui.setStatus() */ getExtensionStatuses(): ReadonlyMap { return this.extensionStatuses; } /** Subscribe to git branch changes. Returns unsubscribe function. */ onBranchChange(callback: () => void): () => void { this.branchChangeCallbacks.add(callback); return () => this.branchChangeCallbacks.delete(callback); } /** Internal: set extension status */ setExtensionStatus(key: string, text: string | undefined): void { if (text === undefined) { this.extensionStatuses.delete(key); } else { this.extensionStatuses.set(key, text); } } /** Internal: clear extension statuses */ clearExtensionStatuses(): void { this.extensionStatuses.clear(); } /** Number of unique providers with available models (for footer display) */ getAvailableProviderCount(): number { return this.availableProviderCount; } /** Internal: update available provider count */ setAvailableProviderCount(count: number): void { this.availableProviderCount = count; } /** Internal: cleanup */ dispose(): void { if (this.gitWatcher) { this.gitWatcher.close(); this.gitWatcher = null; } this.branchChangeCallbacks.clear(); } private setupGitWatcher(): void { if (this.gitWatcher) { this.gitWatcher.close(); this.gitWatcher = null; } const gitHeadPath = findGitHeadPath(); if (!gitHeadPath) return; // Watch the directory containing HEAD, not HEAD itself. // Git uses atomic writes (write temp, rename over HEAD), which changes the inode. // fs.watch on a file stops working after the inode changes. const gitDir = dirname(gitHeadPath); try { this.gitWatcher = watch(gitDir, (_eventType, filename) => { if (filename === "HEAD") { this.cachedBranch = undefined; for (const cb of this.branchChangeCallbacks) cb(); } }); } catch { // Silently fail if we can't watch } } } /** Read-only view for extensions - excludes setExtensionStatus, setAvailableProviderCount and dispose */ export type ReadonlyFooterDataProvider = Pick< FooterDataProvider, "getGitBranch" | "getExtensionStatuses" | "getAvailableProviderCount" | "onBranchChange" >;