chore(ci): fail the build on a surviving merge-conflict marker - #154
chore(ci): fail the build on a surviving merge-conflict marker#154sandeep-agami wants to merge 2 commits into
Conversation
A conflict marker that outlives its resolution is invisible to review, renders
broken on GitHub, and can leak an internal branch name into a release artifact.
Nothing currently catches one.
Deliberately not pre-commit-hooks' `check-merge-conflict`: that hook only inspects
files while a merge is in progress, so a marker already committed on a branch you
later merge walks straight past it. This scans the tracked tree unconditionally,
and CI re-runs it as the copy that cannot be skipped with `--no-verify`.
The matching rule is asymmetric on purpose, because one marker is ambiguous:
`<<<<<<< ` / `>>>>>>> ` never legitimate -> always an error
`=======` a valid Markdown setext heading underline, so it is
reported ONLY when the same file also carries an
unambiguous marker
A real conflict always leaves at least one unambiguous marker, so the ambiguous
one becomes attributable instead of guessed at — and ordinary prose with a
setext heading does not fail the build. Both behaviours are covered by the
probes in the PR description.
Runs as its own CI job rather than a step on `gitleaks`, so a failure reads as
what it is instead of arriving under a secret-scan heading.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an explicit repo-wide hygiene check that fails CI (and optionally pre-commit) if merge-conflict markers survive into tracked files, using an asymmetric rule to avoid false-positives on Markdown setext headings.
Changes:
- Introduces
dev/check_conflict_markers.pyto scan the tracked tree for conflict markers with an “unambiguous required” rule for=======. - Adds a local pre-commit hook (
conflict-markers) for fast developer feedback. - Adds a dedicated
hygienejob to CI so failures are surfaced separately from secret scanning.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| dev/check_conflict_markers.py | New tracked-tree scanner that detects merge-conflict markers with an asymmetric matching rule. |
| .pre-commit-config.yaml | Adds a local hook to run the conflict-marker scan. |
| .github/workflows/ci.yml | Adds a dedicated CI job to run the conflict-marker scan as an unskippable gate. |
Comments suppressed due to low confidence (2)
dev/check_conflict_markers.py:50
scan()opens files without a context manager and useserrors="strict"then silently skips onUnicodeDecodeError. That can leak file descriptors in non-CPython runtimes and can also produce false negatives for text files that contain a few non-UTF-8 bytes (the conflict markers are ASCII and still searchable).
try:
text = open(path, encoding="utf-8", errors="strict").read()
except (UnicodeDecodeError, OSError):
return [] # binary or unreadable — nothing meaningful to match
dev/check_conflict_markers.py:56
- This can be simplified (and sped up slightly) by using
str.startswith()with a tuple directly, instead ofany(...)over the tuple.
(n, line)
for n, line in enumerate(lines, 1)
if any(line.startswith(m) for m in UNAMBIGUOUS)
]
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| out = subprocess.run( | ||
| ["git", "ls-files"], capture_output=True, text=True, check=True | ||
| ).stdout | ||
| return [p for p in out.splitlines() if p and not p.endswith(SKIP_SUFFIXES)] |
There was a problem hiding this comment.
Fixed. Suffix matching is now case-insensitive (p.lower().endswith(...)), and the list grew to cover .svg-adjacent, archive, and compiled types.
The more important half of this: the suffix list was never actually the binary defence — the broad except UnicodeDecodeError was, which is why a text file with one latin-1 byte scanned clean. The authoritative test is now a NUL-byte probe over the first 8 KiB, so an unlisted binary type is handled correctly rather than silently mis-scanned, and the suffix list is a fast path only.
| - id: conflict-markers | ||
| name: no merge-conflict markers | ||
| entry: python3 dev/check_conflict_markers.py | ||
| language: system | ||
| always_run: true |
There was a problem hiding this comment.
Good catch on the portability, but python would trade a Windows break for a Linux one — Debian/Ubuntu ship only python3 unless python-is-python3 is installed. (README.md:149 also states Windows is not yet validated end-to-end.)
Switched to language: script with a shebang instead, so pre-commit resolves the interpreter per-platform and neither bare name is hardcoded. Verified: uvx pre-commit run conflict-markers --all-files → Passed.
| def main() -> int: | ||
| findings = [(p, n, line) for p in tracked_files() for n, line in scan(p)] | ||
| if not findings: | ||
| print("✓ no merge-conflict markers in tracked files") | ||
| return 0 |
There was a problem hiding this comment.
Added — tests/test_conflict_marker_gate.py, 24 cases, all green.
It covers the asymmetric rule you name, including the case that turned out to matter most: the bare separator is scoped per file, so a conflict in a.py must not indict a setext heading in b.md. Worth noting no tracked file in this repo currently contains a line of only =, so deleting that guard would have kept CI green until someone added a doc with a setext heading, at which point the gate fails the whole repo on innocent prose.
Note the bootstrap problem: a fixture with markers at column 0 would be caught by this very gate. The tests build markers at runtime ("<" * 7) and drive the script as a subprocess against a throwaway git repo, so nothing is written that the gate would later scan.
Review: conflict-marker gateThe design call is right, and the reasoning in the docstring is genuinely good — But the gate has three silent-pass paths that share one root cause, and I reproduced all of them. Decisive repro — a repo whose only tracked content is three files, each holding a live Root cause: CriticalC1.
A non-ASCII directory hides its whole subtree ( C2. One non-UTF-8 byte anywhere in a text file hides every marker in it.
The markers are pure ASCII, so there is no reason to decode at all. Read bytes and match on bytes. C3. An unreadable file is indistinguishable from a clean one. The comment Fixing the one root cause closes C1, C2, C3 and most of M3 below. ImportantI1. The The comment at
So I2. diff3/zdiff3 leaves a 4th marker, and the error message walks the developer into shipping it.
The gate reports three lines and says "remove every marker". Remove exactly what it printed and you are left with I3. No tests, and the script lands in a double blind spot.
There is direct precedent for exactly this artifact class: The bootstrap problem is real (a fixture with markers at column 0 fails this gate) but avoidable with no script change: I4.
Suggestions
Checked and fine
Recommendation: hold for C1–C3 (one root-cause fix: bytes not decode, Findings verified by probe against throwaway git repos; nothing in the repo or its worktrees was modified. |
Review found the gate reporting a pass over files it never read. Three tracked files holding live conflict blocks — café.py, docs/日本語/readme.md, and a .py with one latin-1 byte — scanned clean and exited 0. Root cause: scan() returned the same value for "read it, it was clean" and "couldn't read it at all". - git ls-files -z: core.quotePath C-quotes any non-ASCII/control/quote path into a string that isn't openable, so the file was skipped silently and a non-ASCII directory hid its whole subtree. - Match on bytes, not a strict UTF-8 decode: one latin-1 byte anywhere discarded the entire file's scan. Verified git does text-merge such files and does write markers into them. - Unreadable is now its own failing state (chmod 000, sparse checkouts, submodule gitlinks), not silence. - Detect diff3/zdiff3's fourth marker. The message said "remove every marker" while printing only three of four; deleting exactly what was printed left a residue that re-scanned clean. - Match runs of 7+ so .gitattributes conflict-marker-size is covered, and make the label optional. - Report the scanned-file count and refuse to pass on an empty tree; chdir to the repo root so a subdirectory run can't pass having scanned nothing. - Surface git's own stderr; dedupe an unmerged index; case-insensitive suffix skip, with a NUL-byte probe as the authoritative binary test (Copilot). - Context manager on the read, startswith-tuple removed in favour of regexes (Copilot). Binary/UTF-16 files stay skipped on purpose: the NUL probe is git's own rule, and a real merge of two UTF-16 files leaves our side in place with no markers. pre-commit hook moves to `language: script` — neither `python3` (absent on Windows) nor `python` (absent on Debian/Ubuntu) is portable. Wire-up gaps the gate had fallen into: dev/ was linted by neither CI nor dev.py (TARGETS had `dev.py` the file, not `dev` the directory), and `dev.py check` didn't run the gate although CLAUDE.md and CONTRIBUTING.md both promise it is "the same checks CI gates on". tests/test_conflict_marker_gate.py locks all of it, including the asymmetric rule's per-file scoping — no tracked file in the repo has a setext underline, so deleting that guard would otherwise stay green until a doc tripped it.
Addressed in b1a85baAll Copilot comments (inline + both suppressed low-confidence ones) and the critical findings above. The headline fix. The gate was reporting a pass over files it never read. Before, with three tracked files each holding a live conflict block: After, same tree (plus a Root cause was single:
One correction to my earlier review: I listed UTF-16 as a bypass. It isn't. The NUL-byte probe is git's own binary rule, and a real merge of two UTF-16 files leaves our side in place with no markers written at all — so that state isn't reachable. Test documents it as intended behaviour rather than a gap. Wire-up gaps the gate had itself fallen into:
Two things deliberately not done1. The 2. No escape hatch. A |
Why
A merge-conflict marker that outlives its resolution is invisible to review, renders broken on GitHub, and can leak an internal branch name into a release artifact. Nothing in the repo currently catches one — not
ruff, notgitleaks, not the test suite.Why not
pre-commit-hooks'check-merge-conflictThat hook only inspects files while a merge is in progress. A marker that is already committed — on a branch you later merge, or a resolution you fixed up in a follow-up commit — walks straight past it. This scans the tracked tree unconditionally.
The matching rule is deliberately asymmetric
One of the three markers is ambiguous:
<<<<<<<>>>>>>>=======A real conflict always leaves at least one unambiguous marker, so the ambiguous one becomes attributable rather than guessed at — and ordinary prose with a setext heading doesn't fail the build.
Verified both directions:
What lands
dev/check_conflict_markers.py— tracked-tree scan, skips binary suffixes, decodes defensively.pre-commit-config.yaml— local hook for fast feedback (bypassable, per the file's own framing).github/workflows/ci.yml— its ownhygienejob, so a failure reads as what it is rather than arriving under the secret-scan headingTest plan
python3 dev.py checkgreen — ruff, full suite, gitleaks, vendored-lib drift all cleanuvx pre-commit run conflict-markers --all-files→ Passed