diff --git a/file_test.go b/file_test.go index 92c6039..5c40ec0 100644 --- a/file_test.go +++ b/file_test.go @@ -78,6 +78,61 @@ func TestFindRepositoryRootWorktree(t *testing.T) { } } +// TestWalkWildcardIgnoreThenReinclude reproduces issue #24: a wildcard ignore +// "/*/" that re-includes a directory via negation "!/keep/" must still walk the +// re-included directory's nested subdirectories. The leading-slash "/*/" pattern +// is anchored to the .gitignore directory and only matches first-level entries, +// so "keep/sub" must not be ignored (matching `git ls-files`). +func TestWalkWildcardIgnoreThenReinclude(t *testing.T) { + tmp := t.TempDir() + + if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("/*/\n!/keep/\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(tmp, "keep", "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(tmp, "drop"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "keep", "a.rs"), []byte("a"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "keep", "sub", "c.rs"), []byte("c"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "drop", "d.rs"), []byte("d"), 0o644); err != nil { + t.Fatal(err) + } + + fileListQueue := make(chan *File, 1000) + walker := NewFileWalker(tmp, fileListQueue) + if err := walker.Start(); err != nil { + t.Fatal(err) + } + + got := map[string]bool{} + for f := range fileListQueue { + rel, err := filepath.Rel(tmp, f.Location) + if err != nil { + t.Fatal(err) + } + got[filepath.ToSlash(rel)] = true + } + + // Expected matches `git ls-files`: keep/a.rs and keep/sub/c.rs kept, + // everything under drop/ ignored. + if !got["keep/a.rs"] { + t.Errorf("expected keep/a.rs to be walked, got %v", got) + } + if !got["keep/sub/c.rs"] { + t.Errorf("expected keep/sub/c.rs to be walked (issue #24), got %v", got) + } + if got["drop/d.rs"] { + t.Errorf("expected drop/d.rs to be ignored by /*/, got %v", got) + } +} + func TestNewFileWalker(t *testing.T) { fileListQueue := make(chan *File, 10_000) // NB we set buffered to ensure we get everything curdir, _ := os.Getwd() diff --git a/go-gitignore/pattern.go b/go-gitignore/pattern.go index 8b09927..464bda4 100644 --- a/go-gitignore/pattern.go +++ b/go-gitignore/pattern.go @@ -196,6 +196,13 @@ func (n *name) Match(path string, isdir bool) bool { _target := path if !n._anchored { _, _target = filepath.Split(path) + } else if strings.ContainsRune(_target, '/') { + // an anchored name pattern is a single path component anchored to the + // base directory, so it can only ever match a single-segment path. A + // multi-segment target (e.g. "keep/sub" against "/*/") must not match, + // otherwise glob patterns such as "*" would incorrectly span the '/' + // separator and ignore nested directories that git keeps. + return false } // fast-path dispatch avoids expensive fnmatch for simple patterns diff --git a/go-gitignore/pattern_opt_test.go b/go-gitignore/pattern_opt_test.go index e5eee78..25c5b0c 100644 --- a/go-gitignore/pattern_opt_test.go +++ b/go-gitignore/pattern_opt_test.go @@ -135,10 +135,11 @@ func TestNamePatternSuffixMatch(t *testing.T) { // TestNamePatternSuffixMatchAnchored tests anchored suffix patterns. func TestNamePatternSuffixMatchAnchored(t *testing.T) { runNameTests(t, []nameMatchTest{ - // anchored suffix: matches full relative path with HasSuffix - // note: name patterns use fnmatch flags=0, so * matches through / + // an anchored name pattern is a single path component anchored to the + // base directory, so it only matches single-segment paths and never + // spans the '/' separator (matching git's behaviour for "/*.o") {"/*.o", "foo.o", false, true}, - {"/*.o", "src/foo.o", false, true}, // * matches "src/foo" (no FNM_PATHNAME) + {"/*.o", "src/foo.o", false, false}, // anchored: must not match nested }) }