// A thin `git`-shelling seam for the extension interior — the TS twin of perk/substrate/git.py. // // Node builtins only (so it loads cleanly under `node --test`); shells `git` via `execFileSync`, // never with a shell. Fail-open by design: every failure degrades to the caller's `cwd` (or null // where stated) rather than throwing — the carriers that use this must never wedge a session. // `revalidationBracket`, `worktreeGitDir` and the checkout-bracket probes (`trackedChanges`, // `untrackedInventory`, `checkoutCleanStart`, `checkoutBracket`) deliberately fail closed: // snapshot proofs and writer coordination must never invent identity from a failed probe. import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; import { type BracketOutcome, type CheckoutSnapshot, cleanStart, compareEndState, type SnapshotObservation, type UntrackedEntry, } from "./checkoutSnapshot.ts"; /** * The MAIN working tree's root, even when `cwd` is inside a linked worktree — the TS twin of * `main_worktree_root`. Resolves `git rev-parse --git-common-dir` (the shared `.git` of the main * checkout) and returns its parent (equal to the repo root in the main checkout). **Fail-open**: * any failure (not a repo, git missing) returns `cwd`, so a session-pointer write always has a * location — never throws. (Python returns `null` outside a repo; here the single caller wants * `main_worktree_root(cwd) or cwd`, so we fold the fallback in.) */ export function mainCheckoutRoot(cwd: string): string { let out: string; try { out = execFileSync("git", ["rev-parse", "--git-common-dir"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); } catch { return cwd; } if (out === "") return cwd; // `--git-common-dir` may be relative (to `cwd`) or absolute; resolve then take the parent // (the dir containing `.git` = the main checkout root). const common = isAbsolute(out) ? out : resolve(cwd, out); return resolve(common, ".."); } /** Canonical PER-WORKTREE Git directory for execution exclusion, never a cwd fallback. */ export function worktreeGitDir(cwd: string): string | null { try { const out = execFileSync("git", ["rev-parse", "--absolute-git-dir"], { cwd, encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"], }).replace(/\r?\n$/, ""); if (!isAbsolute(out) || /[\0\r\n]/.test(out) || !statSync(out).isDirectory()) return null; return realpathSync(out); } catch { return null; } } /** * The git-config entries that decide WHERE a GitHub-backed save lands: every `remote.*.url` and * `remote.*.gh-resolved` key (`gh` resolves the target repo from exactly these). Returns the * NUL-separated `key\nvalue` entries sorted lexicographically and re-joined with `\0` (a * canonical form fit for digesting), `""` when the repo has no remotes at all (git exits 1 with * empty stdout), or `null` when the destination cannot be verified (not a repo, git missing, * any other failure). **Fails closed** on purpose: a caller fencing a save must treat `null` as * "unverifiable", never as "unchanged". No other git config participates — `branch.*`, * `user.*`, and friends never route a save, so they must never invalidate a review. * * Repo membership is probed first (`worktreeGitDir`): outside a repo `git config` still reads * the global/system files and reports "no match" — which would masquerade as a verified empty * destination. */ export function remoteConfig(cwd: string): string | null { if (worktreeGitDir(cwd) === null) return null; try { const out = execFileSync( "git", ["config", "--null", "--get-regexp", "^remote\\..*\\.(url|gh-resolved)$"], { cwd, encoding: "utf8", timeout: 5_000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "ignore"], }, ); return out .split("\0") .filter((entry) => entry !== "") .sort() .join("\0"); } catch (error) { // `git config --get-regexp` exits 1 with empty stdout when NO key matches — a repo with no // remotes is a verified (empty) destination, not a failure. const failure = error as { status?: unknown; stdout?: unknown }; if (failure.status === 1 && String(failure.stdout ?? "") === "") return ""; return null; } } /** Run one git command; trimmed stdout, or null on any failure (the module's fail-open style). */ function git(cwd: string, args: string[], timeout?: number): string | null { try { const out = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...(timeout !== undefined ? { timeout } : {}), }).trim(); return out === "" ? null : out; } catch { return null; } } /** * Tracked files under `pathspec` (repo-relative names), [] when none or on ANY failure (not a * repo, git missing — the module's fail-open style). Callers deciding trust on the result must * treat [] as "nothing PROVEN tracked", not proof of cleanliness. */ export function lsFiles(cwd: string, pathspec: string): string[] { const out = git(cwd, ["ls-files", "--", pathspec]); return out === null ? [] : out.split("\n").filter((line) => line !== ""); } /** The bounded best-effort `git fetch` budget (ms) — see `sinceBaseSha` step 2. */ const FETCH_TIMEOUT_MS = 15_000; /** * The since-base merge-base of the working tree: `merge-base(HEAD, origin/)` — the sha the * terminal review door diffs the active worktree against. **Fail-open**: null on any failure * (not a repo, no such ref, git missing), never throws. * * 1. Resolve the base branch name: `base` when given; else the repo default via * `git symbolic-ref --short refs/remotes/origin/HEAD` (`origin/main` → `main`). * 2. Best-effort `git fetch origin ` with a bounded timeout — a failure (offline, no * remote) is swallowed and the stale local ref is used, keeping the door usable offline (and * the test scaffold network-free). * 3. `git merge-base HEAD origin/` → the full sha. */ export function sinceBaseSha(cwd: string, base: string | null | undefined): string | null { let branch = base ?? null; if (branch === null) { const head = git(cwd, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]); if (head === null) return null; // `origin/main` → `main` (keep anything after the first slash — branch names may carry `/`). branch = head.includes("/") ? head.slice(head.indexOf("/") + 1) : head; } if (branch === "") return null; git(cwd, ["fetch", "origin", branch], FETCH_TIMEOUT_MS); return git(cwd, ["merge-base", "HEAD", `origin/${branch}`]); } /** * The current HEAD sha. **Fail-open**: null on any failure — not a repo, git missing, or an * unborn HEAD (no commits yet), which callers treat as "no before-point to diff from". */ export function headSha(cwd: string): string | null { return git(cwd, ["rev-parse", "HEAD"]); } /** Whether HEAD is a positively-PROVEN unborn branch pointer: `symbolic-ref -q HEAD` resolves * AND `for-each-ref` proves the pointed-to ref ABSENT (an exit-0 run, empty output). `false` = * the ref EXISTS (a failing `headSha` read was transient, not unborn); **fail-open to null** * when either probe fails outright — callers must never read null as unborn. Own `execFileSync` * for the second probe: `git()` conflates empty output (absence — meaningful here) with * failure. */ export function unbornHead(cwd: string): boolean | null { const pointer = git(cwd, ["symbolic-ref", "-q", "HEAD"]); if (pointer === null) return null; try { const out = execFileSync("git", ["for-each-ref", "--format=%(refname)", pointer], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); return out.trim() === ""; } catch { return null; } } /** * Whether the working tree has anything uncommitted (`git status --porcelain`). Untracked files * count as dirty — deliberate: the model decides whether they belong in a commit. **Fail-open to * null** on any failure (not a repo, git missing) — callers must NOT conflate null with clean. * Own `execFileSync` rather than the `git()` helper: `git()` conflates empty output (a clean * tree — meaningful here) with failure. */ export function worktreeDirty(cwd: string): boolean | null { try { const out = execFileSync("git", ["status", "--porcelain"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); return out.trim() !== ""; } catch { return null; } } /** * Whether the index carries `assume-unchanged` (a lowercase `git ls-files -v` tag) or * `skip-worktree` (`S`/`s` — sparse checkouts) entries. Either bit hides worktree edits from * `git status --porcelain`, so a status-based cleanliness proof over a flagged index is not a * proof. **Fail-open to null** on any failure (not a repo, git missing) — callers must NOT * conflate null with "no flags". Own `execFileSync` rather than the `git()` helper: `git()` * conflates empty output (an empty index — meaningful here) with failure. Module-private: the * default flags probe of `revalidationBracket` and `observeCheckout` (tests reach the arms through * the brackets — real-repo flag arms — and their `probes` seams for the null arm). */ function indexHidesChanges(cwd: string): boolean | null { try { const out = execFileSync("git", ["ls-files", "-v"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); return out.split("\n").some((line) => { const tag = line[0]; return tag !== undefined && (tag === "S" || (tag >= "a" && tag <= "z")); }); } catch { return null; } } /** * The dream-snapshot revalidation bracket (contracts.md §8.65) — a deliberately * **fail-closed** snapshot composition (an exception to the fail-open probe defaults): it exists * to PROVE the repository still matches a stamped snapshot, so an unprovable probe must read as * drift, never as "unchanged". The claim is END-STATE equality only — HEAD unchanged, the * working tree clean, and no assume-unchanged/skip-worktree index flags (which would hide edits * from the status probe) at the moment of the check — never mid-window byte immutability (a * transient modify-and-restore inside the window is invisible by design; §8.65's accepted * residuals). `probes` defaults to the real `headSha`/`worktreeDirty`/`indexHidesChanges` and * exists so tests can pin each fail-closed arm independently (from a non-repo fixture the HEAD * arm returns first, making the later null arms reachable only through the seam). */ export function revalidationBracket( cwd: string, expectedSha: string, probes?: { head?: (cwd: string) => string | null; dirty?: (cwd: string) => boolean | null; flags?: (cwd: string) => boolean | null; }, ): { ok: boolean; detail: string | null } { const head = probes?.head ?? headSha; const dirty = probes?.dirty ?? worktreeDirty; const flags = probes?.flags ?? indexHidesChanges; const actual = head(cwd); if (actual === null) { return { ok: false, detail: "HEAD could not be resolved — cannot prove the snapshot is unchanged", }; } if (actual !== expectedSha) { return { ok: false, detail: `HEAD moved from ${expectedSha} to ${actual}` }; } const isDirty = dirty(cwd); if (isDirty === null) { return { ok: false, detail: "working-tree cleanliness could not be verified" }; } if (isDirty) { return { ok: false, detail: "the working tree is no longer clean" }; } const hidden = flags(cwd); if (hidden === null) { return { ok: false, detail: "index flag state could not be verified" }; } if (hidden) { return { ok: false, detail: "the index carries assume-unchanged/skip-worktree flag(s) — worktree cleanliness " + "cannot be proven against the snapshot", }; } return { ok: true, detail: null }; } /** The bracket probes' output budget: whole-checkout listings can be large. */ const LISTING_MAX_BUFFER = 64 * 1024 * 1024; /** Run one listing command; raw stdout (empty = meaningful), null on ANY failure. */ function listing(cwd: string, args: string[]): string | null { try { return execFileSync("git", ["--no-optional-locks", ...args], { cwd, encoding: "utf8", timeout: 60_000, maxBuffer: LISTING_MAX_BUFFER, stdio: ["ignore", "pipe", "ignore"], }); } catch { return null; } } /** * The tracked paths with uncommitted changes (staged or not) — `[]` = the tracked tree is clean; * untracked and ignored paths never appear. Parses `status --porcelain=v1 -z * --untracked-files=no --ignore-submodules=none`: a record whose X or Y status is a rename/copy * is followed by its original path as the next NUL token, which is consumed (the new path is * reported). `--ignore-submodules=none` overrides every `submodule..ignore` / * `diff.ignoreSubmodules` setting (`.gitmodules` included), so a submodule whose HEAD moved or * whose content is dirty — untracked files inside it included — is a changed path, never * hidden by configuration (a submodule's own untracked content is therefore an unclean start: * the superproject inventory cannot see inside it). **Fails closed to null** on any failure — * never conflate null with clean. */ export function trackedChanges(cwd: string): string[] | null { const out = listing(cwd, [ "status", "--porcelain=v1", "-z", "--untracked-files=no", "--ignore-submodules=none", ]); if (out === null) return null; const tokens = out.split("\0"); const paths: string[] = []; for (let i = 0; i < tokens.length; i++) { const record = tokens[i] ?? ""; if (record === "") continue; if (record.length < 4) return null; const x = record[0]; const y = record[1]; paths.push(record.slice(3)); if (x === "R" || x === "C" || y === "R" || y === "C") i++; } return paths; } /** * The non-ignored untracked inventory (`ls-files --others --exclude-standard -z` — ignored paths * and empty directories never appear, so nothing git could commit is missed), each path with its * kind and digest: a file's sha256, a symlink's target, `""` for anything else. Sorted by path; * `[]` when none. **Fails closed to null** on any git, lstat or read failure — an untracked file * the probe cannot read is unprovable. Hashing is uncapped on purpose: a cap would reopen the * overwritten-in-place blind spot. */ export function untrackedInventory(cwd: string): UntrackedEntry[] | null { const out = listing(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]); if (out === null) return null; const entries: UntrackedEntry[] = []; try { for (const path of out.split("\0")) { if (path === "") continue; const absolute = resolve(cwd, path); const stat = lstatSync(absolute); if (stat.isSymbolicLink()) { entries.push({ path, kind: "symlink", digest: readlinkSync(absolute) }); } else if (stat.isFile()) { const digest = createHash("sha256").update(readFileSync(absolute)).digest("hex"); entries.push({ path, kind: "file", digest }); } else { entries.push({ path, kind: "other", digest: "" }); } } } catch { return null; } return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); } /** The bracket's probe seam (the `revalidationBracket` pattern): tests pin each null arm. */ export interface CheckoutProbes { head?: (cwd: string) => string | null; trackedChanges?: (cwd: string) => string[] | null; flags?: (cwd: string) => boolean | null; untracked?: (cwd: string) => UntrackedEntry[] | null; } /** One observation of the checkout through the (overridable) fail-closed probes. */ export function observeCheckout(cwd: string, probes?: CheckoutProbes): SnapshotObservation { return { head: (probes?.head ?? headSha)(cwd), trackedChanges: (probes?.trackedChanges ?? trackedChanges)(cwd), flags: (probes?.flags ?? indexHidesChanges)(cwd), untracked: (probes?.untracked ?? untrackedInventory)(cwd), }; } /** The clean-start policy over a live observation (contracts.md §8.75(l)). */ export function checkoutCleanStart( cwd: string, probes?: CheckoutProbes, ): { ok: true; snapshot: CheckoutSnapshot } | { ok: false; detail: string } { return cleanStart(observeCheckout(cwd, probes)); } /** The end-state bracket over a live observation: equality with the snapshot, fail-closed. */ export function checkoutBracket( cwd: string, snapshot: CheckoutSnapshot, probes?: CheckoutProbes, ): BracketOutcome { return compareEndState(snapshot, observeCheckout(cwd, probes)); } /** * The `git log --oneline ..HEAD` listing of commits now ahead of `fromSha` — or every * commit (`git log --oneline HEAD`) when `fromSha` is null (HEAD was unborn at capture time). * This is range evidence, not proof that this command created every listed commit. **Fail-open**: * null on failure or when the range is empty. */ export function commitsSince(cwd: string, fromSha: string | null): string | null { const range = fromSha === null ? "HEAD" : `${fromSha}..HEAD`; return git(cwd, ["log", "--oneline", range]); }