447a1170ce
Task 2 of the native-surfaces migration plan. GitBaselineAdapter resolves a
document's HEAD blob via the built-in vscode.git extension (hardened with a
.git/logs/HEAD watch that nudges repo.status(), since the git extension's own
watcher doesn't reliably notice out-of-band commits on non-workspace-folder
repos in the E2E host). DiffViewController is reworked into a baseline router:
head mode (git-tracked, never persisted, re-read on commit) vs snapshot mode
(captured on CoeditingRegistry entry, re-pinned by "Mark Changes as Reviewed"
— renamed from cowriting.pinDiffBaseline, D14). The shipped machine-landing
baseline advance (#48/INV-18) is retired: a landed Claude edit now stays a
visible change-since-baseline until commit or review (INV-7/D21). Legacy
on-disk reasons ("opened"/"machine-landing") migrate to "entered" via
BaselineStore.normalizeReason on load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
132 lines
5.3 KiB
TypeScript
132 lines
5.3 KiB
TypeScript
/**
|
|
* 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<void> };
|
|
show(ref: string, path: string): Promise<string>;
|
|
status(): Promise<void>;
|
|
}
|
|
interface GitAPI {
|
|
repositories: GitRepository[];
|
|
getRepository(uri: vscode.Uri): GitRepository | null;
|
|
openRepository(root: vscode.Uri): Promise<GitRepository | null>;
|
|
onDidOpenRepository: vscode.Event<GitRepository>;
|
|
}
|
|
|
|
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<GitRepository>();
|
|
private readonly lastCommit = new Map<string, string>(); // repo root → HEAD commit
|
|
private readonly emitter = new vscode.EventEmitter<void>();
|
|
readonly onDidChangeHead = this.emitter.event;
|
|
private readonly disposables: vscode.Disposable[] = [this.emitter];
|
|
|
|
private async gitApi(): Promise<GitAPI | null> {
|
|
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<HeadBaseline | null> {
|
|
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<GitRepository | null> {
|
|
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<typeof setTimeout> | 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();
|
|
}
|
|
}
|