/** * A change detector for Git-visible workspace state. * * It exists to answer one question, asked between two attempts at the same * verification: **did anything happen since it last failed?** A verify-then-fix * loop that re-runs the build after a turn which edited nothing spends a full * command execution to learn what a comparison already knew, and does it once * per remaining attempt — so a model that has stopped making progress burns * the entire budget confirming the same failure. * * ## What is hashed, and why each part * * Four sources, because no one of them is complete: * * 1. **`git status --porcelain`** — which paths differ from the index at all. * Cheap, and it catches additions, deletions and mode changes. On its own * it is not enough: editing a tracked file that was ALREADY modified * leaves the status output byte-identical. * 2. **`git diff --binary HEAD`** — the content of every tracked change. * `--binary` so an edit to a file git treats as binary is a real diff * rather than the constant line `Binary files … differ`, which would make * every edit to such a file invisible. * 3. **Untracked file contents**, which no `git diff` covers. A new file is * named by `status` but its CONTENT is not, so successive edits to a * brand-new file would otherwise look like no change at all. * 4. **`git rev-parse HEAD`** — the committed baseline. Two clean checkouts * can contain different code while all three sources above are empty. * Commit identity also covers verification that inspects Git history. * * Ignored files, external services and other command inputs are not covered. * Reads are not an atomic snapshot. Matching hashes are an optimization hint, * not proof that a command must produce the same result. * * ### Symlinks are recorded as their target, not read through * * Reading a link follows it, so a link repointed from one file to another * with identical contents hashes the same — while the thing the workspace * actually resolves has changed. The link's target path is the fact that * moved, so that is what goes in. * * ## Failing open, on the cheap side * * Every uncertainty returns `null`, meaning *no fingerprint*, and a caller * that cannot fingerprint re-runs its command. That is the correct direction: * the cost of a wrong `null` is one command execution, and the cost of a * wrong MATCH is a verification silently skipped — the loop would report * "nothing changed" about a workspace that did change, and the model would be * told to edit something it had already edited. * * So: a non-zero exit from any git invocation, a repository with no commits, * a timeout, an executor-reported truncated stream, or output past the size * cap all produce `null` rather than a partial hash. A truncated diff that * hashed successfully would be the worst outcome available here, because two * different workspaces truncated at the same point collide. */ import type { CommandOptions, CommandResult } from '../types/execution/index.js'; /** How a fingerprint runs git. Injected so a test needs no repository. */ export type FingerprintExec = (command: string, args: string[], options?: CommandOptions) => Promise; /** * The three filesystem reads an untracked entry needs. * * Injectable for one specific reason, written down because a seam that * exists only for tests is usually a smell: **creating a symlink requires a * privilege that is not granted by default on Windows**, so the symlink rule * below — the one that says a repointed link changes the fingerprint even * when the bytes behind it do not — cannot be exercised on a developer * machine without it. A rule that can only be checked on some machines is a * rule nobody checks. * * The default is `node:fs/promises` and every other test uses it against a * real repository, so this is not a fixture standing in for production; it is * one branch of one function reached without a privilege. */ export interface FingerprintFs { lstat(path: string): Promise<{ isSymbolicLink(): boolean; isFile(): boolean; }>; readlink(path: string): Promise; readFile(path: string): Promise; } /** * Cap on the bytes any single git invocation may produce. * * Past it the fingerprint is abandoned rather than hashed. A diff big enough * to hit this is a diff nobody is going to iterate on anyway, and hashing a * clipped one would let two different trees agree. */ export declare const FINGERPRINT_MAX_BYTES: number; /** Default deadline per git invocation. */ export declare const FINGERPRINT_TIMEOUT_MS = 20000; export interface WorkspaceFingerprintOptions { /** Repository root, or any directory inside it. */ readonly cwd: string; /** How to run git. */ readonly exec: FingerprintExec; /** Per-invocation deadline. See {@link FINGERPRINT_TIMEOUT_MS}. */ readonly timeoutMs?: number; /** See {@link FINGERPRINT_MAX_BYTES}. */ readonly maxBytes?: number; /** Filesystem reads. See {@link FingerprintFs}. */ readonly fs?: FingerprintFs; } /** * A hash of the commit and Git-visible working state, or `null` when it cannot be * established. * * **`null` is never "unchanged".** It means "I cannot tell", and the caller * must treat it as a reason to do the work rather than to skip it. */ export declare function fingerprintWorkspace(options: WorkspaceFingerprintOptions): Promise; //# sourceMappingURL=workspace-fingerprint.d.ts.map