From bd81cdeb585e3e6541c7482923dfd9e70eb28f65 Mon Sep 17 00:00:00 2001 From: Yunare Maia Date: Sun, 30 Aug 2026 01:05:55 +0000 Subject: [PATCH] feat(detector): detect missing CRLF-safe .gitattributes (line-ending drift) Adds a line-ending drift check: a repo that ships text source but lacks `* text=auto eol=lf` in `.gitattributes` silently checks out CRLF working-tree bytes on Windows (core.autocrlf=true) while the index stores LF, breaking byte-exact checks. `driftcheck --fix` now appends the rule (or creates the file). This is the exact pattern applied across the archify/gods-eye-view CRLF PRs; driftcheck can now catch it before contributors hit a build failure, same as it does for Rust/Node/Python/Go version drift. - find_lineending_drift(root) scans .gitattributes - scan_repo() reports lineending_drifts - apply_fixes() creates/appends `* text=auto eol=lf` - 3 new tests (detect, fix, no-false-positive) Bump Checks to v0.1.5 in README. --- README.md | 3 ++- src/driftcheck/detector.py | 47 ++++++++++++++++++++++++++++++++++++++ tests/test_detector.py | 38 +++++++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ecc1d8d..6f604f2 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ driftcheck --json # machine-readable driftcheck --fix # auto-fix drifts in documentation files ``` -Checks (v0.1.4): +Checks (v0.1.5): - Rust: `rust-toolchain.toml` `channel` **and** `Cargo.toml` `rust-version` vs `README.md` / `docs/README*.md` / `CONTRIBUTING*.md` - Minor-aware: `channel = "1.96"` matches docs that say `Rust 1.96.1` (patch differences ignored); a real drift is a different major/minor. - Node: `package.json` `engines.node` vs README - Python: `pyproject.toml` `requires-python` vs README - Go: `go.mod` `go` directive vs README +- Line endings: missing `* text=auto eol=lf` in `.gitattributes` (causes CRLF working-tree drift on Windows `core.autocrlf=true`) - Extensible: add more toolchain sources in `driftcheck/detector.py` Inspired by fixing https://github.com/tinyhumansai/openhuman/issues/5781 (6 READMEs drifted). \ No newline at end of file diff --git a/src/driftcheck/detector.py b/src/driftcheck/detector.py index 4fe4d08..0e0dbc9 100644 --- a/src/driftcheck/detector.py +++ b/src/driftcheck/detector.py @@ -157,6 +157,10 @@ def find_python_drift(pyproject_text: str, docs: dict[str, str]) -> list[dict]: ) GO_MOD_RE = re.compile(r'^\s*go\s+(?P[0-9]+\.[0-9]+)', re.MULTILINE) + +EOL_ATTR_RE = re.compile(r'^\s*\*?\s*text\s*=\s*auto', re.MULTILINE) +EOL_LINE_RE = re.compile(r'^\s*\*.*eol\s*=\s*lf', re.MULTILINE) + def parse_go_version_from_gomod(text: str) -> str | None: m = GO_MOD_RE.search(text) return m.group("ver") if m else None @@ -182,6 +186,32 @@ def find_go_drift(gomod_text: str, docs: dict[str, str]) -> list[dict]: return drifts + +def find_lineending_drift(root: Path) -> list[dict]: + """Detect missing CRLF-safe .gitattributes. + + A repo that ships text source but lacks `* text=auto eol=lf` in + .gitattributes can check out with CRLF working-tree bytes on Windows + (core.autocrlf=true) while the index stores LF -- silently breaking + byte-exact checks. Returns a drift if .gitattributes is absent or does + not normalize line endings. + """ + ga = root / ".gitattributes" + if not ga.exists(): + return [{ + "file": ".gitattributes", + "kind": "lineending", + "detail": "missing .gitattributes with `* text=auto eol=lf`", + }] + text = ga.read_text(encoding="utf-8", errors="replace") + if not (EOL_ATTR_RE.search(text) and EOL_LINE_RE.search(text)): + return [{ + "file": ".gitattributes", + "kind": "lineending", + "detail": ".gitattributes does not set `* text=auto eol=lf`", + }] + return [] + def apply_fixes(root: Path, result: dict) -> list[str]: """Apply fixes for all detected drifts. Returns list of fixed file paths.""" fixed = [] @@ -238,6 +268,21 @@ def repl(match): if fix_in_file(fpath, d["doc_version"], d["gomod_version"], [GO_RE]): fixed.append(d["file"]) + + # Line-ending drifts: ensure .gitattributes normalizes CRLF + for d in result.get("lineending_drifts", []): + ga = root / d["file"] + needed = "* text=auto eol=lf\n" + if not ga.exists(): + ga.write_text("# Normalize line endings so working-tree bytes match the index on every platform\n" + needed) + fixed.append(d["file"]) + else: + text = ga.read_text(encoding="utf-8", errors="replace") + if "text=auto eol=lf" not in text: + text = text.rstrip("\n") + "\n\n# Normalize line endings (added by driftcheck --fix)\n" + needed + ga.write_text(text, encoding="utf-8") + fixed.append(d["file"]) + return fixed @@ -266,6 +311,7 @@ def scan_repo(root: Path = Path(".")) -> dict: node_drifts = find_node_drift(package_text, docs) python_drifts = find_python_drift(pyproject_text, docs) go_drifts = find_go_drift(gomod_text, docs) + lineending_drifts = find_lineending_drift(root) return { "toolchain_version": parse_toolchain_version(toolchain_text), @@ -278,4 +324,5 @@ def scan_repo(root: Path = Path(".")) -> dict: "node_drifts": node_drifts, "python_drifts": python_drifts, "go_drifts": go_drifts, + "lineending_drifts": lineending_drifts, } \ No newline at end of file diff --git a/tests/test_detector.py b/tests/test_detector.py index 0ee46a7..cd42a8e 100644 --- a/tests/test_detector.py +++ b/tests/test_detector.py @@ -1,6 +1,6 @@ """TDD for driftcheck — RED first, then GREEN.""" -from driftcheck.detector import find_rust_drift +from driftcheck.detector import find_rust_drift, scan_repo, apply_fixes def test_no_drift(): toolchain = 'channel = "1.96.1"' @@ -150,3 +150,39 @@ def test_apply_fixes_idempotent_when_no_drift(): (root / "README.md").write_text("Rust 1.96.1") fixed = apply_fixes(root, {"drifts": [], "node_drifts": [], "python_drifts": [], "go_drifts": []}) assert fixed == [] + + +def test_lineending_drift_detected_when_missing(): + from pathlib import Path + import tempfile + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "README.md").write_text("project") + r = scan_repo(root) + assert r["lineending_drifts"], "expected a lineending drift when .gitattributes is absent" + assert r["lineending_drifts"][0]["file"] == ".gitattributes" + + +def test_lineending_drift_apply_fix_creates_gitattributes(): + from pathlib import Path + import tempfile + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "README.md").write_text("project") + r = scan_repo(root) + fixed = apply_fixes(root, r) + assert ".gitattributes" in fixed + assert "text=auto eol=lf" in (root / ".gitattributes").read_text() + # re-scan should be clean + assert scan_repo(root)["lineending_drifts"] == [] + + +def test_lineending_drift_absent_when_present(): + from pathlib import Path + import tempfile + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "README.md").write_text("project") + (root / ".gitattributes").write_text("* text=auto eol=lf\n") + r = scan_repo(root) + assert r["lineending_drifts"] == []