// tests/hooks/merge-evidence-ambiguous.test.mjs // [AMB] The guard's evidence reader (hooks/lib/merge-evidence.mjs) shows no // run evidence for a merged name git would read as something other than // refs/heads/. git looks a name up as `$GIT_DIR/`, `refs/`, // `refs/tags/` before `refs/heads/`, so with a tag `feat` the // command `git merge feat` merges the tag's commit, while the reader would // show the evidence written for the branch head. An entry of any kind at // `/`, `/`, `/refs/` or // `/refs/tags/`, or a `packed-refs` line for `refs/` or // `refs/tags/`, gives "no run evidence". The writer // (lib/review/merge-evidence.mjs) refuses the same names. // [CLU] `--cleanup ` takes a separate value: `git merge --cleanup strip // feat` merges feat, and the reader shows feat's Tested by line. // `keep:` cases hold at the base already and stay untagged. // The reader loads through `import * as m`, so a missing export is a failed // assertion at the red base, never a crash. Every child gets childEnv(); refs // are made with `git update-ref` (and `git pack-refs --all`), never `git tag`. import { after, test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as m from '../../hooks/lib/merge-evidence.mjs'; import { runHookWithEnv } from '../helpers/hook-helper.mjs'; import { childEnv } from '../helpers/child-env.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs'); const UNDONE_TWO_WAY = 'Undone by: `git revert -m 1 ` (two-way).'; const NO_EVIDENCE = 'Tested by: no run evidence.\nUndone by: no run evidence.'; const made = []; after(() => { for (const b of made) rmSync(b, { recursive: true, force: true }); }); function g(cwd, ...args) { const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() }); if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); return r.stdout.trim(); } /** * A throwaway repo on `main` (README) with a branch `feat` one commit ahead * (a.mjs), checked out on main. No hooks, no signing. * (Copied from tests/hooks/merge-evidence.test.mjs `mevRepo`.) */ function ambRepo() { const base = realpathSync(mkdtempSync(join(tmpdir(), 'merge-evidence-amb-'))); made.push(base); const repo = join(base, 'repo'); mkdirSync(repo, { recursive: true }); mkdirSync(join(base, 'hooks-none')); g(repo, 'init', '-q', '-b', 'main'); appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n' + `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`); writeFileSync(join(repo, 'README.md'), '# t\n'); g(repo, 'add', 'README.md'); g(repo, 'commit', '-q', '-m', 'init'); const mainSha = g(repo, 'rev-parse', 'HEAD'); g(repo, 'checkout', '-q', '-b', 'feat'); writeFileSync(join(repo, 'a.mjs'), 'export const a = 1;\n'); g(repo, 'add', 'a.mjs'); g(repo, 'commit', '-q', '-m', 'feat: the work'); const featSha = g(repo, 'rev-parse', 'HEAD'); g(repo, 'checkout', '-q', 'main'); const common = join(repo, '.git'); const evDir = join(common, 'voyage', 'merge-evidence'); return { base, repo, mainSha, featSha, common, evDir, /** Write an evidence file by hand (an object as JSON). */ put: (body, sha = featSha) => { mkdirSync(evDir, { recursive: true }); writeFileSync(join(evDir, `${sha}.json`), JSON.stringify(body, null, 2)); }, }; } /** A complete evidence object for `sha` on `branch` (copied from tests/hooks/merge-evidence.test.mjs `evidence`). */ function evidence(sha, branch = 'feat') { return { v: 1, branch, branch_head_sha: sha, written_at: '2026-11-02T10:00:00.000Z', end_to_end: { command: '/bin/bash tests/e2e.sh', exit: 0 }, verification: { passed: 5, total: 5 }, review: { verdict: 'ALLOW', sha }, door: { type: 'two-way', text: 'a revert restores the old gate' }, }; } const tested = (sha) => `Tested by: End-to-end \`/bin/bash tests/e2e.sh\` exit 0; Verification 5/5; review ALLOW at ${sha.slice(0, 8)}.`; /** The guard on a Bash call, run in `cwd` (copied from tests/hooks/merge-evidence.test.mjs `ask`). */ async function ask(cwd, command = 'git merge --no-ff feat') { const input = { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } }; const r = await runHookWithEnv(PRE_BASH, input, {}); let out = null; try { out = JSON.parse(String(r.stdout).trim()); } catch { out = null; } return { ...r, decision: out?.hookSpecificOutput?.permissionDecision ?? null, reason: String(out?.hookSpecificOutput?.permissionDecisionReason ?? ''), }; } /** The repo's evidence for feat's head, and the guard's question and the reader's lines for `git merge --no-ff feat`. */ async function asked(fx, command = 'git merge --no-ff feat', args = ['--no-ff', 'feat']) { const r = await ask(fx.repo, command); assert.equal(r.decision, 'ask', `Control: the merge into main is asked about: ${r.stdout}${r.stderr}`); return { reason: r.reason, lines: m.evidenceLines(fx.repo, args) }; } test('the reader module exports evidenceLines', () => { assert.equal(typeof m.evidenceLines, 'function'); assert.equal(typeof m.mergedBranch, 'function'); }); // ---- the ambiguous names ---------------------------------------------------------- test('[AMB] a loose tag with the branch\'s name gives no run evidence although an evidence file for the branch head exists', async () => { const fx = ambRepo(); fx.put(evidence(fx.featSha)); g(fx.repo, 'update-ref', 'refs/tags/feat', fx.mainSha); assert.equal(g(fx.repo, 'rev-parse', '--verify', 'refs/tags/feat'), fx.mainSha, 'Control: the tag is there'); const { reason, lines } = await asked(fx); assert.equal(lines, NO_EVIDENCE, 'the reader shows no evidence for an ambiguous name'); assert.ok(reason.includes(NO_EVIDENCE), reason); assert.ok(!reason.includes(tested(fx.featSha)), `no Tested by line for the branch head:\n${reason}`); }); test('[AMB] a packed tag with the branch\'s name gives no run evidence', async () => { const fx = ambRepo(); fx.put(evidence(fx.featSha)); g(fx.repo, 'update-ref', 'refs/tags/feat', fx.mainSha); g(fx.repo, 'pack-refs', '--all'); const packed = readFileSync(join(fx.common, 'packed-refs'), 'utf8'); assert.ok(packed.includes(`${fx.mainSha} refs/tags/feat`), `Control: the tag is packed:\n${packed}`); assert.ok(packed.includes(`${fx.featSha} refs/heads/feat`), `Control: the branch is packed too:\n${packed}`); const { reason, lines } = await asked(fx); assert.equal(lines, NO_EVIDENCE); assert.ok(reason.includes(NO_EVIDENCE), reason); assert.ok(!reason.includes(tested(fx.featSha)), reason); }); test('[AMB] refs/ gives no run evidence', async () => { const fx = ambRepo(); fx.put(evidence(fx.featSha)); g(fx.repo, 'update-ref', 'refs/feat', fx.mainSha); assert.equal(g(fx.repo, 'rev-parse', '--verify', 'refs/feat'), fx.mainSha, 'Control: refs/feat is there'); const { reason, lines } = await asked(fx); assert.equal(lines, NO_EVIDENCE); assert.ok(reason.includes(NO_EVIDENCE), reason); assert.ok(!reason.includes(tested(fx.featSha)), reason); // Control: packed, refs/feat still gives no evidence. g(fx.repo, 'pack-refs', '--all'); assert.ok(readFileSync(join(fx.common, 'packed-refs'), 'utf8').includes(`${fx.mainSha} refs/feat`), 'Control: refs/feat is packed'); assert.equal(m.evidenceLines(fx.repo, ['--no-ff', 'feat']), NO_EVIDENCE, 'packed refs/'); }); test('[AMB] a file / gives no run evidence', async () => { const fx = ambRepo(); fx.put(evidence(fx.featSha)); writeFileSync(join(fx.common, 'feat'), `${fx.mainSha}\n`); const { reason, lines } = await asked(fx); assert.equal(lines, NO_EVIDENCE); assert.ok(reason.includes(NO_EVIDENCE), reason); assert.ok(!reason.includes(tested(fx.featSha)), reason); }); test('[AMB] a packed-refs that cannot be read within the cap counts as ambiguous, never as "no tag"', () => { const fx = ambRepo(); fx.put(evidence(fx.featSha)); assert.equal(m.evidenceLines(fx.repo, ['--no-ff', 'feat']), `${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`, 'Control: shown before'); // A packed-refs that is a directory (there, not a regular file): cannot tell. mkdirSync(join(fx.common, 'packed-refs')); assert.equal(m.evidenceLines(fx.repo, ['--no-ff', 'feat']), NO_EVIDENCE); }); // ---- --cleanup takes a value ------------------------------------------------------- test('[CLU] git merge --cleanup strip feat shows feat\'s Tested by line', async () => { const fx = ambRepo(); fx.put(evidence(fx.featSha)); // A branch named after the mode, with its own evidence, must not be read as the merged one. g(fx.repo, 'update-ref', 'refs/heads/strip', fx.mainSha); fx.put(evidence(fx.mainSha, 'strip'), fx.mainSha); assert.equal(m.mergedBranch(['--cleanup', 'strip', 'feat']), 'feat'); const { reason, lines } = await asked(fx, 'git merge --cleanup strip feat', ['--cleanup', 'strip', 'feat']); assert.equal(lines, `${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`); assert.ok(reason.includes(`${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`), reason); assert.ok(!reason.includes(tested(fx.mainSha)), reason); }); // ---- keep ---------------------------------------------------------------------- test('keep: an unambiguous branch still shows its Tested by line', async () => { const fx = ambRepo(); fx.put(evidence(fx.featSha)); // Other refs that are not the name: a tag and a ref under another name, a packed branch. g(fx.repo, 'update-ref', 'refs/tags/v1', fx.mainSha); g(fx.repo, 'update-ref', 'refs/tags/feat-old', fx.mainSha); const { reason, lines } = await asked(fx); assert.equal(lines, `${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`); assert.ok(reason.includes(`${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`), reason); g(fx.repo, 'pack-refs', '--all'); assert.equal(m.evidenceLines(fx.repo, ['--no-ff', 'feat']), `${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`, 'packed'); }); test('keep: git merge --no-ff -m "Merge run branch" feat asks and never shows another branch\'s Tested by line', async () => { const fx = ambRepo(); fx.put(evidence(fx.featSha)); // A branch named after a word of the message, with its own evidence. g(fx.repo, 'update-ref', 'refs/heads/Merge', fx.mainSha); fx.put(evidence(fx.mainSha, 'Merge'), fx.mainSha); const r = await ask(fx.repo, 'git merge --no-ff -m "Merge run branch" feat'); assert.equal(r.decision, 'ask', `${r.stdout}${r.stderr}`); assert.ok(!r.reason.includes(tested(fx.mainSha)), `not the other branch's line:\n${r.reason}`); assert.ok(r.reason.includes(NO_EVIDENCE) || r.reason.includes(`${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`), r.reason); }); test('keep: git merge --no-ff feat -m "merge: x" shows feat\'s Tested by line', async () => { const fx = ambRepo(); fx.put(evidence(fx.featSha)); const r = await ask(fx.repo, 'git merge --no-ff feat -m "merge: x"'); assert.equal(r.decision, 'ask', `${r.stdout}${r.stderr}`); assert.ok(r.reason.includes(`${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`), r.reason); });