Files
aiturk-hermes-ide/scripts/check-case-collisions.py
T

115 lines
4.0 KiB
Python

#!/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())