diff --git a/scripts/hooks/ledger_check.py b/scripts/hooks/ledger_check.py index 0570f80..a4f7aab 100644 --- a/scripts/hooks/ledger_check.py +++ b/scripts/hooks/ledger_check.py @@ -52,6 +52,20 @@ def git(*args: str) -> str: return proc.stdout or "" +def _obj_exists(spec: str) -> bool: + """Does the `:` object exist? Probed EXPLICITLY rather than inferred from an error. + + :func:`git` raises on any non-zero exit deliberately — a swallowed failure would read as "empty + ledger", i.e. "no numbers taken", the false-clean this gate exists to prevent. "The path is simply + not on that ref" is the one case that is NOT a failure, so it gets its own probe instead of a + broad ``except``. + """ + probe = subprocess.run( # nosec B603 B607 - fixed argv, no shell + ["git", "cat-file", "-e", spec], capture_output=True + ) + return probe.returncode == 0 + + class Ledger: def __init__(self, *, ci: bool, base: str = "origin/main") -> None: self.ci = ci @@ -107,10 +121,19 @@ def base_has(self, path: str) -> bool: verified — otherwise a bad/unfetched base would quietly answer "absent" and disable the check. """ git("rev-parse", "--verify", f"{self.base}^{{commit}}") - probe = subprocess.run( # nosec B603 B607 - fixed argv, no shell - ["git", "cat-file", "-e", f"{self.base}:{path}"], capture_output=True - ) - return probe.returncode == 0 + return _obj_exists(f"{self.base}:{path}") + + def head_has(self, path: str) -> bool: + """Does the commit under test contain ``path``? Mirrors :meth:`head_text`'s ref. + + The symmetric case to :meth:`base_has`, and it bites the moment a ledger file is FIRST + published. In CI the change set is `diff base HEAD`, so once ``origin/main`` gains a file that + a branch predates, that branch's diff lists it — as a DELETION relative to base — even though + the branch never touched it. The rule then reads HEAD for a copy that was never there and + `git show HEAD:path` exits 128. That is not a ledger violation; it is a stale branch, and it + broke every open branch the hour docs/BACKLOG.md landed. + """ + return _obj_exists(f"HEAD:{path}" if self.ci else f":{path}") def base_adr_numbers(self) -> dict[str, str]: out: dict[str, str] = {} @@ -206,6 +229,12 @@ def check_backlog(self) -> None: # there is nothing to police; without this, importing the ledger wholesale would report # every one of its ~229 items as "not allocated to this worktree". return + if not self.head_has("docs/BACKLOG.md"): + # Present on base, absent here: a branch that PREDATES the file's publication. CI diffs + # against origin/main, so the file shows up as "changed" (a deletion relative to base) + # although the branch never touched it — and reading HEAD for a copy that was never there + # exits 128. A stale branch is not a ledger violation. + return head = set(BACKLOG_HEADING.findall(self.head_text("docs/BACKLOG.md"))) base = set(BACKLOG_HEADING.findall(self.base_text("docs/BACKLOG.md"))) for number in sorted(head - base, key=int): diff --git a/tests/test_ledger_check.py b/tests/test_ledger_check.py index 409808a..6b58a73 100644 --- a/tests/test_ledger_check.py +++ b/tests/test_ledger_check.py @@ -412,6 +412,42 @@ def test_ADDING_a_backlog_that_the_base_lacks_is_not_a_wall_of_unallocated_numbe assert "not allocated" not in out +def test_a_branch_that_PREDATES_the_backlog_is_not_a_ledger_violation(tmp_path: Path) -> None: + """The mirror image of the case above, and it broke every open branch the hour BACKLOG.md landed. + + CI's change set is `diff base HEAD`. The moment origin/main gained docs/BACKLOG.md, every branch + cut before that merge began listing the file as changed — as a DELETION relative to base — while + its own HEAD had no copy. The rule then read HEAD for a file that was never there and died on + `git show HEAD:docs/BACKLOG.md` (exit 128). A stale branch is not a number collision. + """ + r = tmp_path / "repo" + r.mkdir() + git(r, "init", "-q", "-b", "main") + git(r, "config", "user.email", "t@t") + git(r, "config", "user.name", "t") + git(r, "config", "commit.gpgsign", "false") + write(r, "README.md", "no backlog yet\n") + git(r, "add", "-A") + git(r, "commit", "-qm", "root") + root = git(r, "rev-parse", "HEAD").strip() + + # main moves on and PUBLISHES the backlog... + write(r, "docs/BACKLOG.md", "# Backlog\n\n## 1. First\n\nb\n") + git(r, "add", "-A") + git(r, "commit", "-qm", "publish backlog") + git(r, "update-ref", "refs/remotes/origin/main", "HEAD") + + # ...while this branch was cut BEFORE it and never touched the file. + git(r, "checkout", "-q", "-b", "stale", root) + write(r, "src.py", "x = 1\n") + git(r, "add", "-A") + git(r, "commit", "-qm", "unrelated work") + + code, out = run_check(r, "--ci") + assert code == 0, out + assert "BACKLOG" not in out + + def test_an_unreachable_base_ref_never_reports_success(tmp_path: Path) -> None: """System-level property: an unresolvable base must never read as "nothing to check".