feat(hooks): the merge question says how the run was tested and how it is undone

This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 21:23:24 +01:00
commit ec5b6483ec
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
10 changed files with 1371 additions and 6 deletions

View file

@ -169,7 +169,7 @@ function classifyGit(call, ctx) {
return classifyPush(args, ctx);
case 'merge':
if (hasFlag(args, '--abort', '--continue', '--quit')) return [];
return MAIN.test(ctx.branch || '') ? [{ cls: 'merge', name: 'merge into main', kind: 'merge' }] : [];
return MAIN.test(ctx.branch || '') ? [{ cls: 'merge', name: 'merge into main', kind: 'merge', merge: { args } }] : [];
case 'tag': {
if (hasFlag(args, '-l', '--list', '-v', '--verify') || shortHas(args, 'l')) return [];
if (hasFlag(args, '-d', '--delete')) return [{ cls: 'release', name: 'delete a tag', kind: null }];
@ -323,7 +323,7 @@ function gitDirOf(found) {
}
// A worktree's git dir names the shared one in `commondir`; refs live there.
function commonDirOf(gitDir) {
export function commonDirOf(gitDir) {
try {
const c = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
return isAbsolute(c) ? c : resolve(gitDir, c);

View file

@ -0,0 +1,182 @@
// hooks/lib/merge-evidence.mjs
// The merge question's run evidence (fix round 4.5b2 part B, Step 11). Before
// it merges a run's branch into main, /trekexecute Phase 8 writes an evidence
// file (lib/review/merge-evidence.mjs write) under the git common dir, named
// by the branch's head sha. The Bash guard (hooks/scripts/pre-bash-executor.mjs)
// reads it here into two lines of the question it already asks:
// Tested by: End-to-end `<cmd>` exit <n>; Verification <passed>/<total>; review <verdict> at <sha8>.
// Undone by: `git revert -m 1 <merge>` (two-way). | (one-way: <text>). | (door not stated).
//
// Fail-safe by construction: the guard's `ask` decision is made before this
// runs, and this only returns text. It reads the filesystem only (no process
// is spawned, no module outside hooks/ is imported), never follows a symlink,
// reads at most 64 KiB of a regular file, and never throws: any error, a
// missing or foreign file, a head sha that does not match, or a field of the
// wrong type reads "no run evidence" or "not measured", never a green line.
// Text taken from the file prints with every non-printable or non-ASCII
// character as `?`.
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync } from 'node:fs';
import { isAbsolute, join, resolve } from 'node:path';
import { commonDirOf } from './git-approval.mjs';
export const MAX_EVIDENCE_BYTES = 64 * 1024;
const NO_EVIDENCE = 'Tested by: no run evidence.\nUndone by: no run evidence.';
const UNREADABLE = 'Tested by: not measured (the run evidence file could not be read).\nUndone by: not measured.';
const NOT = 'not measured';
const UNDO = 'Undone by: `git revert -m 1 <merge>`';
// `git merge` options that take a separate value: their value is not the branch.
const MERGE_VALUED = ['-m', '-F', '-s', '-X', '--message', '--file', '--strategy', '--strategy-option', '--into-name'];
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
const VERDICTS = ['ALLOW', 'WARN', 'BLOCK'];
/** Printable ASCII only; anything else prints as `?`. Cut to `max` characters. */
function clean(s, max = 200) {
const out = String(s).replace(/[^\x20-\x7e]/g, '?');
return out.length > max ? `${out.slice(0, max)}...` : out;
}
/** The merged branch: the one positional after the valued options; else null. */
export function mergedBranch(mergeArgs) {
if (!Array.isArray(mergeArgs)) return null;
const pos = [];
for (let i = 0; i < mergeArgs.length; i += 1) {
const a = mergeArgs[i];
if (typeof a !== 'string') return null;
if (MERGE_VALUED.includes(a)) { i += 1; continue; }
if (!a.startsWith('-')) pos.push(a);
}
if (pos.length !== 1) return null;
const name = pos[0];
// A plain branch name only: no `..`, no control character, no leading `-`.
if (name === '' || name.includes('..') || /[\x00-\x1f\x7f]/.test(name) || name.startsWith('-')) return null;
return name;
}
/** The git dir of the repo whose top is `root` (a `.git` dir, or a `.git` file naming one). */
// Same reading as gitDirOf in hooks/lib/git-approval.mjs (not exported there).
function gitDirAt(root) {
const dotGit = join(root, '.git');
let st;
try { st = lstatSync(dotGit); } catch { return null; }
if (st.isDirectory()) return dotGit;
if (!st.isFile()) return null;
const m = readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
if (!m) return null;
return isAbsolute(m[1]) ? m[1] : resolve(root, m[1]);
}
/** A small regular file's text, or null (missing, not regular, too big). */
function smallRegular(path, max) {
let st;
try { st = lstatSync(path); } catch { return null; }
if (!st.isFile() || st.size > max) return null;
return readFileSync(path, 'utf8');
}
/** The head sha of `refs/heads/<branch>`: the loose ref, else `packed-refs`; else null. */
function headSha(common, branch) {
const loose = smallRegular(join(common, 'refs', 'heads', ...branch.split('/')), 4096);
if (loose !== null) {
const sha = loose.trim();
if (SHA_RE.test(sha)) return sha;
}
const packed = smallRegular(join(common, 'packed-refs'), 16 * 1024 * 1024);
if (packed === null) return null;
for (const line of packed.split('\n')) {
const m = line.match(/^([0-9a-f]{40}|[0-9a-f]{64}) (.+)$/);
if (m && m[2] === `refs/heads/${branch}`) return m[1];
}
return null;
}
/**
* The evidence file's text: 'missing', null (there, but not a regular file of
* at most 64 KiB), or the text. Never follows a symlink and never opens a FIFO
* for a blocking read.
*/
function readEvidence(path) {
let st;
try {
st = lstatSync(path);
} catch (e) {
return e && e.code === 'ENOENT' ? 'missing' : null;
}
if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_EVIDENCE_BYTES) return null;
const flags = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
let fd;
try {
fd = openSync(path, flags);
const fst = fstatSync(fd);
if (!fst.isFile() || fst.size > MAX_EVIDENCE_BYTES) return null;
const buf = Buffer.alloc(MAX_EVIDENCE_BYTES + 1);
let n = 0;
for (;;) {
const got = readSync(fd, buf, n, buf.length - n, null);
if (got === 0) break;
n += got;
if (n > MAX_EVIDENCE_BYTES) return null;
}
return buf.subarray(0, n).toString('utf8');
} catch {
return null;
} finally {
if (fd !== undefined) { try { closeSync(fd); } catch { /* closed */ } }
}
}
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
const isCount = (n) => Number.isInteger(n) && n >= 0;
function testedLine(ev) {
const e2e = isObj(ev.end_to_end) ? ev.end_to_end : {};
const cmd = typeof e2e.command === 'string' && e2e.command.trim() !== '' ? `\`${clean(e2e.command)}\`` : `command ${NOT}`;
const exit = Number.isInteger(e2e.exit) ? String(e2e.exit) : NOT;
const ver = isObj(ev.verification) ? ev.verification : {};
const count = isCount(ver.passed) && isCount(ver.total) && ver.passed <= ver.total ? `${ver.passed}/${ver.total}` : NOT;
const rev = isObj(ev.review) ? ev.review : {};
const verdict = VERDICTS.includes(rev.verdict) ? rev.verdict : NOT;
const sha = typeof rev.sha === 'string' && SHA_RE.test(rev.sha) ? rev.sha.slice(0, 8) : NOT;
return `Tested by: End-to-end ${cmd} exit ${exit}; Verification ${count}; review ${verdict} at ${sha}.`;
}
function undoneLine(ev) {
const door = isObj(ev.door) ? ev.door : {};
if (door.type === 'two-way') return `${UNDO} (two-way).`;
if (door.type === 'one-way' && typeof door.text === 'string' && door.text.trim() !== '') {
return `${UNDO} (one-way: ${clean(door.text.trim())}).`;
}
return `${UNDO} (door not stated).`;
}
/**
* The two evidence lines for a merge, for the guard's question.
* @param {string} repoRoot top of the repo the merge runs in
* @param {string[]} mergeArgs the tokens after `merge`
* @returns {string} always two lines; never throws
*/
export function evidenceLines(repoRoot, mergeArgs) {
try {
const branch = mergedBranch(mergeArgs);
if (!branch || typeof repoRoot !== 'string' || repoRoot === '') return NO_EVIDENCE;
const gitDir = gitDirAt(repoRoot);
if (!gitDir) return NO_EVIDENCE;
const common = commonDirOf(gitDir);
const sha = headSha(common, branch);
if (!sha) return NO_EVIDENCE;
const text = readEvidence(join(common, 'voyage', 'merge-evidence', `${sha}.json`));
if (text === 'missing') return NO_EVIDENCE;
if (text === null) return UNREADABLE;
let ev;
try { ev = JSON.parse(text); } catch { return UNREADABLE; }
if (!isObj(ev)) return UNREADABLE;
// A file of another format, another branch or another head is not this run's evidence.
if (ev.v !== 1 || ev.branch !== branch || ev.branch_head_sha !== sha) return NO_EVIDENCE;
return `${testedLine(ev)}\n${undoneLine(ev)}`;
} catch {
return UNREADABLE;
}
}

View file

@ -779,13 +779,32 @@ if (approvals.length > 0) {
const unlogged = loggable.length > 0 && !recorded
? ' Note: this approval will not be logged (Voyage could not record the question).' : '';
const advisory = (warnings.length > 0 ? ` Advisory: ${warnings.map((w) => w.trim()).join(' ')}` : '') + unlogged;
process.stdout.write(JSON.stringify({
const answer = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'ask',
permissionDecisionReason: askReason(approvals) + advisory,
},
}));
};
// BEGIN merge-evidence
// A merge into main also says how the run was tested and how it is undone,
// read from the evidence file /trekexecute Phase 8 wrote (hooks/lib/merge-evidence.mjs).
// The decision above is already 'ask'; this block only adds text to the
// reason, and a reader that fails to load or throws leaves a note, never a
// green line.
const merge = approvals.find((m) => m.cls === 'merge');
if (merge) {
let evidence;
try {
const { evidenceLines } = await import('../lib/merge-evidence.mjs');
evidence = ' ' + evidenceLines(merge.subject, merge.merge && merge.merge.args);
} catch {
evidence = ' (run evidence not loaded)';
}
answer.hookSpecificOutput.permissionDecisionReason += evidence;
}
// END merge-evidence
process.stdout.write(JSON.stringify(answer));
}
process.exit(0);