63 lines
5.4 KiB
TypeScript
63 lines
5.4 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { lstat, readFile, readlink, realpath } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import type { Exec } from "./project-context.ts";
|
|
|
|
export interface WorkspaceSnapshot { readonly kind: "git" | "fallback" | "unknown"; readonly root: string; readonly fingerprint: string; readonly entries: ReadonlyMap<string, string>; readonly head?: string }
|
|
export interface WorkspaceDiff { readonly changedPaths: readonly string[]; readonly changed: boolean; readonly provable: boolean }
|
|
export class WorkspaceInspector {
|
|
constructor(private readonly exec: Exec) {}
|
|
async capture(cwd: string, relevant: (path: string) => boolean = () => true, signal?: AbortSignal): Promise<WorkspaceSnapshot> {
|
|
const top = await this.exec("git", ["-C", cwd, "rev-parse", "--show-toplevel"], opts(signal));
|
|
if (top.code !== 0) return Object.freeze({ kind: "unknown", root: cwd, fingerprint: "unknown", entries: new Map() });
|
|
const root = await realpath(top.stdout.trim()).catch(() => top.stdout.trim());
|
|
const [head, status] = await Promise.all([
|
|
this.exec("git", ["-C", root, "rev-parse", "HEAD"], opts(signal)),
|
|
this.exec("git", ["-C", root, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"], opts(signal)),
|
|
]);
|
|
if (status.code !== 0 || Buffer.byteLength(status.stdout) > 2_000_000) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() });
|
|
const paths = parseStatus(status.stdout); if (paths.size > 2000) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() });
|
|
const dirtyPaths = [...paths.keys()]; const index = dirtyPaths.length ? await this.exec("git", ["-C", root, "ls-files", "--stage", "-z", "--", ...dirtyPaths], opts(signal)) : { stdout: "", stderr: "", code: 0 };
|
|
if (index.code !== 0) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() });
|
|
const indexEntries = parseIndex(index.stdout);
|
|
const entries = new Map<string, string>();
|
|
for (const [path, state] of paths) {
|
|
let content = "irrelevant";
|
|
if (relevant(path)) content = await hashPath(join(root, path));
|
|
entries.set(path, `${state}\0${indexEntries.get(path) ?? "untracked"}\0${content}`);
|
|
}
|
|
const headValue = head.code === 0 ? head.stdout.trim() : "NO_HEAD";
|
|
const indexHash = createHash("sha256").update(index.stdout).digest("hex");
|
|
const material = [headValue, indexHash, ...[...entries].sort(([a], [b]) => a.localeCompare(b)).flatMap(([path, value]) => [path, value])].join("\0");
|
|
return Object.freeze({ kind: "git", root, fingerprint: createHash("sha256").update(material).digest("hex"), entries, head: headValue });
|
|
}
|
|
diff(before: WorkspaceSnapshot, after: WorkspaceSnapshot): WorkspaceDiff {
|
|
if (before.kind !== "git" || after.kind !== "git" || before.root !== after.root) return Object.freeze({ changedPaths: Object.freeze([]), changed: before.fingerprint !== after.fingerprint, provable: false });
|
|
const paths = new Set([...before.entries.keys(), ...after.entries.keys()]);
|
|
const changedPaths = [...paths].filter((path) => before.entries.get(path) !== after.entries.get(path)).sort();
|
|
return Object.freeze({ changedPaths: Object.freeze([...new Set(changedPaths)]), changed: before.fingerprint !== after.fingerprint, provable: true });
|
|
}
|
|
async headChangedPaths(before: WorkspaceSnapshot, after: WorkspaceSnapshot, signal?: AbortSignal): Promise<readonly string[]> {
|
|
if (before.kind !== "git" || after.kind !== "git" || !before.head || !after.head || before.head === after.head) return [];
|
|
const result = await this.exec("git", ["-C", after.root, "diff", "--name-only", "-z", before.head, after.head, "--"], opts(signal));
|
|
if (result.code !== 0 || Buffer.byteLength(result.stdout) > 2_000_000) return [];
|
|
return Object.freeze(result.stdout.split("\0").filter(Boolean).sort());
|
|
}
|
|
}
|
|
function opts(signal?: AbortSignal) { return { timeout: 10_000, ...(signal ? { signal } : {}) }; }
|
|
function parseStatus(output: string): Map<string, string> {
|
|
const tokens = output.split("\0"); const result = new Map<string, string>();
|
|
for (let index = 0; index < tokens.length; index++) {
|
|
const token = tokens[index]; if (!token || token.length < 4) continue;
|
|
const state = token.slice(0, 2); const path = token.slice(3); result.set(path, state);
|
|
if (state.includes("R") || state.includes("C")) { const oldPath = tokens[++index]; if (oldPath) result.set(oldPath, `${state}:source`); }
|
|
}
|
|
return result;
|
|
}
|
|
function parseIndex(output: string): Map<string, string> { const result = new Map<string, string>(); for (const token of output.split("\0")) { if (!token) continue; const tab = token.indexOf("\t"); if (tab > 0) result.set(token.slice(tab + 1), token.slice(0, tab)); } return result; }
|
|
async function hashPath(path: string): Promise<string> {
|
|
try { const stat = await lstat(path); if (stat.isSymbolicLink()) return `symlink:${await readlink(path)}`; if (!stat.isFile()) return `mode:${stat.mode}:size:${stat.size}`; return createHash("sha256").update(await readFile(path)).digest("hex"); }
|
|
catch { return "missing"; }
|
|
}
|
|
export function explicitMutationPaths(toolName: string, input: Record<string, unknown>): readonly string[] { if ((toolName === "write" || toolName === "edit") && typeof input.path === "string") return Object.freeze([input.path]); return Object.freeze([]); }
|