fix(diff): honor .gitignore negation patterns - #651
Conversation
Patterns were resolved with a first-match-wins scan that discarded any
`!` line outright ("negation patterns are not needed for exclusion
purposes"). That holds for a blocklist .gitignore, but inverts the result
for the allow-list idiom github/gitignore ships per language: `*` to
ignore everything, then `!` lines to re-include. Because a bare `*`
basename-matches every file, every path in such a repository resolved as
excluded.
The failure is silent. `ocr review` reports "0 reviewable / 0 total" and
`--preview` prints "No files changed", both of which read as a clean
review of a repository that was never looked at. It reaches `ocr scan`
and the agent's file_find tool too, since both filter through the same
matcher.
Resolve patterns the way git does — in file order, last match wins, `!`
inverting that pattern's verdict — and while in here support the two
constructs the allow-list idiom needs: a leading `/` anchoring a pattern
to the repository root, and `**`, routed through doublestar (already a
dependency) since filepath.Match cannot express it.
Two deliberate asymmetries:
- The hardcoded directory blocklist (.git/, node_modules/, vendor/…)
still short-circuits, so a negation cannot re-admit those.
- A negated directory-only pattern is inert. Git uses `!*/` to keep
descending into subdirectories, not to re-admit the files inside
them; honouring it against file paths would readmit everything below
the root. Positive directory-only patterns now also match on
directory components only, so `vendor/` no longer matches a file
named `vendor`.
MatchGitignorePattern keeps its existing contract: a negated pattern
reports false, so callers testing one pattern in isolation still read it
as "does this exclude the path". Ordered resolution, where negations
carry meaning, lives in isPathExcluded.
Verified against a repository using Go.AllowList.gitignore: file
discovery goes from 0 reviewable / 0 total to 9 reviewable / 18 total,
with the ignore file untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Retraction: my original description claimed The failure was my own git config. I had a stale With that neutralised the test passes, and so does the rest of the suite: I'd verified the failure reproduced with my changes stashed and concluded "pre-existing on main". That was the wrong conclusion from a correct observation — both runs shared the same broken machine config, so stashing only ruled out my diff as the cause, not my environment. Should have run One small thing that may still be worth having, entirely your call: Nothing here affects the fix in this PR — |
|
Closing this for now — I put it up ahead of an internal review on our side, so it's premature. The fix stays on our fork while we evaluate it. Apologies for the noise; happy to reopen if we bring it back. |
|
Reopened — the close was a miscommunication on our side, not a withdrawal. This fix is still wanted and unchanged; disregard the closing note above. Sorry for the churn in your timeline. |
|
🔍 OpenCodeReview found 1 issue(s) in this PR.
|
| if !anchored && strings.HasSuffix(relPath, body) { | ||
| return true | ||
| } |
There was a problem hiding this comment.
[bug · medium]
The suffix matching does not enforce a path-component boundary, which can cause false positives. For example, with pattern src/main.go, a file at path othersrc/main.go would incorrectly match because strings.HasSuffix("othersrc/main.go", "src/main.go") is true, even though git would not consider this a match.
The suffix check should verify that the match starts at a / boundary to avoid matching partial directory names.
Suggestion:
| if !anchored && strings.HasSuffix(relPath, body) { | |
| return true | |
| } | |
| if !anchored && strings.HasSuffix(relPath, "/"+body) { | |
| return true | |
| } |
There was a problem hiding this comment.
Good catch — fixed in 3c6b881, as you recommended.
Worth noting this is pre-existing rather than something this PR introduced: strings.HasSuffix(relPath, pat) is unchanged on main, and reproduces the same false positive there. The line appears in this diff only because of the anchored-pattern guard I added next to it. Since the fix is a one-liner on a line already in the diff, I folded it in rather than deferring — happy to split it into its own PR if you would rather keep this one scoped to negation handling.
Confirmed the behaviour before and after:
| before | after | |
|---|---|---|
othersrc/main.go vs src/main.go |
true |
false |
src/main.go vs src/main.go |
true |
true |
src/generated/api.go vs generated/api.go |
true |
true |
The last row is the existing path suffix match case, so the intentionally loose suffix behaviour is preserved — only the partial-component match goes away. Two regression cases added to TestMatchGitignorePattern.
The suffix fallback compared raw strings, so a pattern containing "/" also matched a path whose directory merely ends in the pattern's first component: "src/main.go" excluded "othersrc/main.go", which git never matches — a pattern with a "/" is anchored to the repository root. Requiring the suffix to start at "/" keeps the intentionally loose "generated/api.go" matches "src/generated/api.go" behaviour while dropping the partial-component case. Two cases added to TestMatchGitignorePattern. Pre-existing rather than introduced here; the line is in this diff because of the anchored-pattern guard, and the fix is a one-liner, so it is folded in rather than deferred. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Description
Fixes #650.
.gitignorenegation patterns were discarded outright:That holds for a blocklist
.gitignore, but it inverts the result for the "allow list" idiom — ignore everything with*, then re-include with!lines — whichgithub/gitignoreships one of per language (Go.AllowList.gitignore,Java.AllowList.gitignore, …).isPathExcludedalso resolved first-match-wins, and a bare*has no/, so it goes down the basename branch wherefilepath.Match("*", base)matches every file. The first pattern excluded everything and the!lines that would have re-included never got a say.The result is that every file in such a repository is treated as ignored, and it fails silently:
0 reviewable / 0 total,No files changed.— indistinguishable from a clean diff. See the issue for the full reasoning on why that's the dangerous part.Scope is wider than diff review:
internal/scan/provider.goandinternal/tool/file_find.goboth filter throughdiff.IsPathExcluded, soocr scanand the agent'sfile_findtool are affected in the same repositories.Approach
Resolve patterns the way git does — in file order, last match wins, with a leading
!inverting that pattern's verdict — and support the two constructs the allow-list idiom needs:/, which anchors a pattern to the repository root (/.golangci.ymlnames the root file) rather than making it a path pattern;**, routed throughdoublestar(already a direct dependency) sincefilepath.Matchcannot express it.Three deliberate decisions, each the kind that's easy to get backwards:
The hardcoded directory blocklist still short-circuits.
.git/,node_modules/,vendor/and friends are checked before any pattern, so a!line cannot re-admit them.A negated directory-only pattern is inert. Git uses a trailing
!*/to keep descending into subdirectories, not to re-admit the files inside them. Honouring it against file paths would readmit everything below the root — in the issue's repro,coverage.outwould come back reviewable. Directory-only patterns are also now matched against directory components only, sovendor/no longer matches a file literally namedvendor(previously the final path segment was considered).MatchGitignorePatternkeeps its current contract. A negated pattern still reportsfalse, so callers testing one pattern in isolation continue to read it as "does this exclude the path". Ordered resolution, where polarity carries meaning, lives inisPathExcluded. The existingTestMatchGitignorePatterncase for!important.logis unchanged and still passes.The matcher body moved into
matchGitignoreBody, which takes a pattern with any!already stripped;matchGitignorePatternis now a thin polarity-preserving wrapper over it.Type of Change
How Has This Been Tested?
make testpasses locallyUnit tests. Two new tables in
internal/diff/gitignore_test.go, written before the fix and confirmed failing againstmain:TestIsPathExcluded_AllowListGitignore— 16 cases over a realisticGo.AllowList.gitignore, covering root-anchored dotfiles,**workflow paths, directory globs, and the four artifacts that must stay excluded.TestIsPathExcluded_LastMatchWins— pins ordering directly: negation after exclusion re-includes, exclusion after negation re-excludes, a negation of an unmatched path is inert, and the hardcoded dirs are not negatable.Verification run (Go 1.25.6):
go test ./internal/diff/— okgofmt -l internal/ cmd/— cleango vet ./...— cleango test ./...— greenEnd-to-end, on a real repository using
Go.AllowList.gitignore, with the ignore file untouched:ocr delegate preview -c <sha>main# Files (0 reviewable / 0 total)# Files (9 reviewable / 18 total)and on the minimal repro from #650:
0 reviewable / 0 total→2 reviewable / 2 total, withcoverage.outstill correctly excluded.An earlier revision of this description claimed
internal/scanTestProvider_Enumerate_FullRepofails on a cleanmain. That was wrong and I have retracted it — see the comment below. It was my local git config, not your test.Checklist
go fmt,go vet)Related Issues
Closes #650