Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions scripts/hooks/ledger_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ def git(*args: str) -> str:
return proc.stdout or ""


def _obj_exists(spec: str) -> bool:
"""Does the `<ref>:<path>` 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
Expand Down Expand Up @@ -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] = {}
Expand Down Expand Up @@ -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):
Expand Down
36 changes: 36 additions & 0 deletions tests/test_ledger_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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".

Expand Down
Loading