Skip to content

repo_gate eol checks the two line-ending documents against each other, never against the tree #633

Description

@ptr727

repo_gate.py --check eol compares .gitattributes against .editorconfig and never compares either against the filesystem. Two defect shapes live in that gap, and both were sitting in ptr727/Blog while the gate reported clean. Raising it here because the pin list is carried content and the check that guards it is hub-managed, so a fix here reaches every repo.

What the gate does today

scripts/README.md states the scope plainly, and the implementation matches:

eol: every path pinned LF in .gitattributes has the matching .editorconfig override the line-ending rule requires, with EditorConfig brace syntax expanded. One direction only.

That direction is correct and worth keeping. The limitation is a different axis: it is document-to-document. Both documents can agree perfectly and both be wrong about the repository they describe.

spec/files.json marks .gitattributes as "fidelity": "intent", "whole": true, so what remains is an agent reading it for meaning during an audit. Nothing mechanical ever asks whether the pins match the tree.

The two shapes, both found in Blog

Unpinned. ops/vps-backup-pull is an extensionless shebang script, run unattended by systemd on the backup host. *.sh does not reach it, the .py pins do not reach it, and * -text keeps git passive, so a CRLF checkout would hand systemd a broken interpreter line. Copilot caught this one on the promotion PR; no gate did.

Dead. .gitattributes pinned two paths that have never been tracked in that repository, in any commit:

Pattern Tracked files matched
deploy/blog-deploy-shell 0
deploy/authorized_keys 0

Both live on the server, not in the repo. This is the worse half, and not because a no-op pin costs anything. The comment above those two read "The deploy shell is an extensionless shebang script that matches no rule above" — so the file asserted the extensionless case was handled, and the one real instance sat unpinned twenty lines up. A dead pin does not merely fail to bind. It reads as coverage, and it is what hid the live defect from every human and agent who read the file.

The gate reports clean on exactly that tree

Not argued from the code, measured. ptr727/Blog at 392de22 carried both defects:

$ python3 scripts/repo_gate.py --root <blog@392de22> --check eol
[ok  ] eol          0 issue(s)

There is a second reason it cannot fail there. Blog's .editorconfig sets [*] end_of_line = lf, so the "matching override" the check looks for is satisfied by the global default for any path, including a path that does not exist. In a repo shaped that way the eol check is vacuously true for every pin it will ever read, so its result carries no information about pin content at all. That is likely to be common across the fleet rather than peculiar to Blog.

What Blog now runs

Fixed in ptr727/Blog#69 as checks/check-eol-pins.py, wired into Lint sources job. Two directions:

  • unpinned — a tracked file opening with #! whose resolved eol is not lf. The shebang is the test rather than the mode bit, because the two move independently and it is the interpreter line a CRLF breaks.
  • dead — a pattern matching no tracked file.

It asks git check-attr for the resolved attribute rather than re-implementing gitattributes matching, so it cannot disagree with what git actually applies on checkout, and it fails rather than passing if it finds no shebang files at all.

Each direction was verified by reintroducing the real defect and watching the gate fail on it, rather than by trusting a green run. It also caught its own file before that was staged.

The design caveat, which matters for adopting this here

The dead-pattern direction cannot be adopted by the hub unmodified. Run against ProjectTemplate@c40cd0a, three patterns match no tracked file:

DEAD      uv.lock
DEAD      Dockerfile
DEAD      *.Dockerfile

All three are deliberate. This is a carried baseline, and those pins go live the moment a repo vendors it and adds a Dockerfile or a lockfile — .gitattributes says as much itself ("A repo with no lockfile is unaffected"). So the distinction the check has to encode is:

  • in a carried baseline, a pattern matching nothing is a forward declaration for consumers, and correct
  • in a leaf repo, a pattern matching nothing after vendoring is dead

Blog's two were unambiguously the second kind: they name host artifacts no consumer of Blog will ever carry. Some marker is needed to separate the cases — an explicit exception list, a comment convention, or simply scoping the dead direction to leaf repos and exempting the template. That call is the hub's, and it is the main thing worth deciding before lifting this.

The unpinned direction has no such caveat and could be adopted as-is: all 16 tracked shebang files in the hub already resolve to eol=lf, so it passes here today and would start protecting the instruction .gitattributes currently gives with no enforcement behind it — "Any repo whose tooling ships extensionless scripts adds the matching path pin." That sentence is the whole mechanism right now.

Two implementation notes

git ls-files -- <pattern> is pathspec matching, not gitattributes matching, and they differ: pathspec lets * cross a /, so capture/*.py also matches capture/sub/x.py. For the dead direction that errs toward not reporting, which is the safe bias, but a stricter implementation here should probably use gitignore-style matching to be exact. The unpinned direction is unaffected, since it delegates to git check-attr.

The symmetric question for .editorconfig — path-specific sections naming files that do not exist — I did not answer. My first pass at it produced false positives because my matcher did not expand braces, and repo_gate.py already implements that expansion, so the hub is better placed to answer it than I was. Recording it as an open question rather than a finding.

Suggested shape

Add the filesystem-facing direction to repo_gate.py --check eol (or a sibling --check eol-coverage), so it stays one script and one report. Sketch:

eol            .editorconfig <-> .gitattributes disagreement      (existing, unchanged)
eol-coverage   tracked shebang file with no LF pin                (new)
               .gitattributes pattern matching no tracked file    (new, needs the baseline exemption)

Happy to port check-eol-pins.py into scripts/ with tests matching the test_repo_gate.py style if that is the shape you want. Flagging rather than sending a PR first, since the baseline-exemption decision changes the design.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions