Files
harness-tools/extensions/native-hooks/src/workspace.ts
T

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([]); }