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:
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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}"
|
||||||
Reference in New Issue
Block a user