feat(hooks): the merge question says how the run was tested and how it is undone
This commit is contained in:
parent
fcba232ccd
commit
ec5b6483ec
10 changed files with 1371 additions and 6 deletions
|
|
@ -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);
|
||||
|
|
|
|||
182
hooks/lib/merge-evidence.mjs
Normal file
182
hooks/lib/merge-evidence.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue