Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 77 additions & 17 deletions lib/git-commit-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ interface CommitTransactionRecordBody {
command: string;
command_hash: string;
arguments: readonly string[];
original_head: string;
original_head_tree: string;
original_head?: string;
original_head_tree?: string;
original_index_tree: string;
original_index_hash: string;
authorized_pre_hook_tree: string;
Expand Down Expand Up @@ -180,13 +180,68 @@ function absoluteGitPath(cwd: string, name: string): string {
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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
const roots = git(root, ["rev-list", "--max-parents=0", "HEAD"]).split(/\r?\n/).filter(Boolean).sort();
// 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);
Expand Down Expand Up @@ -241,11 +296,11 @@ function decodeRecord(value: unknown): CommitTransactionRecord {
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_head", "original_head_tree", "original_index_tree",
"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 ["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) {
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");
Expand Down Expand Up @@ -437,7 +492,7 @@ export function prepareCommitTransactionInvocation(input: {
}): CommitTransactionInvocation {
assertSafeCommitArguments(input.arguments);
const binding = repositoryBinding(input.cwd);
const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
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();
Expand Down Expand Up @@ -540,7 +595,7 @@ function validateNativeTree(result: NativeValidateResult, lineageId: string, tre
}

function createRecord(binding: RepositoryBinding, invocation: CommitTransactionInvocation, now: () => Date): CommitTransactionRecord {
const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
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();
Expand All @@ -555,7 +610,7 @@ function createRecord(binding: RepositoryBinding, invocation: CommitTransactionI
command_hash: invocation.commandHash,
arguments: [...invocation.arguments],
original_head: head,
original_head_tree: git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]),
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,
Expand All @@ -571,13 +626,17 @@ function createRecord(binding: RepositoryBinding, invocation: CommitTransactionI
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 (git(binding.root, ["rev-parse", "--verify", "HEAD"]) !== record.original_head) throw new Error("commit transaction HEAD changed before reconciliation");
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 = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
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);
Expand Down Expand Up @@ -684,14 +743,15 @@ export async function runGitCommitTransaction(
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 = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
const headTree = git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
if (head !== record.original_head && head === record.git_created_head && headTree === record.git_created_tree && headTree === record.authorized_tree) {
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, tree: headTree };
return { transactionId: record.transaction_id, status: "committed", head: head!, tree: headTree! };
}
if (head !== record.original_head) {
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");
}
Expand All @@ -701,7 +761,7 @@ export async function runGitCommitTransaction(
if (record !== undefined && dependencies.signal?.aborted === true && existsSync(binding.activePath)) {
try {
const active = readRecord(binding.activePath) ?? record;
if (git(binding.root, ["rev-parse", "--verify", "HEAD"]) === active.original_head) transition(binding, active, COMMIT_TRANSACTION_STATE.INTERRUPTED, { error: "commit transaction was cancelled" }, now);
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;
Expand Down Expand Up @@ -782,7 +842,7 @@ export function abandonCommitTransaction(cwd: string): CommitTransactionRecord {
try {
const record = readRecord(binding.activePath);
if (record === undefined) throw new Error("active commit transaction disappeared during recovery");
if (git(binding.root, ["rev-parse", "--verify", "HEAD"]) !== record.original_head) throw new Error("cannot abandon a commit transaction after HEAD changed; reconcile the committed tree instead");
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(); }
Expand Down
Loading
Loading