fix(hooks): no run evidence for a merged name git reads as a tag or another ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-04 10:38:39 +01:00
commit 29b1fba9fb
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 299 additions and 9 deletions

View file

@ -15,6 +15,18 @@
// 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 `?`.
//
// A merged name git would read as something other than refs/heads/<name>
// gives "no run evidence": git looks a name up as `$GIT_DIR/<name>`,
// `refs/<name>` and `refs/tags/<name>` before `refs/heads/<name>`, so the
// merge would not take the branch head the evidence was written for. The name
// is ambiguous when an entry of any kind (file, directory, link; lstat only)
// sits at `<git dir>/<name>`, `<common>/<name>`, `<common>/refs/<name>` or
// `<common>/refs/tags/<name>`, or when a `packed-refs` line (not a `^` peeled
// line) names exactly `refs/<name>` or `refs/tags/<name>`. A `packed-refs`
// that is there but cannot be read within its cap counts as ambiguous (cannot
// tell), never as "no tag". The writer (lib/review/merge-evidence.mjs)
// refuses the same names.
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync } from 'node:fs';
import { isAbsolute, join, resolve } from 'node:path';
@ -28,7 +40,7 @@ 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 MERGE_VALUED = ['-m', '-F', '-s', '-X', '--message', '--file', '--strategy', '--strategy-option', '--into-name', '--cleanup'];
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
const VERDICTS = ['ALLOW', 'WARN', 'BLOCK'];
@ -77,18 +89,66 @@ function smallRegular(path, max) {
return readFileSync(path, 'utf8');
}
/** The head sha of `refs/heads/<branch>`: the loose ref, else `packed-refs`; else null. */
function headSha(common, branch) {
const PACKED_MAX = 16 * 1024 * 1024;
/**
* `packed-refs` of `common`: '' when there is none, its text, or null when it
* is there but not a regular file of at most PACKED_MAX bytes, or unreadable.
*/
function packedRefs(common) {
const path = join(common, 'packed-refs');
let st;
try {
st = lstatSync(path);
} catch (e) {
return e && (e.code === 'ENOENT' || e.code === 'ENOTDIR') ? '' : null;
}
if (!st.isFile() || st.size > PACKED_MAX) return null;
try { return readFileSync(path, 'utf8'); } catch { return null; }
}
/** The ref name of each `packed-refs` line `<sha> <ref>` (a `^` peeled line is none). */
function packedLines(packed) {
const out = [];
for (const line of packed.split('\n')) {
const m = line.match(/^([0-9a-f]{40}|[0-9a-f]{64}) (.+)$/);
if (m) out.push([m[1], m[2]]);
}
return out;
}
/**
* True when git would read `name` as something other than refs/heads/<name>
* (see the header): an entry of any kind at one of the four paths, or a
* packed `refs/<name>` or `refs/tags/<name>`. Cannot tell counts as true.
*/
function ambiguousName(gitDir, common, name, packed) {
const parts = name.split('/');
for (const path of [join(gitDir, ...parts), join(common, ...parts), join(common, 'refs', ...parts), join(common, 'refs', 'tags', ...parts)]) {
try { lstatSync(path); return true; } catch (e) {
// No entry is ENOENT/ENOTDIR; any other error cannot tell.
if (!e || (e.code !== 'ENOENT' && e.code !== 'ENOTDIR')) return true;
}
}
if (packed === null) return true;
return packedLines(packed).some(([, ref]) => ref === `refs/${name}` || ref === `refs/tags/${name}`);
}
/**
* The head sha of `refs/heads/<branch>`: the loose ref, else `packed-refs`;
* null when there is none or the name is ambiguous for git.
*/
function headSha(gitDir, common, branch) {
const packed = packedRefs(common);
if (ambiguousName(gitDir, common, branch, packed)) return null;
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];
if (!packed) return null;
for (const [sha, ref] of packedLines(packed)) {
if (ref === `refs/heads/${branch}`) return sha;
}
return null;
}
@ -165,7 +225,7 @@ export function evidenceLines(repoRoot, mergeArgs) {
const gitDir = gitDirAt(repoRoot);
if (!gitDir) return NO_EVIDENCE;
const common = commonDirOf(gitDir);
const sha = headSha(common, branch);
const sha = headSha(gitDir, common, branch);
if (!sha) return NO_EVIDENCE;
const text = readEvidence(join(common, 'voyage', 'merge-evidence', `${sha}.json`));
if (text === 'missing') return NO_EVIDENCE;