import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs"; import { hostname } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; import { createNativeReviewCli, type NativeReviewCli, type NativeValidateResult, } from "./native-review-cli.ts"; const TRANSACTION_SCHEMA = "gentle-pi.git-commit-transaction/v1"; const INVOCATION_SCHEMA = "gentle-pi.git-commit-transaction-invocation/v1"; const INDEX_ASSERTION_SCHEMA = "gentle-pi.git-commit-index-assertion/v1"; const COMMIT_CAPTURE_SCHEMA = "gentle-pi.git-commit-capture/v1"; const GIT_TIMEOUT_MS = 10_000; export const COMMIT_TRANSACTION_STATE = { PREPARED: "prepared", HOOK_RUNNING: "hook-running", AWAITING_NATIVE: "awaiting-native", AWAITING_REVIEW: "awaiting-review", VALIDATION_FAILED: "validation-failed", VALIDATED: "validated", COMMIT_RUNNING: "commit-running", COMMIT_FAILED: "commit-failed", COMMITTED: "committed", HOOK_FAILED: "hook-failed", INTERRUPTED: "interrupted", INCIDENT: "incident", ABANDONED: "abandoned", } as const; export type CommitTransactionState = (typeof COMMIT_TRANSACTION_STATE)[keyof typeof COMMIT_TRANSACTION_STATE]; export interface CommitTransactionAuthorization { lineageId: string; storeRevision: string; fingerprint: string; intendedTree: string; } export interface CommitTransactionInvocation { schema: typeof INVOCATION_SCHEMA; transactionId: string; command: string; commandHash: string; cwd: string; arguments: readonly string[]; authorization: CommitTransactionAuthorization; } interface CommitTransactionRecordBody { schema: typeof TRANSACTION_SCHEMA; transaction_id: string; repository_id: string; repository_root: string; common_directory: string; git_directory: string; command: string; command_hash: string; arguments: readonly string[]; original_head?: string; original_head_tree?: string; original_index_tree: string; original_index_hash: string; authorized_pre_hook_tree: string; state: CommitTransactionState; created_at: string; updated_at: string; hook_runs: number; invocation_ids: readonly string[]; lineage_history: readonly string[]; post_hook_tree?: string; post_hook_index_hash?: string; authorized_tree?: string; authority_revision?: string; gate_context_hash?: string; committed_head?: string; committed_tree?: string; git_created_head?: string; git_created_tree?: string; error?: string; native_result?: Record; } export interface CommitTransactionRecord extends CommitTransactionRecordBody { record_hash: string; } export interface CommitTransactionInspection { status: "clean" | "active" | "corrupted"; record?: CommitTransactionRecord; reason?: string; } export interface CommitTransactionResult { transactionId: string; status: "committed" | "recovered"; head: string; tree: string; } export interface CommitTransactionDependencies { nativeReviewCli?: NativeReviewCli; runnerPath?: string; now?: () => Date; signal?: AbortSignal; failpoint?: "after-commit-before-proof"; } interface RepositoryBinding { root: string; commonDir: string; gitDir: string; repositoryId: string; stateDir: string; activePath: string; lockPath: string; historyDir: string; } interface LockBody { schema: "gentle-pi.git-commit-transaction-lock/v1"; pid: number; host: string; transaction_id: string; created_at: string; } interface ProcessResult { code: number; signal: NodeJS.Signals | null; } function sha256(value: string | Buffer): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; } function canonicalJson(value: unknown): string { if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; const object = value as Record; return `{${Object.keys(object).filter((key) => object[key] !== undefined).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`; } function recordHash(body: CommitTransactionRecordBody): string { return sha256(canonicalJson(body)); } function git(cwd: string, args: readonly string[]): string { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS, windowsHide: true, }).trim(); } function absoluteGitPath(cwd: string, name: string): string { const value = git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", name]); return isAbsolute(value) ? value : resolve(cwd, value); } // Runs a probe that may exit nonzero as an expected signal (absent ref, unborn // HEAD). Returns the exit status and trimmed stdout. Timeout and I/O failures // propagate instead of being masked as a status, so callers fail closed. function probeGit(cwd: string, args: readonly string[]): { status: number; stdout: string } { try { const stdout = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS, windowsHide: true, }); return { status: 0, stdout: stdout.trim() }; } catch (error) { const detail = error as NodeJS.ErrnoException & { killed?: boolean; status?: number; stdout?: string | Buffer }; if (detail.code === "ETIMEDOUT" || detail.killed === true) throw error; if (typeof detail.status === "number") return { status: detail.status, stdout: typeof detail.stdout === "string" ? detail.stdout.trim() : "" }; throw error; } } // Resolves HEAD to a commit SHA, or undefined only for a valid unborn symbolic // HEAD (symbolic HEAD pointing at a branch with no commits). Timeout, I/O, // corruption, and all other failures propagate (fail closed on uncertain HEAD // state). Classification uses status-based probes, not localized stderr text. function resolveHead(cwd: string): string | undefined { try { return git(cwd, ["rev-parse", "--verify", "HEAD"]); } catch (error) { const detail = error as NodeJS.ErrnoException & { killed?: boolean }; if (detail.code === "ETIMEDOUT" || detail.killed === true) throw error; if (typeof detail.status !== "number") throw error; const symbolic = probeGit(cwd, ["symbolic-ref", "--quiet", "HEAD"]); if (symbolic.status !== 0) throw error; // show-ref --verify --quiet distinguishes: status 1 = ref absent (valid // unborn), status 0 = ref exists and valid (rethrow original HEAD error), // any other status (128, etc.) = corruption/missing object (fail closed). const refProbe = probeGit(cwd, ["show-ref", "--verify", "--quiet", symbolic.stdout]); if (refProbe.status === 1) return undefined; throw error; } } function repositoryBinding(cwd: string): RepositoryBinding { const root = realpathSync(git(cwd, ["rev-parse", "--show-toplevel"])); const commonDirValue = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]); const gitDirValue = git(root, ["rev-parse", "--path-format=absolute", "--git-dir"]); const commonDir = realpathSync(commonDirValue); const gitDir = realpathSync(gitDirValue); // Preserve the durable repository identity across the unborn-handling // upgrade: a born repository keeps the byte-for-byte previous formula // `sha256(canonicalJson({ common_directory: commonDir, roots }))` with // `roots` the sorted root commits reachable from HEAD. An unborn // repository has no HEAD, so `rev-list HEAD` cannot run; resolveHead // already classifies HEAD state and propagates timeout/corruption/I/O // failures rather than masking them, so the unborn branch gets a // deterministic safe roots representation (the empty set) without // hiding real errors. const head = resolveHead(root); const roots = head === undefined ? [] : git(root, ["rev-list", "--max-parents=0", "HEAD"]).split(/\r?\n/).filter(Boolean).sort(); const repositoryId = sha256(canonicalJson({ common_directory: commonDir, roots })); const worktreeKey = sha256(gitDir).slice("sha256:".length, "sha256:".length + 24); const stateDir = join(commonDir, "gentle-pi", "commit-transactions", worktreeKey); return { root, commonDir, gitDir, repositoryId, stateDir, activePath: join(stateDir, "active.json"), lockPath: join(stateDir, "lock.json"), historyDir: join(stateDir, "history"), }; } function ensureStateDirectories(binding: RepositoryBinding): void { mkdirSync(binding.historyDir, { recursive: true, mode: 0o700 }); chmodSync(dirname(binding.stateDir), 0o700); chmodSync(binding.stateDir, 0o700); chmodSync(binding.historyDir, 0o700); } function atomicWrite(path: string, value: string): void { const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; const descriptor = openSync(temporary, "wx", 0o600); try { writeFileSync(descriptor, value, "utf8"); fsyncSync(descriptor); } finally { closeSync(descriptor); } renameSync(temporary, path); if (process.platform !== "win32") { const directory = openSync(dirname(path), "r"); try { fsyncSync(directory); } finally { closeSync(directory); } } } function writeRecord(path: string, body: CommitTransactionRecordBody): CommitTransactionRecord { const record: CommitTransactionRecord = { ...body, record_hash: recordHash(body) }; atomicWrite(path, `${canonicalJson(record)}\n`); return record; } function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((entry) => typeof entry === "string"); } function decodeRecord(value: unknown): CommitTransactionRecord { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("commit transaction record is not an object"); const record = value as Record; if (record.schema !== TRANSACTION_SCHEMA) throw new Error("commit transaction record schema is incompatible"); for (const field of [ "transaction_id", "repository_id", "repository_root", "common_directory", "git_directory", "command", "command_hash", "original_index_tree", "original_index_hash", "authorized_pre_hook_tree", "state", "created_at", "updated_at", "record_hash", ]) if (typeof record[field] !== "string" || (record[field] as string).length === 0) throw new Error(`commit transaction record ${field} is invalid`); if (!isStringArray(record.arguments) || !isStringArray(record.invocation_ids) || !isStringArray(record.lineage_history)) throw new Error("commit transaction record arrays are invalid"); for (const field of ["original_head", "original_head_tree", "post_hook_tree", "post_hook_index_hash", "authorized_tree", "authority_revision", "gate_context_hash", "committed_head", "committed_tree", "git_created_head", "git_created_tree", "error"] as const) { if (record[field] !== undefined && typeof record[field] !== "string") throw new Error(`commit transaction record ${field} is invalid`); } if (!Number.isSafeInteger(record.hook_runs) || (record.hook_runs as number) < 0) throw new Error("commit transaction hook count is invalid"); if (!(Object.values(COMMIT_TRANSACTION_STATE) as readonly unknown[]).includes(record.state)) throw new Error("commit transaction state is invalid"); const { record_hash: hash, ...body } = record; if (recordHash(body as unknown as CommitTransactionRecordBody) !== hash) throw new Error("commit transaction record integrity check failed"); return record as unknown as CommitTransactionRecord; } function readRecord(path: string): CommitTransactionRecord | undefined { if (!existsSync(path)) return undefined; return decodeRecord(JSON.parse(readFileSync(path, "utf8"))); } function bodyOf(record: CommitTransactionRecord): CommitTransactionRecordBody { const { record_hash: _hash, ...body } = record; return body; } function transition( binding: RepositoryBinding, record: CommitTransactionRecord, state: CommitTransactionState, patch: Partial = {}, now: () => Date = () => new Date(), ): CommitTransactionRecord { return writeRecord(binding.activePath, { ...bodyOf(record), ...patch, state, updated_at: now().toISOString(), }); } function archive(binding: RepositoryBinding, record: CommitTransactionRecord): CommitTransactionRecord { const archived = writeRecord(join(binding.historyDir, `${record.transaction_id}.json`), bodyOf(record)); if (existsSync(binding.activePath)) unlinkSync(binding.activePath); return archived; } function processIsAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } } function acquireLock(binding: RepositoryBinding, transactionId: string, now: () => Date): () => void { ensureStateDirectories(binding); const body: LockBody = { schema: "gentle-pi.git-commit-transaction-lock/v1", pid: process.pid, host: hostname(), transaction_id: transactionId, created_at: now().toISOString(), }; for (let attempt = 0; attempt < 2; attempt += 1) { try { const descriptor = openSync(binding.lockPath, "wx", 0o600); try { writeFileSync(descriptor, `${canonicalJson(body)}\n`, "utf8"); fsyncSync(descriptor); } finally { closeSync(descriptor); } return () => { try { unlinkSync(binding.lockPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; let existing: Partial = {}; try { existing = JSON.parse(readFileSync(binding.lockPath, "utf8")) as Partial; } catch { /* fail closed below */ } if (existing.host === hostname() && typeof existing.pid === "number" && !processIsAlive(existing.pid)) { unlinkSync(binding.lockPath); continue; } throw new Error(`commit transaction lock is active${typeof existing.transaction_id === "string" ? ` for ${existing.transaction_id}` : ""}`); } } throw new Error("commit transaction stale lock could not be reconciled"); } function indexFingerprint(cwd: string): string { const indexPath = absoluteGitPath(cwd, "index"); if (!existsSync(indexPath)) return sha256("missing-index"); const before = statSync(indexPath); const bytes = readFileSync(indexPath); const after = statSync(indexPath); if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) { throw new Error("Git index changed while its transaction fingerprint was captured"); } return sha256(bytes); } function assertSafeCommitArguments(arguments_: readonly string[]): void { const booleanOptions = new Set([ "--allow-empty", "--allow-empty-message", "--amend", "--edit", "--no-edit", "--no-gpg-sign", "--no-post-rewrite", "--no-signoff", "--no-status", "--no-verify", "--quiet", "--short", "--signoff", "--status", "--verbose", ]); const valueOptions = new Set([ "--author", "--cleanup", "--date", "--file", "--fixup", "--message", "--reedit-message", "--reuse-message", "--squash", "-C", "-F", "-c", "-m", ]); const unsupportedTreeOptions = /^(?:--all|--include|--interactive|--only|--patch|--pathspec-from-file|--pathspec-file-nul|-a|-i|-o|-p)$/; for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index]!; if (argument === "--") { if (index !== arguments_.length - 1) throw new Error("commit pathspecs are unsupported by the transaction runner"); continue; } if (argument === "--dry-run") throw new Error("commit --dry-run does not create a transaction"); if (unsupportedTreeOptions.test(argument) || unsupportedTreeOptions.test(argument.split("=")[0]!)) throw new Error(`unsupported commit tree semantics: ${argument}`); if (argument === "-S" || argument === "--gpg-sign") continue; if (/^-S.+/.test(argument) || argument.startsWith("--gpg-sign=")) continue; if (/^-[^-]+$/.test(argument) && argument.length > 2) { const flags = argument.slice(1); if (/[^emnsqv]/.test(flags)) throw new Error(`unsupported combined commit option: ${argument}`); if (flags.includes("m")) { index += 1; if (arguments_[index] === undefined) throw new Error("commit message option is missing its value"); } continue; } if (booleanOptions.has(argument)) continue; if ([...valueOptions].some((option) => argument.startsWith(`${option}=`))) continue; if (valueOptions.has(argument)) { index += 1; if (arguments_[index] === undefined) throw new Error(`commit option ${argument} is missing its value`); continue; } if (!argument.startsWith("-")) throw new Error("commit pathspecs are unsupported by the transaction runner"); throw new Error(`unsupported commit option: ${argument}`); } } function shellQuote(value: string): string { if (process.platform === "win32") return `\"${value.replaceAll("\"", "\\\"")}\"`; return `'${value.replaceAll("'", `'\\''`)}'`; } function invocationBody(invocation: CommitTransactionInvocation): Record { return { schema: invocation.schema, transaction_id: invocation.transactionId, command: invocation.command, command_hash: invocation.commandHash, cwd: invocation.cwd, arguments: invocation.arguments, authorization: { lineage_id: invocation.authorization.lineageId, store_revision: invocation.authorization.storeRevision, fingerprint: invocation.authorization.fingerprint, intended_tree: invocation.authorization.intendedTree, }, }; } export function encodeCommitTransactionInvocation(invocation: CommitTransactionInvocation): string { return Buffer.from(canonicalJson(invocationBody(invocation)), "utf8").toString("base64url"); } export function decodeCommitTransactionInvocation(encoded: string): CommitTransactionInvocation { let value: unknown; try { value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); } catch { throw new Error("commit transaction invocation is malformed"); } if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("commit transaction invocation is invalid"); const body = value as Record; const authorization = body.authorization; if (body.schema !== INVOCATION_SCHEMA || typeof body.transaction_id !== "string" || typeof body.command !== "string" || typeof body.command_hash !== "string" || typeof body.cwd !== "string" || !isStringArray(body.arguments) || typeof authorization !== "object" || authorization === null || Array.isArray(authorization)) throw new Error("commit transaction invocation is incompatible"); const native = authorization as Record; for (const field of ["lineage_id", "store_revision", "fingerprint", "intended_tree"]) if (typeof native[field] !== "string" || (native[field] as string).length === 0) throw new Error(`commit transaction authorization ${field} is invalid`); if (sha256(body.command) !== body.command_hash) throw new Error("commit transaction command hash is invalid"); assertSafeCommitArguments(body.arguments); return { schema: INVOCATION_SCHEMA, transactionId: body.transaction_id, command: body.command, commandHash: body.command_hash, cwd: body.cwd, arguments: body.arguments, authorization: { lineageId: native.lineage_id as string, storeRevision: native.store_revision as string, fingerprint: native.fingerprint as string, intendedTree: native.intended_tree as string, }, }; } export function prepareCommitTransactionInvocation(input: { command: string; cwd: string; arguments: readonly string[]; authorization: CommitTransactionAuthorization; }): CommitTransactionInvocation { assertSafeCommitArguments(input.arguments); const binding = repositoryBinding(input.cwd); const head = resolveHead(binding.root); const currentTree = git(binding.root, ["write-tree"]); if (currentTree !== input.authorization.intendedTree) throw new Error("commit transaction pre-hook index no longer matches its controller authorization"); let transactionId = randomUUID(); const active = readRecord(binding.activePath); const commandHash = sha256(input.command); if (active !== undefined && active.command_hash === commandHash && active.original_head === head) transactionId = active.transaction_id; return { schema: INVOCATION_SCHEMA, transactionId, command: input.command, commandHash, cwd: binding.root, arguments: [...input.arguments], authorization: { ...input.authorization }, }; } export function commitTransactionRunnerPath(): string { return fileURLToPath(new URL("../scripts/run-git-commit-transaction.mjs", import.meta.url)); } export function buildCommitTransactionShellCommand(invocation: CommitTransactionInvocation): string { return [ shellQuote(process.execPath), shellQuote(commitTransactionRunnerPath()), "run", shellQuote(encodeCommitTransactionInvocation(invocation)), ].join(" "); } function runProcess(file: string, arguments_: readonly string[], cwd: string, signal?: AbortSignal): Promise { return new Promise((resolvePromise, reject) => { const child = spawn(file, [...arguments_], { cwd, env: process.env, stdio: "inherit", windowsHide: true, signal }); child.once("error", reject); child.once("close", (code, processSignal) => resolvePromise({ code: code ?? 1, signal: processSignal })); }); } function assertionPayload(binding: RepositoryBinding, record: CommitTransactionRecord): string { return Buffer.from(canonicalJson({ schema: INDEX_ASSERTION_SCHEMA, cwd: binding.root, transaction_id: record.transaction_id, authorized_tree: record.authorized_tree, }), "utf8").toString("base64url"); } function capturePayload(binding: RepositoryBinding, record: CommitTransactionRecord): string { return Buffer.from(canonicalJson({ schema: COMMIT_CAPTURE_SCHEMA, cwd: binding.root, transaction_id: record.transaction_id, authorized_tree: record.authorized_tree, }), "utf8").toString("base64url"); } function writeHook(path: string, lines: readonly string[]): void { writeFileSync(path, `#!/bin/sh\nset -eu\n${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o700 }); chmodSync(path, 0o700); } function createHookProxy(binding: RepositoryBinding, record: CommitTransactionRecord, runnerPath: string): string { if (record.authorized_tree === undefined) throw new Error("commit transaction cannot create hooks before native authorization"); const originalHooks = absoluteGitPath(binding.root, "hooks"); const proxy = join(binding.stateDir, `hooks-${record.transaction_id}`); rmSync(proxy, { recursive: true, force: true }); mkdirSync(proxy, { recursive: true, mode: 0o700 }); const assertion = `${shellQuote(process.execPath)} ${shellQuote(runnerPath)} assert-index ${shellQuote(assertionPayload(binding, record))}`; const capture = `${shellQuote(process.execPath)} ${shellQuote(runnerPath)} capture-commit ${shellQuote(capturePayload(binding, record))}`; writeHook(join(proxy, "pre-commit"), [`exec ${assertion}`]); for (const name of ["prepare-commit-msg", "commit-msg"]) { const original = join(originalHooks, name); const lines = existsSync(original) && (statSync(original).mode & 0o111) !== 0 ? [`${shellQuote(original)} \"$@\"`, `exec ${assertion}`] : [`exec ${assertion}`]; writeHook(join(proxy, name), lines); } const postCommit = join(originalHooks, "post-commit"); writeHook(join(proxy, "post-commit"), [capture, ...(existsSync(postCommit) && (statSync(postCommit).mode & 0o111) !== 0 ? [`exec ${shellQuote(postCommit)} \"$@\"`] : [])]); const postRewrite = join(originalHooks, "post-rewrite"); if (existsSync(postRewrite) && (statSync(postRewrite).mode & 0o111) !== 0) writeHook(join(proxy, "post-rewrite"), [`exec ${shellQuote(postRewrite)} \"$@\"`]); return proxy; } function nativeResultDocument(result: NativeValidateResult): Record { return { allowed: result.allowed, result: result.result, action: result.action, reason: result.reason, context: result.gateContext.raw, }; } function validateNativeTree(result: NativeValidateResult, lineageId: string, tree: string): void { if (!result.allowed || result.result !== "allow") throw new Error(`native pre-commit validation denied the post-hook tree: ${result.result}; ${result.action}; ${result.reason}`); if (result.gateContext.lineageId !== lineageId) throw new Error("native pre-commit validation returned a different lineage"); if (result.gateContext.raw.gate !== "pre-commit") throw new Error("native pre-commit validation returned a different gate"); if (result.gateContext.raw.candidate_tree !== tree) throw new Error("native pre-commit validation did not authorize the exact post-hook tree"); } function createRecord(binding: RepositoryBinding, invocation: CommitTransactionInvocation, now: () => Date): CommitTransactionRecord { const head = resolveHead(binding.root); const tree = git(binding.root, ["write-tree"]); if (tree !== invocation.authorization.intendedTree) throw new Error("commit transaction index changed after controller authorization"); const timestamp = now().toISOString(); return writeRecord(binding.activePath, { schema: TRANSACTION_SCHEMA, transaction_id: invocation.transactionId, repository_id: binding.repositoryId, repository_root: binding.root, common_directory: binding.commonDir, git_directory: binding.gitDir, command: invocation.command, command_hash: invocation.commandHash, arguments: [...invocation.arguments], original_head: head, original_head_tree: head === undefined ? undefined : git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]), original_index_tree: tree, original_index_hash: indexFingerprint(binding.root), authorized_pre_hook_tree: invocation.authorization.intendedTree, state: COMMIT_TRANSACTION_STATE.PREPARED, created_at: timestamp, updated_at: timestamp, hook_runs: 0, invocation_ids: [invocation.transactionId], lineage_history: [invocation.authorization.lineageId], }); } function assertInvocationMatches(binding: RepositoryBinding, record: CommitTransactionRecord, invocation: CommitTransactionInvocation): void { if (record.repository_id !== binding.repositoryId || record.repository_root !== binding.root || record.common_directory !== binding.commonDir || record.git_directory !== binding.gitDir) throw new Error("commit transaction repository identity changed"); if (record.transaction_id !== invocation.transactionId || record.command_hash !== invocation.commandHash || record.command !== invocation.command || canonicalJson(record.arguments) !== canonicalJson(invocation.arguments)) throw new Error("commit transaction exact retry does not match the durable command intent"); if (resolveHead(binding.root) !== record.original_head) throw new Error("commit transaction HEAD changed before reconciliation"); } function recoverCompletedCommit(binding: RepositoryBinding, record: CommitTransactionRecord, now: () => Date): CommitTransactionResult | undefined { if (record.state !== COMMIT_TRANSACTION_STATE.COMMIT_RUNNING && record.state !== COMMIT_TRANSACTION_STATE.COMMITTED) return undefined; const head = resolveHead(binding.root); if (head === record.original_head) return undefined; if (head === undefined) { transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { error: "HEAD disappeared during commit transaction" }, now); throw new Error("commit transaction incident: HEAD disappeared during commit; publication remains blocked"); } const tree = git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]); if (record.git_created_head === undefined || record.git_created_tree === undefined || head !== record.git_created_head || tree !== record.git_created_tree || tree !== record.authorized_tree) { transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { committed_head: head, committed_tree: tree, error: "HEAD identity differs from the exact Git-created authorized commit" }, now); throw new Error("commit transaction incident: HEAD identity differs from the exact Git-created authorized commit; push, PR, and release remain blocked"); } const committed = transition(binding, record, COMMIT_TRANSACTION_STATE.COMMITTED, { committed_head: head, committed_tree: tree }, now); archive(binding, committed); return { transactionId: record.transaction_id, status: "recovered", head, tree }; } function appendInvocation(record: CommitTransactionRecord, invocation: CommitTransactionInvocation): Partial { return { invocation_ids: [...new Set([...record.invocation_ids, invocation.transactionId])], lineage_history: [...new Set([...record.lineage_history, invocation.authorization.lineageId])], }; } export async function runGitCommitTransaction( invocation: CommitTransactionInvocation, dependencies: CommitTransactionDependencies = {}, ): Promise { assertSafeCommitArguments(invocation.arguments); if (sha256(invocation.command) !== invocation.commandHash) throw new Error("commit transaction command identity is invalid"); const now = dependencies.now ?? (() => new Date()); const binding = repositoryBinding(invocation.cwd); const releaseLock = acquireLock(binding, invocation.transactionId, now); let record: CommitTransactionRecord | undefined; try { record = readRecord(binding.activePath); if (record !== undefined) { assertInvocationMatches(binding, record, invocation); const recovered = recoverCompletedCommit(binding, record, now); if (recovered !== undefined) return recovered; if ([COMMIT_TRANSACTION_STATE.HOOK_FAILED, COMMIT_TRANSACTION_STATE.VALIDATION_FAILED, COMMIT_TRANSACTION_STATE.COMMIT_FAILED, COMMIT_TRANSACTION_STATE.INTERRUPTED, COMMIT_TRANSACTION_STATE.INCIDENT, COMMIT_TRANSACTION_STATE.HOOK_RUNNING, COMMIT_TRANSACTION_STATE.COMMIT_RUNNING].includes(record.state as never)) { throw new Error(`commit transaction ${record.transaction_id} requires explicit recovery from state ${record.state}; no Git state was rolled back`); } if (record.state !== COMMIT_TRANSACTION_STATE.AWAITING_REVIEW && record.state !== COMMIT_TRANSACTION_STATE.AWAITING_NATIVE && record.state !== COMMIT_TRANSACTION_STATE.VALIDATED && record.state !== COMMIT_TRANSACTION_STATE.PREPARED) throw new Error(`commit transaction cannot resume from ${record.state}`); record = transition(binding, record, record.state, appendInvocation(record, invocation), now); } else { record = createRecord(binding, invocation, now); } const noVerify = invocation.arguments.includes("--no-verify") || invocation.arguments.some((argument) => /^-[^-]*n/.test(argument)); if (record.state === COMMIT_TRANSACTION_STATE.PREPARED) { if (!noVerify) { record = transition(binding, record, COMMIT_TRANSACTION_STATE.HOOK_RUNNING, { hook_runs: record.hook_runs + 1 }, now); const hook = await runProcess("git", ["hook", "run", "--ignore-missing", "pre-commit"], binding.root, dependencies.signal); if (hook.code !== 0 || hook.signal !== null) { record = transition(binding, record, COMMIT_TRANSACTION_STATE.HOOK_FAILED, { error: `pre-commit hook failed with ${hook.signal ?? `exit ${hook.code}`}` }, now); throw new Error(`pre-commit hook failed; transaction ${record.transaction_id} created no commit and requires explicit recovery`); } } const postHookTree = git(binding.root, ["write-tree"]); record = transition(binding, record, COMMIT_TRANSACTION_STATE.AWAITING_NATIVE, { post_hook_tree: postHookTree, post_hook_index_hash: indexFingerprint(binding.root), }, now); } if (record.state === COMMIT_TRANSACTION_STATE.AWAITING_REVIEW) { const tree = git(binding.root, ["write-tree"]); if (tree !== record.post_hook_tree || indexFingerprint(binding.root) !== record.post_hook_index_hash) throw new Error("post-hook index changed while the commit transaction awaited review"); } if (record.state === COMMIT_TRANSACTION_STATE.AWAITING_NATIVE || record.state === COMMIT_TRANSACTION_STATE.AWAITING_REVIEW || record.state === COMMIT_TRANSACTION_STATE.VALIDATED) { const currentTree = git(binding.root, ["write-tree"]); if (record.post_hook_tree !== currentTree) throw new Error("commit transaction post-hook tree changed before native validation"); if (currentTree !== invocation.authorization.intendedTree) { const mutation = `pre-commit hook mutated the staged candidate: post-hook tree ${currentTree} is not the authorized tree ${invocation.authorization.intendedTree}; the content-bound receipt no longer covers this index; normalize sources and re-run review explicitly, or make the hook convergent, then retry the exact command; transaction ${record.transaction_id} created no commit`; record = transition(binding, record, COMMIT_TRANSACTION_STATE.AWAITING_REVIEW, { error: mutation }, now); throw new Error(mutation); } const nativeReviewCli = dependencies.nativeReviewCli ?? createNativeReviewCli(); let nativeResult: NativeValidateResult; try { nativeResult = await nativeReviewCli.validate({ cwd: binding.root, gate: "pre-commit", lineageId: invocation.authorization.lineageId, ...(dependencies.signal === undefined ? {} : { signal: dependencies.signal }) }); } catch (error) { record = transition(binding, record, COMMIT_TRANSACTION_STATE.VALIDATION_FAILED, { error: error instanceof Error ? error.message : String(error) }, now); throw error; } if (!nativeResult.allowed || nativeResult.result !== "allow") { const state = nativeResult.result === "scope-changed" ? COMMIT_TRANSACTION_STATE.AWAITING_REVIEW : COMMIT_TRANSACTION_STATE.VALIDATION_FAILED; record = transition(binding, record, state, { native_result: nativeResultDocument(nativeResult), error: nativeResult.reason }, now); throw new Error(`native pre-commit validation denied the post-hook tree: ${nativeResult.result}; ${nativeResult.action}; ${nativeResult.reason}`); } try { validateNativeTree(nativeResult, invocation.authorization.lineageId, currentTree); } catch (error) { record = transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { native_result: nativeResultDocument(nativeResult), error: error instanceof Error ? error.message : String(error) }, now); throw error; } record = transition(binding, record, COMMIT_TRANSACTION_STATE.VALIDATED, { authorized_tree: currentTree, authority_revision: nativeResult.gateContext.storeRevision, gate_context_hash: sha256(canonicalJson(nativeResult.gateContext.raw)), native_result: nativeResultDocument(nativeResult), error: undefined, }, now); } const runnerPath = dependencies.runnerPath ?? commitTransactionRunnerPath(); const proxy = createHookProxy(binding, record, runnerPath); dependencies.signal?.throwIfAborted(); record = transition(binding, record, COMMIT_TRANSACTION_STATE.COMMIT_RUNNING, {}, now); const commit = await runProcess("git", ["-c", `core.hooksPath=${proxy}`, "commit", ...invocation.arguments], binding.root); if (dependencies.failpoint === "after-commit-before-proof") throw new Error("commit transaction test interruption after Git returned"); record = readRecord(binding.activePath) ?? record; const head = resolveHead(binding.root); const headTree = head === undefined ? undefined : git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]); const headChanged = head !== record.original_head; if (headChanged && head === record.git_created_head && headTree === record.git_created_tree && headTree === record.authorized_tree) { const committed = transition(binding, record, COMMIT_TRANSACTION_STATE.COMMITTED, { committed_head: head, committed_tree: headTree, ...(commit.code === 0 && commit.signal === null ? {} : { error: `Git returned ${commit.signal ?? `exit ${commit.code}`} after creating the authorized commit` }) }, now); archive(binding, committed); return { transactionId: record.transaction_id, status: "committed", head: head!, tree: headTree! }; } if (headChanged) { transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { committed_head: head, committed_tree: headTree, error: "HEAD identity differs from the exact Git-created authorized commit" }, now); throw new Error("commit transaction incident: HEAD identity changed after Git created the authorized commit; publication remains blocked"); } record = transition(binding, record, COMMIT_TRANSACTION_STATE.COMMIT_FAILED, { error: `Git commit failed with ${commit.signal ?? `exit ${commit.code}`}` }, now); throw new Error(`Git commit failed; transaction ${record.transaction_id} created no commit and requires explicit recovery`); } catch (error) { if (record !== undefined && dependencies.signal?.aborted === true && existsSync(binding.activePath)) { try { const active = readRecord(binding.activePath) ?? record; if (resolveHead(binding.root) === active.original_head) transition(binding, active, COMMIT_TRANSACTION_STATE.INTERRUPTED, { error: "commit transaction was cancelled" }, now); } catch { /* retain the earlier durable state */ } } throw error; } finally { releaseLock(); } } export function assertCommitTransactionIndex(encoded: string): void { let value: unknown; try { value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); } catch { throw new Error("commit transaction index assertion is malformed"); } if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("commit transaction index assertion is invalid"); const body = value as Record; if (body.schema !== INDEX_ASSERTION_SCHEMA || typeof body.cwd !== "string" || typeof body.transaction_id !== "string" || typeof body.authorized_tree !== "string") throw new Error("commit transaction index assertion is incompatible"); const binding = repositoryBinding(body.cwd); const record = readRecord(binding.activePath); if (record === undefined || record.transaction_id !== body.transaction_id || record.state !== COMMIT_TRANSACTION_STATE.COMMIT_RUNNING || record.authorized_tree !== body.authorized_tree) throw new Error("commit transaction index assertion has no matching active authorization"); if (git(binding.root, ["write-tree"]) !== body.authorized_tree) throw new Error("Git hook changed the index after native pre-commit authorization"); } export function captureCommitTransactionHead(encoded: string): void { let value: unknown; try { value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); } catch { throw new Error("commit transaction capture is malformed"); } if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("commit transaction capture is invalid"); const body = value as Record; if (body.schema !== COMMIT_CAPTURE_SCHEMA || typeof body.cwd !== "string" || typeof body.transaction_id !== "string" || typeof body.authorized_tree !== "string") throw new Error("commit transaction capture is incompatible"); const binding = repositoryBinding(body.cwd); const record = readRecord(binding.activePath); if (record === undefined || record.transaction_id !== body.transaction_id || record.state !== COMMIT_TRANSACTION_STATE.COMMIT_RUNNING || record.authorized_tree !== body.authorized_tree) throw new Error("commit transaction capture has no matching active authorization"); const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]); const tree = git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]); if (head === record.original_head || tree !== record.authorized_tree) throw new Error("commit transaction capture does not match a new authorized commit"); if (record.git_created_head !== undefined && (record.git_created_head !== head || record.git_created_tree !== tree)) throw new Error("commit transaction capture conflicts with the recorded Git commit"); transition(binding, record, COMMIT_TRANSACTION_STATE.COMMIT_RUNNING, { git_created_head: head, git_created_tree: tree }); } export function inspectCommitTransaction(cwd: string): CommitTransactionInspection { let binding: RepositoryBinding; try { binding = repositoryBinding(cwd); } catch (error) { return { status: "corrupted", reason: error instanceof Error ? error.message : String(error) }; } try { const record = readRecord(binding.activePath); return record === undefined ? { status: "clean" } : { status: "active", record }; } catch (error) { return { status: "corrupted", reason: error instanceof Error ? error.message : String(error) }; } } export function reconcileCommitTransaction(cwd: string): CommitTransactionInspection { const binding = repositoryBinding(cwd); const active = readRecord(binding.activePath); if (active === undefined) return { status: "clean" }; const now = () => new Date(); const releaseLock = acquireLock(binding, active.transaction_id, now); try { const record = readRecord(binding.activePath); if (record === undefined) return { status: "clean" }; const recovered = recoverCompletedCommit(binding, record, now); return recovered === undefined ? { status: "active", record } : { status: "clean" }; } finally { releaseLock(); } } export function assertNoUnresolvedCommitTransaction(cwd: string): void { const inspection = inspectCommitTransaction(cwd); if (inspection.status === "clean") return; if (inspection.status === "corrupted") throw new Error(`commit transaction recovery state is corrupted: ${inspection.reason}`); throw new Error(`commit transaction ${inspection.record!.transaction_id} is unresolved in state ${inspection.record!.state}; publication is blocked until deterministic recovery completes`); } export function abandonCommitTransaction(cwd: string): CommitTransactionRecord { const binding = repositoryBinding(cwd); const now = () => new Date(); const active = readRecord(binding.activePath); if (active === undefined) throw new Error("no active commit transaction exists"); const releaseLock = acquireLock(binding, active.transaction_id, now); try { const record = readRecord(binding.activePath); if (record === undefined) throw new Error("active commit transaction disappeared during recovery"); if (resolveHead(binding.root) !== record.original_head) throw new Error("cannot abandon a commit transaction after HEAD changed; reconcile the committed tree instead"); const abandoned = transition(binding, record, COMMIT_TRANSACTION_STATE.ABANDONED, { error: "explicitly abandoned without changing HEAD or index" }, now); return archive(binding, abandoned); } finally { releaseLock(); } } export function verifyCommitTransactionResult(cwd: string, transactionId: string): CommitTransactionResult { const binding = repositoryBinding(cwd); const active = readRecord(binding.activePath); if (active?.transaction_id === transactionId) throw new Error(`commit transaction ${transactionId} remains unresolved in state ${active.state}`); const history = readRecord(join(binding.historyDir, `${transactionId}.json`)); if (history === undefined || history.state !== COMMIT_TRANSACTION_STATE.COMMITTED || history.committed_head === undefined || history.committed_tree === undefined || history.git_created_head !== history.committed_head || history.git_created_tree !== history.committed_tree || history.authorized_tree !== history.committed_tree) throw new Error(`commit transaction ${transactionId} has no durable verified commit result`); const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]); const tree = git(binding.root, ["rev-parse", "--verify", `${history.committed_head}^{tree}`]); if (head !== history.committed_head || tree !== history.committed_tree) throw new Error(`commit transaction ${transactionId} HEAD proof changed before tool_result reconciliation`); return { transactionId, status: "committed", head, tree }; }