From 62c93316c0e41e038e767e951f9da99b1597aab1 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 26 Mar 2026 16:23:20 +0000 Subject: [PATCH] Fix wildcard matching, unclosed brackets, and trailing /** behavior Three fixes for pattern matching to align with git behavior: 1. The literalSuffix fast-reject optimization checked only the last path segment, but patterns with an implicit trailing ** can match any segment. Skip suffix extraction when the pattern ends with **. 2. Patterns with unclosed bracket expressions (e.g. "v[ou]l[") were treated as containing a literal "[". Git rejects these entirely. Now reported as a compilation error via Errors(). 3. Trailing /** patterns (e.g. "/a*/**") matched plain files when they should only match directories and their contents. Convert trailing /** to dirOnly semantics by stripping the ** and setting dirOnly. Fixes #4 --- gitignore.go | 30 +++++++++----- gitignore_test.go | 100 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/gitignore.go b/gitignore.go index d6e395a..92dcf3d 100644 --- a/gitignore.go +++ b/gitignore.go @@ -18,7 +18,7 @@ type segment struct { type pattern struct { segments []segment negate bool - dirOnly bool // trailing slash pattern + dirOnly bool // trailing slash pattern or trailing /** pattern hasConcrete bool // has at least one non-** segment anchored bool prefix string // directory scope for nested .gitignore @@ -466,6 +466,15 @@ func compilePattern(line, dir string) (pattern, string) { return pattern{}, msg } + // Trailing /** means "match directory and its contents, not files with the + // same name". In git, "data/**" matches data/ and data/file but not data + // (as a file). This is equivalent to dirOnly semantics, so strip the + // trailing ** and set dirOnly. + if !p.dirOnly && len(segs) >= 2 && segs[len(segs)-1].doubleStar { + segs = segs[:len(segs)-1] + p.dirOnly = true + } + segs = appendTrailingDoubleStar(segs, p.dirOnly) p.segments = segs @@ -536,15 +545,18 @@ func appendTrailingDoubleStar(segs []segment, dirOnly bool) []segment { // segment, for fast rejection. For example, "*.log" yields ".log", "test_*.go" // yields ".go". Only extracts a suffix when the segment is a simple star-prefix // glob with no brackets, escapes, or question marks in the suffix portion. +// +// The suffix is only extracted when the last segment is concrete (not **), +// because the fast-reject check compares against the final path segment. +// When the pattern ends with **, the concrete segment could match any path +// segment, making a last-segment-only check incorrect. func extractLiteralSuffix(segs []segment) string { - // Find the last non-** segment. - var last string - for i := len(segs) - 1; i >= 0; i-- { - if !segs[i].doubleStar { - last = segs[i].raw - break - } + if len(segs) == 0 || segs[len(segs)-1].doubleStar { + return "" } + + // The last segment is concrete; use it for suffix extraction. + last := segs[len(segs)-1].raw if last == "" { return "" } @@ -622,7 +634,7 @@ func validateBracketAt(glob string, pos int) (string, int) { j++ } if j >= len(glob) { - return "", -1 + return "unclosed bracket expression", -1 } return "", j } diff --git a/gitignore_test.go b/gitignore_test.go index 971846e..89ef219 100644 --- a/gitignore_test.go +++ b/gitignore_test.go @@ -617,6 +617,96 @@ func TestMatchWildcardStar(t *testing.T) { } } +func TestMatchWildcardInTheMiddle(t *testing.T) { + m := setupMatcher(t, "v*o\n") + + shouldMatch := []string{ + "vulkano", + "value/vulkano/tail", + "voo", + } + shouldNotMatch := []string{ + "value", + "tail", + } + + for _, path := range shouldMatch { + if !m.Match(path) { + t.Errorf("expected %q to match", path) + } + } + for _, path := range shouldNotMatch { + if m.Match(path) { + t.Errorf("expected %q to not match", path) + } + } +} + +func TestMatchMalformedBracketRejectsPattern(t *testing.T) { + m := setupMatcher(t, "v[ou]l[\n") + + // Git rejects patterns with unclosed bracket expressions. + // The pattern should be treated as invalid and match nothing. + if m.Match("vol[") { + t.Error("expected vol[ to not match - unclosed bracket should reject pattern") + } + if m.Match("vol") { + t.Error("expected vol to not match - unclosed bracket should reject pattern") + } + + errs := m.Errors() + if len(errs) == 0 { + t.Error("expected pattern compilation error for unclosed bracket") + } +} + +func TestMatchTrailingDoubleStarMatchesOnlyContents(t *testing.T) { + m := setupMatcher(t, "/a*/**\n") + + shouldMatch := []string{ + "ab_dir/file", + "abc/deep/nested/file", + } + shouldNotMatch := []string{ + "ab", + "abc", + } + + for _, path := range shouldMatch { + if !m.Match(path) { + t.Errorf("expected %q to match", path) + } + } + for _, path := range shouldNotMatch { + if m.Match(path) { + t.Errorf("expected %q to not match", path) + } + } +} + +func TestMatchDirectoryNegationWithDoubleStarSlash(t *testing.T) { + m := setupMatcher(t, "data/**\n!data/**/\n") + + cases := []struct { + path string + expect bool + }{ + {"data/", false}, + {"data/data1/", false}, + {"data/file.txt", true}, + {"data/data1/file.txt", true}, + {"data/data1/data2/", false}, + {"data/data1/data2/file.txt", true}, + } + + for _, tc := range cases { + got := m.Match(tc.path) + if got != tc.expect { + t.Errorf("Match(%q) = %v, want %v", tc.path, got, tc.expect) + } + } +} + func TestMatchQuestionMark(t *testing.T) { m := setupMatcher(t, "dea?beef\n") @@ -1275,14 +1365,15 @@ func TestMatchBracketNegationCaret(t *testing.T) { } func TestMatchUnclosedBracket(t *testing.T) { - // An unclosed [ is treated as a literal character + // Git rejects patterns with unclosed bracket expressions. + // The pattern should be treated as invalid and match nothing. m := setupMatcher(t, "file[.txt\n") tests := []struct { path string want bool }{ - {"file[.txt", true}, + {"file[.txt", false}, {"filea.txt", false}, } @@ -1292,6 +1383,11 @@ func TestMatchUnclosedBracket(t *testing.T) { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } + + errs := m.Errors() + if len(errs) == 0 { + t.Error("expected pattern compilation error for unclosed bracket") + } } func TestMatchTrailingSpacesStripped(t *testing.T) {