182 lines
9.2 KiB
JavaScript
182 lines
9.2 KiB
JavaScript
// 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);
|
|
});
|