/** * GitBaselineAdapter — resolves a document's git-HEAD baseline via the built-in * Git extension API (spec §6.4, INV-7). Spike-verified: Repository.show('HEAD', * path) + state.onDidChange work as assumed; the change event is coarse, so * re-reads key off state.HEAD.commit; a nested repo may need openRepository(). * * Hardening (Task 2 E2E, host-verified): for a repo that is NOT a workspace * folder (the common case here — F8/#19 precedent, coediting works on any * file), the git extension's own filesystem watcher does not reliably notice * an out-of-band commit (e.g. a `git commit` run from a terminal, or in the * host E2E) — `repo.state.onDidChange` can simply never fire. We additionally * watch `.git/logs/HEAD` (the reflog VS Code itself also watches for * in-workspace repos) with Node's own `fs.watch` and nudge `repo.status()`, * which reliably re-syncs `state.HEAD` and lets the existing onDidChange path * do the rest. Best-effort: any failure here just falls back to the passive * listener. */ import * as fs from "node:fs"; import * as path from "node:path"; import * as vscode from "vscode"; interface GitRepository { rootUri: vscode.Uri; state: { HEAD?: { commit?: string }; onDidChange: vscode.Event }; show(ref: string, path: string): Promise; status(): Promise; } interface GitAPI { repositories: GitRepository[]; getRepository(uri: vscode.Uri): GitRepository | null; openRepository(root: vscode.Uri): Promise; onDidOpenRepository: vscode.Event; } export interface HeadBaseline { text: string; commit: string } export class GitBaselineAdapter implements vscode.Disposable { private api: GitAPI | null | undefined; // undefined = not resolved yet; null = unavailable private readonly hooked = new WeakSet(); private readonly lastCommit = new Map(); // repo root → HEAD commit private readonly emitter = new vscode.EventEmitter(); readonly onDidChangeHead = this.emitter.event; private readonly disposables: vscode.Disposable[] = [this.emitter]; private async gitApi(): Promise { if (this.api !== undefined) return this.api; try { const ext = vscode.extensions.getExtension("vscode.git"); if (!ext) return (this.api = null); const exports = ext.isActive ? ext.exports : await ext.activate(); this.api = exports.getAPI(1) as GitAPI; this.disposables.push(this.api.onDidOpenRepository((r) => this.hook(r))); for (const r of this.api.repositories) this.hook(r); } catch { this.api = null; } return this.api; } /** HEAD blob text + commit for a tracked file; null when untracked / no repo / no HEAD. */ async headFor(uri: vscode.Uri): Promise { if (uri.scheme !== "file") return null; const git = await this.gitApi(); if (!git) return null; let repo = git.getRepository(uri); if (!repo) repo = await this.discoverNested(git, uri); if (!repo) return null; this.hook(repo); try { const text = await repo.show("HEAD", uri.fsPath); return { text, commit: repo.state.HEAD?.commit ?? "" }; } catch { return null; // untracked, ignored, or no commit yet → snapshot mode } } /** Spike finding: a nested repo may not be auto-opened — walk up for .git, then openRepository. */ private async discoverNested(git: GitAPI, uri: vscode.Uri): Promise { let dir = path.dirname(uri.fsPath); for (let i = 0; i < 32; i++) { if (fs.existsSync(path.join(dir, ".git"))) { await git.openRepository(vscode.Uri.file(dir)); return git.getRepository(uri); } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return null; } /** Watch a repo; fire onDidChangeHead only when HEAD's commit actually moves. */ private hook(repo: GitRepository): void { if (this.hooked.has(repo)) return; this.hooked.add(repo); this.lastCommit.set(repo.rootUri.toString(), repo.state.HEAD?.commit ?? ""); this.disposables.push( repo.state.onDidChange(() => { const key = repo.rootUri.toString(); const commit = repo.state.HEAD?.commit ?? ""; if (this.lastCommit.get(key) !== commit) { this.lastCommit.set(key, commit); this.emitter.fire(); } }), ); this.watchReflog(repo); } /** Best-effort nudge: watch `.git/logs/HEAD` directly and force `repo.status()` * on any write, so an out-of-band commit is picked up even when the git * extension's own watcher doesn't fire (see the class doc). */ private watchReflog(repo: GitRepository): void { try { const reflog = path.join(repo.rootUri.fsPath, ".git", "logs", "HEAD"); if (!fs.existsSync(reflog)) return; let pending: ReturnType | undefined; const watcher = fs.watch(reflog, () => { if (pending) clearTimeout(pending); pending = setTimeout(() => void repo.status().catch(() => {}), 150); }); this.disposables.push({ dispose: () => watcher.close() }); } catch { // best-effort only — the passive repo.state.onDidChange listener still applies. } } dispose(): void { for (const d of this.disposables) d.dispose(); } }