Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions gitignore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ""
}
Expand Down Expand Up @@ -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
}
Expand Down
100 changes: 98 additions & 2 deletions gitignore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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},
}

Expand All @@ -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) {
Expand Down