fix(hooks): a command with several merges says its run evidence is not shown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-04 10:54:56 +01:00
commit ff3e20919e
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 566 additions and 5 deletions

View file

@ -240,3 +240,30 @@ export function evidenceLines(repoRoot, mergeArgs) {
return UNREADABLE;
}
}
/** The text for a command with several merges into main (N = the count). */
const MANY_MERGES = (n) =>
`Tested by: not shown (this command merges ${n} branches into main; merge one at a time to see each run's evidence).\nUndone by: not shown.`;
/**
* The evidence text for every merge approval of one command, for the guard's
* question: zero merges give '', one gives its two evidence lines (exactly
* evidenceLines), two or more give MANY_MERGES with the count: one question
* cannot show each run's evidence, so none is shown.
* @param {Array<{subject: string, merge: {args: string[]}}>} merges
* @returns {string} never throws
*/
export function evidenceForMerges(merges) {
try {
if (!Array.isArray(merges)) return UNREADABLE;
if (merges.length === 0) return '';
if (merges.length === 1) {
const m = merges[0];
if (!isObj(m)) return UNREADABLE;
return evidenceLines(m.subject, m.merge && m.merge.args);
}
return MANY_MERGES(merges.length);
} catch {
return UNREADABLE;
}
}

View file

@ -791,13 +791,14 @@ if (approvals.length > 0) {
// 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) {
// green line. A command with several merges gets one line that says the
// run evidence is not shown (one question cannot show each run's).
const merges = approvals.filter((m) => m.cls === 'merge');
if (merges.length > 0) {
let evidence;
try {
const { evidenceLines } = await import('../lib/merge-evidence.mjs');
evidence = ' ' + evidenceLines(merge.subject, merge.merge && merge.merge.args);
const { evidenceForMerges } = await import('../lib/merge-evidence.mjs');
evidence = ' ' + evidenceForMerges(merges);
} catch {
evidence = ' (run evidence not loaded)';
}

View file

@ -0,0 +1,351 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b2-c.sh
# End to end for fix round 4.5b2, part C (plan
# .claude/plans/trekplan-2026-11-03-review-gate-4-5b2-fixes.md, Step 6,
# End-to-end:). On throwaway fixture repos it writes run evidence the way
# /trekexecute Phase 8 does (node lib/review/merge-evidence.mjs write) and
# feeds hooks/scripts/pre-bash-executor.mjs Bash inputs the way Claude Code does:
# 1. `git merge --no-ff feat -m "merge: trekexecute demo"` on main
# (/trekexecute's own order) asks with feat's Tested by line;
# 2. `git merge --no-ff feat && git merge --no-ff other` asks with the line
# that says the run evidence is not shown, and no `Tested by: End-to-end`;
# 3. `git merge --cleanup strip --no-ff feat` asks with feat's Tested by line;
# 4. `git merge --no-ff -m "Merge run branch" other` with evidence only for
# feat asks and never shows feat's evidence;
# 5. a tag named `feat` next to the branch: the writer refuses, and the
# question shows no run evidence;
# 6. a write from a second worktree whose HEAD lags the branch: the question
# reads `review not measured`.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and
# "e2e: ok" at the end, exit 0; else exit 1. Starts nothing but node, git and
# the usual file tools. bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
FILL="$ROOT/lib/review/fill-finding-ids.mjs"
WRITER="$ROOT/lib/review/merge-evidence.mjs"
unset CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b2-c.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
export LC_ALL=C
export LANG=C
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: cannot make the temp directories"; exit 1; }
# A review_version 1.2 ALLOW review with no findings of <start>..<end>
# (shape copied from tests/e2e-fix-round-4-5b2-b.sh mkreview.mjs).
# node mkreview.mjs <out> <start> <end>
cat > "$TMP/mkreview.mjs" <<'EOF'
import { writeFileSync } from 'node:fs';
const [out, start, end] = process.argv.slice(2);
const json = JSON.stringify({ verdict: 'ALLOW', counts: { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 }, allow_blocked_by: [], findings: [] }, null, 2);
writeFileSync(out, `---
type: trekreview
review_version: "1.2"
task: "t"
slug: e2e
project_dir: .claude/projects/2026-11-03-e2e/
brief_path: .claude/projects/2026-11-03-e2e/brief.md
scope_sha_start: ${start}
scope_sha_end: ${end}
reviewed_files_count: 1
verdict: ALLOW
findings: []
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | deep-review | default |
## Declined to Judge
None.
Trimmed: 0
## Remediation Steps
None.
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`);
EOF
# jfield <file> <expr>: prints the JS expression <expr> over the parsed JSON
# value `j` read from <file> (empty when it does not parse).
# (Copied from tests/e2e-fix-round-4-5b2-b.sh.)
jfield() {
node -e '
const fs = require("fs");
let j;
try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) { process.exit(0); }
const v = Function("j", "return (" + process.argv[2] + ");")(j);
process.stdout.write(v === undefined || v === null ? "" : String(v));
' "$1" "$2"
}
# mkrepo <dir>: a git repo on main (a.mjs) with two branches one commit ahead
# each, feat (b.mjs) and other (c.mjs), checked out on main. Sets MAIN_SHA,
# FEAT and OTHER.
mkrepo() {
r="$1"
mkdir -p "$r" || return 1
git -C "$r" init -q -b main || return 1
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' "$TMP/hooks-none" >> "$r/.git/config"
printf 'export const a = 1;\n' > "$r/a.mjs"
git -C "$r" add a.mjs && git -C "$r" commit -q -m 'chore: start' || return 1
MAIN_SHA="$(git -C "$r" rev-parse HEAD)"
git -C "$r" checkout -q -b feat || return 1
printf 'export const b = 1;\n' > "$r/b.mjs"
git -C "$r" add b.mjs && git -C "$r" commit -q -m 'feat: the work' || return 1
FEAT="$(git -C "$r" rev-parse HEAD)"
git -C "$r" checkout -q main || return 1
git -C "$r" checkout -q -b other || return 1
printf 'export const c = 1;\n' > "$r/c.mjs"
git -C "$r" add c.mjs && git -C "$r" commit -q -m 'feat: the other work' || return 1
OTHER="$(git -C "$r" rev-parse HEAD)"
git -C "$r" checkout -q main || return 1
}
# mkproject <dir> <start> <end>: a run's artifacts (plan, progress, ALLOW
# review of <start>..<end>) outside the repo.
mkproject() {
mkdir -p "$1" || return 1
printf '# Plan\n\n### Step 1: End to end\n\n- **End-to-end:** `/bin/bash tests/e2e-demo.sh` -> expected: `e2e: ok`, exit 0.\n\n## Doors\n\n- two-way: a revert restores the old parser\n' > "$1/plan.md"
printf '{"schema_version": "1", "status": "completed", "end_to_end": "verified", "plan_verification": {"status": "passed", "summary": {"total": 3, "passed": 3, "failed": 0, "blocked": 0, "unrunnable": 0}}}\n' > "$1/progress.json"
node "$TMP/mkreview.mjs" "$1/review.md" "$2" "$3" && node "$FILL" "$1/review.md" > /dev/null
}
# writeev <repo> <branch> <project> <out>: the writer's CLI run from <repo>;
# prints its exit status.
writeev() {
node "$WRITER" write --repo "$1" --branch "$2" --project "$3" --json > "$4" 2>&1
echo $?
}
# record <repo> <branch> <sha> <label>: check out <branch> (HEAD at the
# reviewed commit, as Phase 8 runs), write its evidence, back to main.
# Returns 1 with the reason in WHY when the file is not written.
record() {
WHY=""
mkproject "$TMP/$4-project" "$MAIN_SHA" "$3" || { WHY="could not write the project"; return 1; }
git -C "$1" checkout -q "$2" || { WHY="checkout $2"; return 1; }
rc="$(writeev "$1" "$2" "$TMP/$4-project" "$TMP/$4-ev.out")"
git -C "$1" checkout -q main || { WHY="checkout main"; return 1; }
if [ "$rc" -ne 0 ] || [ "$(jfield "$TMP/$4-ev.out" 'j.written')" != true ] || [ ! -f "$1/.git/voyage/merge-evidence/$3.json" ]; then
WHY="the writer exited $rc: $(cat "$TMP/$4-ev.out")"
return 1
fi
return 0
}
# askhook <repo> <command> <label>: the guard on a Bash call in <repo>; sets
# DECISION and REASON (and HOOK_RC).
askhook() {
node -e 'process.stdout.write(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: process.argv[1], tool_input: { command: process.argv[2] } }));' "$1" "$2" \
| node "$HOOK" > "$TMP/$3-hook.out" 2> "$TMP/$3-hook.err"
HOOK_RC=$?
DECISION="$(jfield "$TMP/$3-hook.out" 'j.hookSpecificOutput.permissionDecision')"
REASON="$(jfield "$TMP/$3-hook.out" 'j.hookSpecificOutput.permissionDecisionReason')"
}
# tested <sha>: the Tested by line the writer's evidence gives for <sha>.
tested() {
printf 'Tested by: End-to-end `/bin/bash tests/e2e-demo.sh` exit 0; Verification 3/3; review ALLOW at %s.' "$(printf '%s' "$1" | cut -c1-8)"
}
UNDONE='Undone by: `git revert -m 1 <merge>` (two-way).'
NOT_SHOWN="Tested by: not shown (this command merges 2 branches into main; merge one at a time to see each run's evidence).
Undone by: not shown."
NO_EVIDENCE='Tested by: no run evidence.
Undone by: no run evidence.'
# The shared fixture of cases 1-3: evidence for both feat and other.
R="$TMP/both"
BOTH_OK=1
if ! mkrepo "$R"; then
BOTH_OK=0
BOTH_WHY="setup"
elif ! record "$R" feat "$FEAT" both-feat; then
BOTH_OK=0
BOTH_WHY="feat: $WHY"
elif ! record "$R" other "$OTHER" both-other; then
BOTH_OK=0
BOTH_WHY="other: $WHY"
fi
BOTH_FEAT="$FEAT"
BOTH_OTHER="$OTHER"
case_order() {
name="the trekexecute merge order shows feat's Tested by line"
if [ "$BOTH_OK" -ne 1 ]; then bad "$name: $BOTH_WHY"; return; fi
askhook "$R" 'git merge --no-ff feat -m "merge: trekexecute demo"' order
want="$(tested "$BOTH_FEAT")
$UNDONE"
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/order-hook.out" "$TMP/order-hook.err")"
return
fi
case "$REASON" in
*"$want") pass "$name" ;;
*) bad "$name: the reason does not end with [$want]: $REASON" ;;
esac
}
case_two() {
name="two merges in one command say the run evidence is not shown"
if [ "$BOTH_OK" -ne 1 ]; then bad "$name: $BOTH_WHY"; return; fi
askhook "$R" 'git merge --no-ff feat && git merge --no-ff other' two
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/two-hook.out" "$TMP/two-hook.err")"
return
fi
case "$REASON" in
*'Tested by: End-to-end'*) bad "$name: a Tested by: End-to-end line is shown: $REASON"; return ;;
esac
case "$REASON" in
*"$NOT_SHOWN"*) pass "$name" ;;
*) bad "$name: no not-shown line [$NOT_SHOWN] in: $REASON" ;;
esac
}
case_cleanup() {
name="git merge --cleanup strip shows feat's Tested by line"
if [ "$BOTH_OK" -ne 1 ]; then bad "$name: $BOTH_WHY"; return; fi
askhook "$R" 'git merge --cleanup strip --no-ff feat' cleanup
want="$(tested "$BOTH_FEAT")
$UNDONE"
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/cleanup-hook.out" "$TMP/cleanup-hook.err")"
return
fi
case "$REASON" in
*"$want"*) pass "$name" ;;
*) bad "$name: no [$want] in: $REASON" ;;
esac
}
case_message() {
name="a quoted -m before another branch never shows feat's evidence"
R4="$TMP/message"
mkrepo "$R4" || { bad "$name: setup"; return; }
record "$R4" feat "$FEAT" message-feat || { bad "$name: $WHY"; return; }
if [ -e "$R4/.git/voyage/merge-evidence/$OTHER.json" ]; then bad "$name: Control: other has evidence"; return; fi
askhook "$R4" 'git merge --no-ff -m "Merge run branch" other' message
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/message-hook.out" "$TMP/message-hook.err")"
return
fi
case "$REASON" in
*"$(tested "$FEAT")"*) bad "$name: feat's Tested by line is shown: $REASON" ;;
*'Tested by: End-to-end'*) bad "$name: a Tested by: End-to-end line is shown, other has no evidence: $REASON" ;;
*'Tested by:'*) pass "$name" ;;
*) bad "$name: no Tested by line at all: $REASON" ;;
esac
}
case_tag() {
name="a tag named feat: the writer refuses and the question shows no run evidence"
R5="$TMP/tag"
mkrepo "$R5" || { bad "$name: setup"; return; }
# Evidence written while the name was a branch only.
record "$R5" feat "$FEAT" tag-feat || { bad "$name: Control: $WHY"; return; }
git -C "$R5" update-ref refs/tags/feat "$MAIN_SHA" || { bad "$name: update-ref"; return; }
git -C "$R5" checkout -q feat 2> "$TMP/tag-checkout.err" || { bad "$name: checkout feat: $(cat "$TMP/tag-checkout.err")"; return; }
rc="$(writeev "$R5" feat "$TMP/tag-feat-project" "$TMP/tag-ev2.out")"
git -C "$R5" checkout -q main || { bad "$name: checkout main"; return; }
written="$(jfield "$TMP/tag-ev2.out" 'j.written')"
why="$(jfield "$TMP/tag-ev2.out" 'j.reason')"
case "$why" in
*'branch name feat is ambiguous'*) ;;
*) bad "$name: the writer exited $rc, written [$written]: $(cat "$TMP/tag-ev2.out")"; return ;;
esac
if [ "$rc" -ne 1 ] || [ "$written" != false ]; then
bad "$name: the writer exited $rc, written [$written]: $(cat "$TMP/tag-ev2.out")"
return
fi
askhook "$R5" 'git merge --no-ff feat' tag
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/tag-hook.out" "$TMP/tag-hook.err")"
return
fi
case "$REASON" in
*'Tested by: End-to-end'*) bad "$name: a Tested by: End-to-end line is shown: $REASON" ;;
*"$NO_EVIDENCE"*) pass "$name" ;;
*) bad "$name: no [$NO_EVIDENCE] in: $REASON" ;;
esac
}
case_worktree() {
name="a write from a worktree whose HEAD lags the branch reads review not measured"
R6="$TMP/wt"
mkrepo "$R6" || { bad "$name: setup"; return; }
REVIEWED="$FEAT"
mkproject "$TMP/wt-project" "$MAIN_SHA" "$REVIEWED" || { bad "$name: could not write the project"; return; }
git -C "$R6" worktree add -q --detach "$TMP/wt-second" "$REVIEWED" || { bad "$name: worktree add"; return; }
# The branch moves on in the first worktree.
git -C "$R6" checkout -q feat || { bad "$name: checkout feat"; return; }
printf 'export const b = 2;\n' > "$R6/b.mjs"
git -C "$R6" add b.mjs && git -C "$R6" commit -q -m 'feat: more' || { bad "$name: commit"; return; }
HEAD2="$(git -C "$R6" rev-parse HEAD)"
git -C "$R6" checkout -q main || { bad "$name: checkout main"; return; }
if [ "$(git -C "$TMP/wt-second" rev-parse HEAD)" != "$REVIEWED" ] || [ "$HEAD2" = "$REVIEWED" ]; then
bad "$name: Control: the second worktree's HEAD does not lag the branch"
return
fi
rc="$(writeev "$TMP/wt-second" feat "$TMP/wt-project" "$TMP/wt-ev.out")"
if [ "$rc" -ne 0 ] || [ "$(jfield "$TMP/wt-ev.out" 'j.written')" != true ] || [ ! -f "$R6/.git/voyage/merge-evidence/$HEAD2.json" ]; then
bad "$name: the writer exited $rc: $(cat "$TMP/wt-ev.out")"
return
fi
askhook "$R6" 'git merge --no-ff feat' wt
sha8="$(printf '%s' "$REVIEWED" | cut -c1-8)"
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/wt-hook.out" "$TMP/wt-hook.err")"
return
fi
case "$REASON" in
*'review ALLOW'*) bad "$name: a review verdict is shown: $REASON" ;;
*"review not measured at $sha8."*) pass "$name" ;;
*) bad "$name: no [review not measured at $sha8.] in: $REASON" ;;
esac
}
case_order
case_two
case_cleanup
case_message
case_tag
case_worktree
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -0,0 +1,182 @@
// tests/hooks/merge-evidence-many.test.mjs
// [MMG] A Bash command with several merges into main asks one question, and
// that question cannot show each run's evidence: the guard's reader
// (hooks/lib/merge-evidence.mjs) used to show only the first merge's Tested by
// line, so `git merge a && git merge b` read as if b were tested too.
// evidenceForMerges(merges) says, for two or more merges, that the run
// evidence is not shown and why; one merge keeps the two lines as before
// (byte-identical), zero merges give ''.
// `keep:` cases hold at the base already and stay untagged; the one-merge case
// goes through the hook process only, so it never calls the new export.
// 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`, 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, 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 <merge>` (two-way).';
const NOT_SHOWN = (n) => `Tested by: not shown (this command merges ${n} branches into main; `
+ 'merge one at a time to see each run\'s evidence).\nUndone by: not shown.';
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 two branches one commit ahead each,
* `feat` (a.mjs) and `other` (b.mjs), checked out on main. No hooks, no signing.
* (Copied from tests/hooks/merge-evidence.test.mjs `mevRepo`, a second branch added.)
*/
function manyRepo() {
const base = realpathSync(mkdtempSync(join(tmpdir(), 'merge-evidence-many-')));
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');
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');
g(repo, 'checkout', '-q', '-b', 'other');
writeFileSync(join(repo, 'b.mjs'), 'export const b = 1;\n');
g(repo, 'add', 'b.mjs');
g(repo, 'commit', '-q', '-m', 'feat: the other work');
const otherSha = g(repo, 'rev-parse', 'HEAD');
g(repo, 'checkout', '-q', 'main');
const evDir = join(repo, '.git', 'voyage', 'merge-evidence');
return {
base, repo, featSha, otherSha,
/** Write an evidence file by hand (an object as JSON). */
put: (body, sha) => {
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) {
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 repo with evidence for both branch heads. */
function withEvidence() {
const fx = manyRepo();
fx.put(evidence(fx.featSha, 'feat'), fx.featSha);
fx.put(evidence(fx.otherSha, 'other'), fx.otherSha);
return fx;
}
/** The guard on a Bash call, run in `cwd` (copied from tests/hooks/merge-evidence.test.mjs `ask`). */
async function ask(cwd, command) {
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 ?? ''),
};
}
const TWO = 'git merge --no-ff feat && git merge --no-ff other';
// ---- several merges ---------------------------------------------------------------
test('[MMG] git merge a && git merge b on main says the run evidence is not shown for 2 branches', async () => {
const fx = withEvidence();
const r = await ask(fx.repo, TWO);
assert.equal(r.decision, 'ask', `Control: the merges into main are asked about: ${r.stdout}${r.stderr}`);
assert.ok(r.reason.startsWith('Voyage asks before this command (P12 approval guard):'), `Control: the guard's own question:\n${r.reason}`);
assert.ok(r.reason.includes(NOT_SHOWN(2)), `the not-shown line for 2 branches:\n${r.reason}`);
assert.ok(!r.reason.includes(tested(fx.featSha)), `not the first merge's Tested by line:\n${r.reason}`);
assert.ok(!r.reason.includes(tested(fx.otherSha)), `not the second merge's Tested by line:\n${r.reason}`);
assert.ok(!r.reason.includes('Tested by: End-to-end'), `no Tested by: End-to-end line at all:\n${r.reason}`);
});
test('[MMG] evidenceForMerges of two merges never holds a Tested by: End-to-end line', () => {
assert.equal(typeof m.evidenceForMerges, 'function', 'hooks/lib/merge-evidence.mjs exports evidenceForMerges');
const fx = withEvidence();
const feat = { subject: fx.repo, merge: { args: ['--no-ff', 'feat'] } };
const other = { subject: fx.repo, merge: { args: ['--no-ff', 'other'] } };
// Control: each alone has its own evidence.
assert.equal(m.evidenceLines(fx.repo, ['--no-ff', 'feat']), `${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`, 'Control: feat alone');
assert.equal(m.evidenceLines(fx.repo, ['--no-ff', 'other']), `${tested(fx.otherSha)}\n${UNDONE_TWO_WAY}`, 'Control: other alone');
const two = m.evidenceForMerges([feat, other]);
assert.equal(two, NOT_SHOWN(2));
assert.ok(!two.includes('Tested by: End-to-end'), two);
const three = m.evidenceForMerges([feat, other, feat]);
assert.equal(three, NOT_SHOWN(3), 'the count is the number of merges');
assert.ok(!three.includes('Tested by: End-to-end'), three);
});
test('[MMG] evidenceForMerges: zero merges give \'\', one gives its evidence lines, a bad input never throws', () => {
assert.equal(typeof m.evidenceForMerges, 'function', 'hooks/lib/merge-evidence.mjs exports evidenceForMerges');
const fx = withEvidence();
assert.equal(m.evidenceForMerges([]), '');
assert.equal(m.evidenceForMerges([{ subject: fx.repo, merge: { args: ['--no-ff', 'feat'] } }]),
`${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`, 'one merge: its two lines, as evidenceLines gives them');
assert.equal(m.evidenceForMerges([{ subject: fx.repo }]), 'Tested by: no run evidence.\nUndone by: no run evidence.',
'one merge without args: no run evidence');
for (const bad of [null, undefined, 'x', 42, [null], [null, null]]) {
let out;
assert.doesNotThrow(() => { out = m.evidenceForMerges(bad); }, `never throws on ${JSON.stringify(bad)}`);
assert.equal(typeof out, 'string', `a string for ${JSON.stringify(bad)}`);
assert.ok(!out.includes('Tested by: End-to-end'), `no green line for ${JSON.stringify(bad)}: ${out}`);
}
});
// ---- keep ----------------------------------------------------------------------
test('keep: the decision stays ask with several merges', async () => {
const fx = withEvidence();
for (const command of [TWO, 'git merge --no-ff feat; git merge --no-ff other', 'git merge feat && git merge other && git merge feat']) {
const r = await ask(fx.repo, command);
assert.equal(r.code, 0, `${command}: ${r.stdout}${r.stderr}`);
assert.equal(r.decision, 'ask', `${command}: ${r.stdout}${r.stderr}`);
}
});
test('keep: one merge\'s question holds the same two lines as before', async () => {
const fx = withEvidence();
const r = await ask(fx.repo, 'git merge --no-ff feat');
assert.equal(r.decision, 'ask', `${r.stdout}${r.stderr}`);
assert.ok(r.reason.endsWith(` ${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`), `the two lines end the reason, after one space:\n${r.reason}`);
assert.equal(r.reason.split('Tested by:').length, 2, `one Tested by line:\n${r.reason}`);
assert.ok(!r.reason.includes('not shown'), r.reason);
});