From 0064ec7d18de3bc941fd8a14d83f344c491160c1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 25 Aug 2026 19:37:11 -0700 Subject: [PATCH 1/2] Check ls-tree Mode, Not Type, to Exclude a Symlink's Content A symlink's git ls-tree type is 'blob' too, the same as a regular file: only its mode (120000) differs. Checking entry type alone let a file-to-symlink transition through as file content, and git show on a symlink revision returns the link's target path string, not the content it points to, so that string would be hashed and compared as if it were the file's real text. Check the ls-tree mode field directly instead of the type: only 100644 (regular) and 100755 (executable) count as file content: everything else (040000 tree, 120000 symlink, 160000 gitlink, or absent) reads as None. Added a self-test covering a file-to-symlink transition. ## Validation - python3 spec/audit.py --selftest - uvx ruff check / uvx ruff format --check spec/audit.py - uvx mypy spec/audit.py - python3 scripts/prose_lint.py (full check set) - python3 scripts/repo_gate.py Raised by CodeRabbit on PR #1016 (develop -> main promotion). --- spec/audit.py | 66 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index eea58350..6b874c34 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -1722,11 +1722,12 @@ def _git_revisions(rel_path): `text` is None where rel_path has no file content at this revision: absent (deleted, checked against the revision's own tree rather than `git show`'s stderr wording, which reads differently once rel_path exists again in a later commit), or present as something other than - a regular file (a directory from a file-to-directory transition, a submodule gitlink). A - `git show` failure for any other reason (a permission or encoding fluke, a corrupt object) - raises instead of folding into the same None, so a real command fault cannot pass as an - ordinary absence. Cached because one canonical's history is read once per fidelity/staleness - check, then reused for every audited repo's copy. + a regular file, by ls-tree mode rather than type (a directory from a file-to-directory + transition, a symlink, whose ls-tree type is "blob" too but whose content is its target path + rather than a file's, a submodule gitlink). A `git show` failure for any other reason (a + permission or encoding fluke, a corrupt object) raises instead of folding into the same None, + so a real command fault cannot pass as an ordinary absence. Cached because one canonical's + history is read once per fidelity/staleness check, then reused for every audited repo's copy. """ r = subprocess.run( ["git", "log", "--format=%cI %H", "--", rel_path], @@ -1757,11 +1758,13 @@ def _git_revisions(rel_path): # only for that, never for a merely absent path (empty stdout, exit 0, below). raise RuntimeError(f"git ls-tree failed for {sha}:{rel_path}: {t.stderr.strip()}") entry = t.stdout.strip() - entry_type = entry.split(None, 2)[1] if entry else None - if entry_type != "blob": + mode = entry.split(None, 1)[0] if entry else None + if mode not in ("100644", "100755"): # Absent (empty stdout), or present as something other than a regular file (a - # directory from a file-to-directory transition, a submodule gitlink): neither has - # file content to compare, so both read the same as a confirmed deletion. + # directory from a file-to-directory transition, a symlink, a submodule gitlink): + # none has file content to compare, so all read the same as a confirmed deletion. A + # symlink's ls-tree type is "blob" too (its content is the link target), so the mode + # is checked directly rather than the type. out.append((date, sha, None)) continue # Decode as UTF-8 with replacement to match the downstream and canonical reads. @@ -3360,6 +3363,51 @@ def _selftest(): f" {'ok ' if got == want else 'FAIL'} want={want!s:<24} got={got!s:<24} _git_revisions: file-to-directory transition" ) + # _git_revisions: a path that becomes a symlink reads as None too, per + # ptr727/ProjectTemplate#1016 (a symlink's ls-tree type is "blob", but git show returns its + # target path, not file content). + with tempfile.TemporaryDirectory() as tmp_root: + tmp_root_path = pathlib.Path(tmp_root) + for cmd in ( + ["git", "init", "-q"], + ["git", "config", "user.email", "test@test.invalid"], + ["git", "config", "user.name", "test"], + ): + subprocess.run(cmd, cwd=tmp_root_path, check=True, capture_output=True) + rel = "thing" + (tmp_root_path / rel).write_text("v1\n") + subprocess.run(["git", "add", rel], cwd=tmp_root_path, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-q", "-m", "file"], + cwd=tmp_root_path, + check=True, + capture_output=True, + ) + subprocess.run(["git", "rm", "-q", rel], cwd=tmp_root_path, check=True, capture_output=True) + (tmp_root_path / rel).symlink_to("target") + subprocess.run(["git", "add", rel], cwd=tmp_root_path, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-q", "-m", "now a symlink"], + cwd=tmp_root_path, + check=True, + capture_output=True, + ) + saved_root = ROOT + ROOT = tmp_root_path + try: + _git_revisions.cache_clear() + revisions = _git_revisions(rel) + finally: + ROOT = saved_root + _git_revisions.cache_clear() + got = [text for _, _, text in revisions] + want = [None, "v1\n"] + if got != want: + ok = False + print( + f" {'ok ' if got == want else 'FAIL'} want={want!s:<24} got={got!s:<24} _git_revisions: file-to-symlink transition" + ) + # Region extraction and hashing: a forked github-release block must hash differently from the canonical. region = split_jobs(rel_ok).get("github-release") forked_region = split_jobs( From b403bf406bb913811b236e9745e2a8a525df3064 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 25 Aug 2026 19:41:37 -0700 Subject: [PATCH 2/2] Skip the Symlink Self-Test Where the Host Cannot Create One Path.symlink_to() raises OSError on a host or user lacking symlink-creation privilege, notably Windows without Developer Mode or an elevated prompt. Calling it unconditionally in the self-test would abort the whole --selftest run on such a host, before it could report any result at all, over an environment limitation rather than a code fault. Wrap the symlink creation in try/except OSError and skip only that one case with a printed note when it fails, leaving every other self-test case (including the rest of _git_revisions' own coverage) unaffected. ## Validation - python3 spec/audit.py --selftest - uvx ruff check / uvx ruff format --check spec/audit.py - uvx mypy spec/audit.py - python3 scripts/prose_lint.py (full check set) - python3 scripts/repo_gate.py Raised by qodo on PR #1020. --- spec/audit.py | 51 +++++++++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index 6b874c34..030d46e9 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -3384,29 +3384,36 @@ def _selftest(): capture_output=True, ) subprocess.run(["git", "rm", "-q", rel], cwd=tmp_root_path, check=True, capture_output=True) - (tmp_root_path / rel).symlink_to("target") - subprocess.run(["git", "add", rel], cwd=tmp_root_path, check=True, capture_output=True) - subprocess.run( - ["git", "commit", "-q", "-m", "now a symlink"], - cwd=tmp_root_path, - check=True, - capture_output=True, - ) - saved_root = ROOT - ROOT = tmp_root_path try: - _git_revisions.cache_clear() - revisions = _git_revisions(rel) - finally: - ROOT = saved_root - _git_revisions.cache_clear() - got = [text for _, _, text in revisions] - want = [None, "v1\n"] - if got != want: - ok = False - print( - f" {'ok ' if got == want else 'FAIL'} want={want!s:<24} got={got!s:<24} _git_revisions: file-to-symlink transition" - ) + (tmp_root_path / rel).symlink_to("target") + except OSError: + # Creating a symlink needs a privilege this host or user may not have (notably + # Windows without Developer Mode or an elevated prompt): skip this case rather than + # aborting the whole --selftest run over an environment limitation, not a code fault. + print(" skip _git_revisions: file-to-symlink transition (host cannot create symlinks)") + else: + subprocess.run(["git", "add", rel], cwd=tmp_root_path, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-q", "-m", "now a symlink"], + cwd=tmp_root_path, + check=True, + capture_output=True, + ) + saved_root = ROOT + ROOT = tmp_root_path + try: + _git_revisions.cache_clear() + revisions = _git_revisions(rel) + finally: + ROOT = saved_root + _git_revisions.cache_clear() + got = [text for _, _, text in revisions] + want = [None, "v1\n"] + if got != want: + ok = False + print( + f" {'ok ' if got == want else 'FAIL'} want={want!s:<24} got={got!s:<24} _git_revisions: file-to-symlink transition" + ) # Region extraction and hashing: a forked github-release block must hash differently from the canonical. region = split_jobs(rel_ok).get("github-release")