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:
parent
5b9cbeba28
commit
29b1fba9fb
2 changed files with 299 additions and 9 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue