ci: block tracked paths that collide case-insensitively

Linux is case-sensitive; Windows and macOS are not. Two tracked paths
differing only by case (README.md vs readme.md, src/Foo.py vs SRC/foo.py)
land fine on Linux and silently break every clone on a case-insensitive
host — the filesystem holds one, so checkout fails or whichever wins
clobbers the other. Git won't stop the pair from landing; it only warns
at checkout time on a case-insensitive FS. This is the enforcement point.

Adds scripts/check-case-collisions.py (index scan keyed on casefolded
full paths) + an unconditional workflow_call job wired into ci.yaml and
the all-checks-pass gate — unconditional because a collision can ship in
any kind of PR (docs, JS, config), not just Python, so gating on a
language lane would be the same passive-rule trap the infographic check
closes. Tests in tests/scripts/test_case_collision_check.py build
collisions via git update-index --cacheinfo so they run on
case-insensitive filesystems too.
This commit is contained in:
ethernet
2026-09-01 15:11:56 -04:00
parent f6c9cb7b90
commit 375ce8eee5
4 changed files with 271 additions and 0 deletions
@@ -0,0 +1,33 @@
name: Case Collision Check
# Rejects PRs that track two files whose paths differ only by case
# (README.md vs readme.md, src/Foo.py vs SRC/foo.py).
#
# Linux is case-sensitive; Windows and macOS (default) are not. A
# case-colliding pair lives fine in a Linux checkout and silently breaks
# every clone on a case-insensitive host — the filesystem can hold only
# one of them, so checkout fails or whichever wins overwrites the other.
# Git won't prevent the pair from landing (it only warns at checkout time,
# on a case-insensitive FS, for the client doing the checkout), so the only
# enforcement point is CI, on Linux, against the index.
#
# Runs unconditionally (no change-classifier gate): a collision can ship in
# any kind of PR — docs, JS, config, not just Python — so gating on a
# language lane would be the same "passive rule that cannot enforce a
# policy" trap the infographic check exists to close.
on:
workflow_call:
permissions:
contents: read
jobs:
check-case-collisions:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run case-collision checker
run: python3 scripts/check-case-collisions.py
+6
View File
@@ -170,6 +170,11 @@ jobs:
needs: detect needs: detect
uses: ./.github/workflows/profile-artifact-check.yml uses: ./.github/workflows/profile-artifact-check.yml
case-collision-check:
name: Check no case-colliding filenames
needs: detect
uses: ./.github/workflows/case-collision-check.yml
lockfile-diff: lockfile-diff:
name: package-lock.json diff name: package-lock.json diff
needs: detect needs: detect
@@ -232,6 +237,7 @@ jobs:
- history-check - history-check
- contributor-check - contributor-check
- uv-lockfile - uv-lockfile
- case-collision-check
- lockfile-diff - lockfile-diff
- docker-lint - docker-lint
- profile-artifact-check - profile-artifact-check
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Blocking check for tracked files whose paths collide when case is ignored.
Linux is case-sensitive; Windows and macOS (default) are not. Two tracked
paths that differ only by case — ``README.md`` and ``readme.md``, or
``src/Foo.py`` and ``SRC/foo.py`` — coexist happily in a Linux checkout and
silently break every clone on a case-insensitive host: the filesystem can
hold only one of them, so checkout either refuses or whichever file is
written last wins and clobbers the other. Git itself won't stop the pair
from landing — it only warns at checkout time, on a case-insensitive FS,
for whichever client happens to do the checkout, and the collision is
invisible on Linux. This check is the enforcement point: scan the index,
fail the build, name the offenders.
Usage:
# Check the checkout this script lives in (CI + the common local case)
python scripts/check-case-collisions.py
# Check an arbitrary git checkout (tests, other worktrees)
python scripts/check-case-collisions.py /path/to/other/repo
Exit status:
0 — no case-colliding tracked paths
1 — at least one collision group (paths printed to stdout)
2 — not in a git repository / git failed
Comparison key: the casefolded FULL path (``str.casefold``), not the
basename — on a case-insensitive filesystem the entire path is
case-insensitive, so ``dir/Foo.txt`` and ``DIR/foo.txt`` collide just like
same-directory pairs. ``casefold`` (not ``lower``) is used because it
matches how the OSes fold case for non-ASCII text (straße vs strasse,
sigma variants); a pair it flags is a genuine collision on macOS/Windows
even when Linux disagrees.
Deliberately out of scope: Unicode NFC/NFD normalization collisions (macOS
stores NFD, Linux NFC). git already handles those at checkout via
``core.precomposeunicode``; this check is strictly about case.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"root",
nargs="?",
default=str(REPO_ROOT),
help="git checkout to scan (default: the repo this script lives in)",
)
args = parser.parse_args()
try:
os.chdir(args.root)
except OSError as exc:
print(f"::error::cannot enter {args.root}: {exc}")
return 2
proc = subprocess.run(["git", "ls-files", "-z"], capture_output=True)
if proc.returncode != 0:
msg = proc.stderr.decode("utf-8", errors="replace").strip()
print(f"::error::git ls-files failed in {args.root}: {msg}")
return 2
paths = [
p.decode("utf-8", errors="surrogateescape")
for p in proc.stdout.split(b"\0")
if p
]
by_casefold: dict[str, list[str]] = defaultdict(list)
for path in paths:
by_casefold[path.casefold()].append(path)
collisions = {key: group for key, group in by_casefold.items() if len(group) > 1}
if not collisions:
print(f"::notice::{len(paths)} tracked files, no case-colliding paths.")
return 0
print(
f"::error::Found {len(collisions)} case-collision group(s) among "
f"{len(paths)} tracked files."
)
print(
"Paths that differ only by case are ONE file on Windows/macOS but "
"several on Linux - the pair breaks every clone on a case-insensitive "
"host. Rename one member of each group so the paths differ beyond case."
)
print()
for key, group in sorted(collisions.items()):
for path in sorted(group):
print(f" {path}")
print()
print(
"Fix: `git mv` one path in each group to a name that doesn't collide. "
"On Windows/macOS you may need two steps (`git mv a.txt tmp && git mv "
"tmp A.txt`) because the filesystem can't hold both spellings at once."
)
return 1
if __name__ == "__main__":
sys.exit(main())
+118
View File
@@ -0,0 +1,118 @@
"""Wrappers for scripts/check-case-collisions.py.
Same pattern as tests/scripts/test_windows_footguns_full_repo_scan.py: run
the real checker and assert its outcomes, so a normal pytest run catches a
regression — someone committing a case-colliding pair — without anyone
having to remember to run the script by hand.
The collision cases are built with ``git update-index --cacheinfo`` (index
only, never touching the working tree), so they exercise the same index the
checker reads and work even on a case-insensitive filesystem, where the two
spellings cannot coexist on disk.
"""
from __future__ import annotations
import hashlib
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "check-case-collisions.py"
def _git_blob_sha(data: bytes) -> str:
"""The git object hash for a blob with ``data`` as its content."""
header = f"blob {len(data)}\0".encode("ascii")
return hashlib.sha1(header + data).hexdigest()
def _run_check(*args, root=None):
cmd = [sys.executable, str(SCRIPT)] + list(args)
if root is not None:
cmd.append(str(root))
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
stdin=subprocess.DEVNULL,
cwd=REPO_ROOT,
)
def _git_init(tmp_path) -> Path:
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
return repo
def test_full_repo_has_no_case_colliding_paths():
"""The real checker against the whole tracked tree must exit clean."""
result = _run_check()
assert result.returncode == 0, (
f"Case-collision check failed:\n{result.stdout}\n{result.stderr}"
)
def test_detects_case_colliding_paths(tmp_path):
"""Same-directory Foo.txt + foo.txt must fail, naming both paths."""
repo = _git_init(tmp_path)
subprocess.run(
[
"git", "update-index", "--add", "--cacheinfo",
f"100644,{_git_blob_sha(b'a')},Foo.txt",
],
cwd=repo, check=True,
)
subprocess.run(
[
"git", "update-index", "--add", "--cacheinfo",
f"100644,{_git_blob_sha(b'b')},foo.txt",
],
cwd=repo, check=True,
)
result = _run_check(root=repo)
assert result.returncode == 1, f"expected failure, got:\n{result.stdout}"
assert "Foo.txt" in result.stdout
assert "foo.txt" in result.stdout
def test_detects_directory_case_collisions(tmp_path):
"""The comparison is on the FULL path — dir/Foo.txt vs DIR/foo.txt too."""
repo = _git_init(tmp_path)
subprocess.run(
[
"git", "update-index", "--add", "--cacheinfo",
f"100644,{_git_blob_sha(b'a')},src/Helper.py",
],
cwd=repo, check=True,
)
subprocess.run(
[
"git", "update-index", "--add", "--cacheinfo",
f"100644,{_git_blob_sha(b'b')},SRC/helper.py",
],
cwd=repo, check=True,
)
result = _run_check(root=repo)
assert result.returncode == 1, f"expected failure, got:\n{result.stdout}"
assert "src/Helper.py" in result.stdout
assert "SRC/helper.py" in result.stdout
def test_same_name_in_different_dirs_is_not_a_collision(tmp_path):
"""a/Readme.txt and b/readme.txt share a basename but not a path."""
repo = _git_init(tmp_path)
(repo / "a").mkdir()
(repo / "b").mkdir()
(repo / "a" / "Readme.txt").write_text("a", encoding="utf-8")
(repo / "b" / "readme.txt").write_text("b", encoding="utf-8")
subprocess.run(["git", "add", "-A"], cwd=repo, check=True)
result = _run_check(root=repo)
assert result.returncode == 0, f"expected clean, got:\n{result.stdout}"