@@ -16,16 +16,16 @@ type segment struct {
1616}
1717
1818type 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.
353358func (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
370369func (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.
396413func 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
439434func (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.
580581func 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 }
0 commit comments