Skip to content

Commit 30f606b

Browse files
authored
Check parent directories in Match and add git check-ignore conformance tests (#27)
* Check parent directories in Match and add git check-ignore conformance tests Match now tests each parent directory of a path before the path itself, so a file under an excluded directory is reported as ignored and a negation cannot re-include it, matching git's per-directory traversal. The implicit trailing ** and the /** rewrite are removed; matchSegments takes a flag so a trailing ** must consume at least one segment. A whole-segment run of two or more asterisks now compiles as **. Four existing tests that asserted the opposite of git are corrected. Fixes #25. Fixes #26. * Handle Git startup failures in conformance tests * Add literal-suffix conformance case
1 parent c1d8483 commit 30f606b

42 files changed

Lines changed: 775 additions & 117 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎conformance_test.go‎

Lines changed: 455 additions & 0 deletions
Large diffs are not rendered by default.

‎gitignore.go‎

Lines changed: 84 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@ type segment struct {
1616
}
1717

1818
type pattern struct {
19-
segments []segment
20-
negate bool
21-
dirOnly bool // trailing slash pattern or trailing /** pattern
22-
hasConcrete bool // has at least one non-** segment
23-
anchored bool
24-
prefix []string // directory scope segments for nested .gitignore
25-
text string // original pattern text before compilation
26-
source string // file path this pattern came from, empty for programmatic
27-
line int // 1-based line number in source file
28-
literalSuffix string // fast-reject: last segment must end with this (e.g. ".log" from "*.log")
19+
segments []segment
20+
negate bool
21+
dirOnly bool // trailing slash pattern
22+
tailDoubleStar bool // pattern ends in "/**"
23+
anchored bool
24+
prefix []string // directory scope segments for nested .gitignore
25+
text string // original pattern text before compilation
26+
source string // file path this pattern came from, empty for programmatic
27+
line int // 1-based line number in source file
28+
literalSuffix string // fast-reject: last segment must end with this (e.g. ".log" from "*.log")
2929
}
3030

3131
// Matcher checks paths against gitignore rules collected from .gitignore files,
@@ -350,53 +350,72 @@ func (m *Matcher) MatchDetail(relPath string) MatchResult {
350350
return m.matchDetail(relPath, isDir)
351351
}
352352

353+
// match reports whether relPath is ignored. Git decides ignore status
354+
// while walking the tree and does not enter an excluded directory, so a
355+
// path is ignored if any of its parent directories is. That is checked
356+
// here by testing each proper prefix of the path as a directory before
357+
// testing the full path.
353358
func (m *Matcher) match(relPath string, isDir bool) bool {
354359
pathSegs := strings.Split(relPath, "/")
355-
lastSeg := pathSegs[len(pathSegs)-1]
356-
357-
for i := len(m.patterns) - 1; i >= 0; i-- {
358-
p := &m.patterns[i]
359-
if p.literalSuffix != "" && !p.dirOnly && !strings.HasSuffix(lastSeg, p.literalSuffix) {
360-
continue
361-
}
362-
if !matchPattern(p, pathSegs, isDir) {
363-
continue
360+
for end := 1; end < len(pathSegs); end++ {
361+
if idx := m.findMatch(pathSegs[:end], true); idx >= 0 && !m.patterns[idx].negate {
362+
return true
364363
}
365-
return !p.negate
366364
}
367-
return false
365+
idx := m.findMatch(pathSegs, isDir)
366+
return idx >= 0 && !m.patterns[idx].negate
368367
}
369368

370369
func (m *Matcher) matchDetail(relPath string, isDir bool) MatchResult {
371370
pathSegs := strings.Split(relPath, "/")
372-
lastSeg := pathSegs[len(pathSegs)-1]
371+
for end := 1; end < len(pathSegs); end++ {
372+
if idx := m.findMatch(pathSegs[:end], true); idx >= 0 && !m.patterns[idx].negate {
373+
return m.resultFor(idx)
374+
}
375+
}
376+
if idx := m.findMatch(pathSegs, isDir); idx >= 0 {
377+
return m.resultFor(idx)
378+
}
379+
return MatchResult{}
380+
}
373381

382+
// findMatch returns the index of the last pattern that matches pathSegs,
383+
// or -1 if none does. Patterns are scanned from last to first because
384+
// gitignore uses last-match-wins ordering.
385+
func (m *Matcher) findMatch(pathSegs []string, isDir bool) int {
386+
lastSeg := pathSegs[len(pathSegs)-1]
374387
for i := len(m.patterns) - 1; i >= 0; i-- {
375388
p := &m.patterns[i]
376-
if p.literalSuffix != "" && !p.dirOnly && !strings.HasSuffix(lastSeg, p.literalSuffix) {
377-
continue
378-
}
379-
if !matchPattern(p, pathSegs, isDir) {
389+
if p.literalSuffix != "" && !strings.HasSuffix(lastSeg, p.literalSuffix) {
380390
continue
381391
}
382-
return MatchResult{
383-
Ignored: !p.negate,
384-
Matched: true,
385-
Pattern: p.text,
386-
Source: p.source,
387-
Line: p.line,
388-
Negate: p.negate,
392+
if matchPattern(p, pathSegs, isDir) {
393+
return i
389394
}
390395
}
391-
return MatchResult{}
396+
return -1
397+
}
398+
399+
func (m *Matcher) resultFor(idx int) MatchResult {
400+
p := &m.patterns[idx]
401+
return MatchResult{
402+
Ignored: !p.negate,
403+
Matched: true,
404+
Pattern: p.text,
405+
Source: p.source,
406+
Line: p.line,
407+
Negate: p.negate,
408+
}
392409
}
393410

394411
// matchPattern checks whether pathSegs matches the compiled pattern,
395412
// including the directory prefix scope and dirOnly handling.
396413
func matchPattern(p *pattern, pathSegs []string, isDir bool) bool {
397414
segs := pathSegs
398415
if n := len(p.prefix); n > 0 {
399-
if len(segs) < n {
416+
// Rules from a nested .gitignore apply only to entries strictly
417+
// inside that directory, never to the directory itself.
418+
if len(segs) <= n {
400419
return false
401420
}
402421
for i, ps := range p.prefix {
@@ -406,34 +425,10 @@ func matchPattern(p *pattern, pathSegs []string, isDir bool) bool {
406425
}
407426
segs = segs[n:]
408427
}
409-
410-
if p.dirOnly {
411-
// Dir-only patterns (trailing slash): match the directory itself,
412-
// or match descendants (files/dirs under the matched directory).
413-
if matchSegments(p.segments, segs) {
414-
// A non-dir path may still be under a matched directory, so let
415-
// exclusions fall through; negations don't inherit downwards.
416-
if isDir || p.negate {
417-
return isDir
418-
}
419-
}
420-
// Only do descendant matching when the pattern identifies a specific
421-
// directory (has at least one non-** segment). Pure ** patterns like
422-
// "**/" only match directory paths directly.
423-
if !p.hasConcrete {
424-
return false
425-
}
426-
// Check if the path is a descendant of a matched directory by trying
427-
// the pattern against every prefix of the path segments.
428-
for end := len(segs) - 1; end >= 1; end-- {
429-
if matchSegments(p.segments, segs[:end]) {
430-
return true
431-
}
432-
}
428+
if p.dirOnly && !isDir {
433429
return false
434430
}
435-
436-
return matchSegments(p.segments, segs)
431+
return matchSegments(p.segments, segs, p.tailDoubleStar)
437432
}
438433

439434
func (m *Matcher) addPatterns(data []byte, dir, source string) {
@@ -516,31 +511,23 @@ func compilePattern(line, dir string) (pattern, string) {
516511
}
517512
}
518513

514+
// A pattern ending "/**" matches everything inside the named directory
515+
// but not the directory itself. Record that here so matchSegments can
516+
// require the trailing ** to consume at least one path segment. Git
517+
// treats any run of two or more asterisks as ** when it forms a whole
518+
// segment, so "/***" and beyond count too.
519+
if i := strings.LastIndexByte(line, '/'); i >= 0 {
520+
p.tailDoubleStar = allStars(line[i+1:])
521+
}
522+
519523
segs, anchored := buildSegments(line, hasLeadingSlash)
520524
p.anchored = anchored
521525

522526
if msg := validateSegmentBrackets(segs); msg != "" {
523527
return pattern{}, msg
524528
}
525529

526-
// Trailing /** means "match directory and its contents, not files with the
527-
// same name". In git, "data/**" matches data/ and data/file but not data
528-
// (as a file). This is equivalent to dirOnly semantics, so strip the
529-
// trailing ** and set dirOnly.
530-
if !p.dirOnly && len(segs) >= 2 && segs[len(segs)-1].doubleStar {
531-
segs = segs[:len(segs)-1]
532-
p.dirOnly = true
533-
}
534-
535-
segs = appendTrailingDoubleStar(segs, p.dirOnly)
536-
537530
p.segments = segs
538-
for _, s := range segs {
539-
if !s.doubleStar {
540-
p.hasConcrete = true
541-
break
542-
}
543-
}
544531
p.literalSuffix = extractLiteralSuffix(segs)
545532
return p, ""
546533
}
@@ -559,7 +546,7 @@ func buildSegments(line string, hasLeadingSlash bool) ([]segment, bool) {
559546
}
560547

561548
for _, raw := range rawSegs {
562-
if raw == "**" {
549+
if allStars(raw) {
563550
segs = append(segs, segment{doubleStar: true})
564551
} else {
565552
segs = append(segs, segment{raw: raw})
@@ -576,6 +563,20 @@ func buildSegments(line string, hasLeadingSlash bool) ([]segment, bool) {
576563
return collapsed, anchored
577564
}
578565

566+
// allStars reports whether s consists of two or more '*' bytes and nothing
567+
// else. Git's wildmatch treats such a segment the same as **.
568+
func allStars(s string) bool {
569+
if len(s) < 2 {
570+
return false
571+
}
572+
for i := 0; i < len(s); i++ {
573+
if s[i] != '*' {
574+
return false
575+
}
576+
}
577+
return true
578+
}
579+
579580
// validateSegmentBrackets checks bracket expressions in all concrete segments.
580581
func validateSegmentBrackets(segs []segment) string {
581582
for _, seg := range segs {
@@ -589,15 +590,6 @@ func validateSegmentBrackets(segs []segment) string {
589590
return ""
590591
}
591592

592-
// appendTrailingDoubleStar adds an implicit ** at the end for non-dir-only
593-
// patterns so that matching "foo" also matches "foo/anything".
594-
func appendTrailingDoubleStar(segs []segment, dirOnly bool) []segment {
595-
if !dirOnly && (len(segs) == 0 || !segs[len(segs)-1].doubleStar) {
596-
segs = append(segs, segment{doubleStar: true})
597-
}
598-
return segs
599-
}
600-
601593
// extractLiteralSuffix finds the literal trailing portion of the last concrete
602594
// segment, for fast rejection. For example, "*.log" yields ".log", "test_*.go"
603595
// yields ".go". Only extracts a suffix when the segment is a simple star-prefix
@@ -628,10 +620,12 @@ func extractLiteralSuffix(segs []segment) string {
628620
return ""
629621
}
630622

631-
// Bail if the suffix contains wildcards, brackets, or escapes.
623+
// Bail if the suffix contains wildcards, brackets, or escapes. A ']'
624+
// here means the '*' found above was inside a bracket expression and
625+
// is literal, so the suffix boundary is wrong.
632626
for i := 0; i < len(suffix); i++ {
633627
switch suffix[i] {
634-
case '*', '?', '[', '\\':
628+
case '*', '?', '[', ']', '\\':
635629
return ""
636630
}
637631
}

‎gitignore_test.go‎

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -745,16 +745,18 @@ func TestMatchNegateAnchored(t *testing.T) {
745745
func TestMatchDoubleStarSlash(t *testing.T) {
746746
m := setupMatcher(t, "**/\n")
747747

748-
// **/ matches any directory
748+
// **/ matches any directory, and therefore any path under a
749+
// directory, since git does not enter an excluded directory.
749750
shouldMatch := []string{
750751
"a/",
751752
"a/b/",
752753
"deep/nested/dir/",
754+
"a/b", // file inside excluded directory a/
755+
"deep/nested/dir", // file at any depth
753756
}
754757
shouldNotMatch := []string{
755-
"a", // file, not directory
756-
"b", // file
757-
"a/b", // file inside directory
758+
"a", // top-level file
759+
"b", // top-level file
758760
}
759761

760762
for _, path := range shouldMatch {
@@ -780,11 +782,9 @@ func TestMatchEscapedCharacters(t *testing.T) {
780782
}
781783
}
782784

783-
// TestMatchAgainstGitCheckIgnore verifies our implementation matches
784-
// git check-ignore for a variety of patterns. Each subtest creates a
785-
// real git repo, writes a .gitignore, and compares our result against
786-
// git's actual output.
787-
func TestMatchAgainstGitCheckIgnore(t *testing.T) {
785+
// TestMatchTable exercises a variety of pattern shapes against fixed
786+
// expectations. See TestConformance for direct comparison with git.
787+
func TestMatchTable(t *testing.T) {
788788
tests := []struct {
789789
name string
790790
patterns string
@@ -994,21 +994,19 @@ func TestMatchDirOnlyFrotz(t *testing.T) {
994994
}
995995

996996
func TestMatchCannotReincludeUnderExcludedParent(t *testing.T) {
997-
// From docs: "It is not possible to re-include a file if a parent directory
998-
// of that file is excluded."
999-
// Since our callers SkipDir on excluded directories, we test that the
1000-
// directory itself is excluded (the caller won't descend into it).
997+
// gitignore(5): "It is not possible to re-include a file if a parent
998+
// directory of that file is excluded." dir/ excludes the directory,
999+
// so the negation on line 2 has no effect.
10011000
m := setupMatcher(t, "dir/\n!dir/important.txt\n")
10021001

1003-
// The directory is still excluded
10041002
if !m.Match("dir/") {
10051003
t.Error("expected dir/ to be ignored")
10061004
}
1007-
// The file would be re-included by the pattern, but since callers
1008-
// SkipDir on dir/, they never check this file. We verify the pattern
1009-
// semantics still work for completeness.
1010-
if m.Match("dir/important.txt") {
1011-
t.Error("negation should re-include dir/important.txt in pattern matching")
1005+
if !m.Match("dir/important.txt") {
1006+
t.Error("expected dir/important.txt to be ignored (parent dir/ is excluded)")
1007+
}
1008+
if !m.Match("dir/other.txt") {
1009+
t.Error("expected dir/other.txt to be ignored (parent dir/ is excluded)")
10121010
}
10131011
}
10141012

@@ -1072,8 +1070,9 @@ func TestMatchStarExtension(t *testing.T) {
10721070
func TestMatchDoubleStarTrailingDir(t *testing.T) {
10731071
m := setupMatcher(t, "foo/**/\n")
10741072

1075-
shouldMatch := []string{"foo/", "foo/abc/", "foo/x/y/z/"}
1076-
shouldNotMatch := []string{"foo"}
1073+
// foo/**/ matches directories inside foo, not foo itself.
1074+
shouldMatch := []string{"foo/abc/", "foo/x/y/z/"}
1075+
shouldNotMatch := []string{"foo", "foo/"}
10771076

10781077
for _, path := range shouldMatch {
10791078
if !m.Match(path) {
@@ -1106,13 +1105,15 @@ func TestMatchDoubleStarWithExtension(t *testing.T) {
11061105
}
11071106

11081107
func TestMatchNegationSubdirectoryFilter(t *testing.T) {
1108+
// abc excludes the directory abc, so nothing under it can be
1109+
// re-included. abc/* would be needed for the negation to take effect.
11091110
m := setupMatcher(t, "abc\n!abc/b\n")
11101111

11111112
if !m.Match("abc/a.js") {
11121113
t.Error("expected abc/a.js to match")
11131114
}
1114-
if m.Match("abc/b/b.js") {
1115-
t.Error("expected abc/b/b.js to not match")
1115+
if !m.Match("abc/b/b.js") {
1116+
t.Error("expected abc/b/b.js to match (parent abc is excluded)")
11161117
}
11171118
}
11181119

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/root-only
2+
unanchored
3+
foo/*
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
root-only
2+
sub/root-only
3+
unanchored
4+
sub/unanchored
5+
foo/
6+
foo/test.json
7+
foo/bar/
8+
foo/bar/hello.c
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[a*]b
2+
x[c*d]y
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
ab
2+
zb
3+
xcy
4+
xdy
5+
xzy
6+
xccdy
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
build/
2+
*.egg-info/
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
buildfile
2+
build/
3+
build/output.js
4+
sub/build/
5+
sub/build/x
6+
mypkg.egg-info/
7+
mypkg.egg-info/PKG-INFO
8+
mypkg.egg-info-backup
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
data/**/

0 commit comments

Comments
 (0)