diff --git a/conformance_test.go b/conformance_test.go new file mode 100644 index 0000000..dc2e79b --- /dev/null +++ b/conformance_test.go @@ -0,0 +1,455 @@ +package gitignore_test + +import ( + "bytes" + "math/rand" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + + "github.com/git-pkgs/gitignore" +) + +// TestConformance compares Match against git check-ignore for every case +// under testdata/conformance. Each case directory contains: +// +// gitignore pattern file +// paths one query path per line; a trailing slash marks a directory +// skip optional; when present, divergences are logged instead of failed +// +// The test builds a temporary git repository per case, materialises every +// path, asks git check-ignore for all of them in one call, and checks the +// library returns the same answer. +func TestConformance(t *testing.T) { + requireGit(t) + isolateGitEnv(t) + + cases, err := filepath.Glob("testdata/conformance/*") + if err != nil { + t.Fatal(err) + } + if len(cases) == 0 { + t.Fatal("no conformance cases found under testdata/conformance") + } + + for _, dir := range cases { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + continue + } + name := filepath.Base(dir) + t.Run(name, func(t *testing.T) { + runConformanceCase(t, dir) + }) + } +} + +func runConformanceCase(t *testing.T, dir string) { + patterns, err := os.ReadFile(filepath.Join(dir, "gitignore")) + if err != nil { + t.Fatalf("read gitignore: %v", err) + } + rawPaths, err := os.ReadFile(filepath.Join(dir, "paths")) + if err != nil { + t.Fatalf("read paths: %v", err) + } + skipReason, _ := os.ReadFile(filepath.Join(dir, "skip")) + + paths := parsePathList(string(rawPaths)) + if len(paths) == 0 { + t.Fatal("no paths to check") + } + + root := buildRepo(t, string(patterns), paths) + m := gitignore.New(root) + + want := gitCheckIgnore(t, root, paths) + report := func(format string, args ...any) { + if len(skipReason) > 0 { + t.Logf("(known divergence) "+format, args...) + } else { + t.Errorf(format, args...) + } + } + + diverged := 0 + for _, p := range paths { + got := m.Match(p.query()) + if got != want[p.rel] { + diverged++ + report("path %q (isDir=%v): library=%v git=%v", p.rel, p.isDir, got, want[p.rel]) + } + } + if diverged == 0 && len(skipReason) > 0 { + t.Errorf("case is marked skip (%s) but no longer diverges; remove the skip file", + strings.TrimSpace(string(skipReason))) + } +} + +// TestConformanceHarnessSelfCheck verifies the batched gitCheckIgnore parse +// by re-asking git for each path individually with the simple exit-code form +// and comparing answers. It exercises every case under testdata/conformance. +// If this fails and TestConformance passes, the batched harness is wrong. +func TestConformanceHarnessSelfCheck(t *testing.T) { + requireGit(t) + isolateGitEnv(t) + + cases, _ := filepath.Glob("testdata/conformance/*") + for _, dir := range cases { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + continue + } + t.Run(filepath.Base(dir), func(t *testing.T) { + patterns, _ := os.ReadFile(filepath.Join(dir, "gitignore")) + rawPaths, _ := os.ReadFile(filepath.Join(dir, "paths")) + paths := parsePathList(string(rawPaths)) + root := buildRepo(t, string(patterns), paths) + + batched := gitCheckIgnore(t, root, paths) + for _, p := range paths { + cmd := exec.Command("git", "check-ignore", "-q", "--no-index", p.rel) + cmd.Dir = root + single := cmd.Run() == nil + if single != batched[p.rel] { + t.Errorf("path %q: batched=%v single-call=%v", p.rel, batched[p.rel], single) + } + } + }) + } +} + +func TestConformanceGitStartupFailure(t *testing.T) { + requireGit(t) + if os.Getenv("GITIGNORE_TEST_STARTUP_FAILURE") == "1" { + gitCheckIgnore(t, filepath.Join(t.TempDir(), "missing"), []conformancePath{{rel: "file"}}) + return + } + + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(executable, "-test.run=^TestConformanceGitStartupFailure$") + cmd.Env = append(os.Environ(), "GITIGNORE_TEST_STARTUP_FAILURE=1") + out, err := cmd.CombinedOutput() + if ee, ok := err.(*exec.ExitError); !ok || ee.ExitCode() != 1 { + t.Fatalf("expected test failure, got %v\n%s", err, out) + } + if !bytes.Contains(out, []byte("git check-ignore:")) || !bytes.Contains(out, []byte("missing")) { + t.Fatalf("missing startup error diagnostic:\n%s", out) + } + if bytes.Contains(out, []byte("panic:")) { + t.Fatalf("startup failure caused a panic:\n%s", out) + } +} + +// TestConformanceFuzz generates random pattern sets and paths, then compares +// the library against git check-ignore. It is skipped under -short. +func TestConformanceFuzz(t *testing.T) { + if testing.Short() { + t.Skip("skipping fuzz comparison in short mode") + } + requireGit(t) + isolateGitEnv(t) + + const ( + maxPatterns = 6 + pathsPerRound = 30 + ) + rounds := envInt("GITIGNORE_FUZZ_ROUNDS", 40) + seed := int64(envInt("GITIGNORE_FUZZ_SEED", 1)) + rng := rand.New(rand.NewSource(seed)) + + for r := 0; r < rounds; r++ { + patterns := randomPatterns(rng, 1+rng.Intn(maxPatterns)) + paths := randomPaths(rng, pathsPerRound) + + root := buildRepo(t, patterns, paths) + m := gitignore.New(root) + want := gitCheckIgnore(t, root, paths) + + for _, p := range paths { + got := m.Match(p.query()) + if got != want[p.rel] { + t.Errorf("round %d\npatterns:\n%s\npath %q (isDir=%v): library=%v git=%v", + r, indent(patterns), p.rel, p.isDir, got, want[p.rel]) + } + } + _ = os.RemoveAll(root) + } +} + +type conformancePath struct { + rel string + isDir bool +} + +func (p conformancePath) query() string { + if p.isDir { + return p.rel + "/" + } + return p.rel +} + +func parsePathList(s string) []conformancePath { + var out []conformancePath + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + isDir := strings.HasSuffix(line, "/") + out = append(out, conformancePath{ + rel: strings.TrimSuffix(line, "/"), + isDir: isDir, + }) + } + return out +} + +// buildRepo creates a temporary git repository containing the given +// .gitignore and materialises each path as a file or directory so that +// git check-ignore has real filesystem entries to consult. +func buildRepo(t *testing.T, patterns string, paths []conformancePath) string { + t.Helper() + root := t.TempDir() + + cmd := exec.Command("git", "-c", "init.defaultBranch=main", "init", "-q") + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(patterns), 0o644); err != nil { + t.Fatal(err) + } + + // Create directories first, deepest last is fine since MkdirAll handles + // intermediates. Create files afterwards so a file path does not get + // turned into a directory by a later entry that lives under it. Sort + // files shortest-first for the same reason in the other direction. + var files, dirs []conformancePath + for _, p := range paths { + if p.isDir { + dirs = append(dirs, p) + } else { + files = append(files, p) + } + } + sort.Slice(files, func(i, j int) bool { return len(files[i].rel) < len(files[j].rel) }) + + for _, p := range dirs { + if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(p.rel)), 0o755); err != nil { + t.Fatal(err) + } + } + for _, p := range files { + full := filepath.Join(root, filepath.FromSlash(p.rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(full); err == nil && info.IsDir() { + continue + } + if err := os.WriteFile(full, nil, 0o644); err != nil { + t.Fatal(err) + } + } + // Verify what was materialised matches what the caller declared, so + // git and the library see the same isDir for every path. A mismatch + // means the paths file lists the same name as both a file and (part + // of) a directory, which cannot be represented on disk. + for _, p := range paths { + info, err := os.Stat(filepath.Join(root, filepath.FromSlash(p.rel))) + if err != nil { + t.Fatalf("materialise %q: %v", p.rel, err) + } + if info.IsDir() != p.isDir { + t.Fatalf("path %q declared isDir=%v but is a %v on disk; fix the paths file", + p.rel, p.isDir, kind(info.IsDir())) + } + } + return root +} + +func kind(isDir bool) string { + if isDir { + return "directory" + } + return "file" +} + +// gitCheckIgnore asks git which of the given paths are ignored, in a single +// batched call. It returns a map from relative path to the ignore result. +// --no-index avoids the tracked-file exemption; -n prints unmatched paths +// too; -z uses NUL separators so patterns containing colons or tabs do not +// break parsing. +func gitCheckIgnore(t *testing.T, root string, paths []conformancePath) map[string]bool { + t.Helper() + + var stdin bytes.Buffer + for _, p := range paths { + stdin.WriteString(p.rel) + stdin.WriteByte(0) + } + + cmd := exec.Command("git", "-c", "core.excludesFile=", "check-ignore", + "--no-index", "-z", "-v", "-n", "--stdin") + cmd.Dir = root + cmd.Stdin = &stdin + out, err := cmd.Output() + if err != nil { + // check-ignore exits 1 when no path is ignored; that is not an error + // for our purposes. Any other failure is. + ee, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("git check-ignore: %v", err) + } + if ee.ExitCode() != 1 { + t.Fatalf("git check-ignore: %v\n%s", err, ee.Stderr) + } + } + + result := make(map[string]bool, len(paths)) + fields := strings.Split(strings.TrimSuffix(string(out), "\x00"), "\x00") + if len(fields) != 4*len(paths) { + t.Fatalf("git check-ignore returned %d fields for %d paths (want %d)", + len(fields), len(paths), 4*len(paths)) + } + // Output is groups of four fields: source, linenum, pattern, path. + for i := 0; i+3 < len(fields); i += 4 { + pattern := fields[i+2] + path := fields[i+3] + ignored := pattern != "" && !strings.HasPrefix(pattern, "!") + result[path] = ignored + } + if len(result) != len(paths) { + t.Fatalf("git check-ignore returned %d distinct paths for %d inputs", len(result), len(paths)) + } + return result +} + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } +} + +// isolateGitEnv points HOME, XDG_CONFIG_HOME and the git global/system config +// locations at an empty temporary directory so neither the git subprocess nor +// gitignore.New picks up the user's global excludes. +func isolateGitEnv(t *testing.T) { + t.Helper() + empty := t.TempDir() + t.Setenv("HOME", empty) + t.Setenv("USERPROFILE", empty) + t.Setenv("XDG_CONFIG_HOME", empty) + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(empty, "gitconfig")) + t.Setenv("GIT_CONFIG_SYSTEM", filepath.Join(empty, "gitconfig")) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") +} + +func envInt(name string, def int) int { + if v, err := strconv.Atoi(os.Getenv(name)); err == nil && v > 0 { + return v + } + return def +} + +func indent(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + for i, l := range lines { + lines[i] = " " + l + } + return strings.Join(lines, "\n") +} + +// randomPatterns builds a small .gitignore from a fixed vocabulary of +// segments and pattern shapes. It is deterministic for a given rng state. +func randomPatterns(rng *rand.Rand, n int) string { + segs := []string{ + "a", "b", "c", "*", "**", "*.log", "x*", "*x", + "?", "??", "a?", "?b", + "[ab]", "[!ab]", "[a-c]", "[[:lower:]]", "[a*]b", "[a?]", + "a*b", "\\*", "\\!a", "***", "*a*", + } + var b strings.Builder + for i := 0; i < n; i++ { + depth := 1 + rng.Intn(4) + parts := make([]string, depth) + for j := range parts { + parts[j] = segs[rng.Intn(len(segs))] + } + p := strings.Join(parts, "/") + // Reject patterns git refuses or that have no useful effect. + if p == "" || p == "**" || p == "/" || strings.Contains(p, "**/**") { + i-- + continue + } + if rng.Intn(4) == 0 { + p = "/" + p + } + if rng.Intn(4) == 0 { + p += "/" + } + if i > 0 && rng.Intn(3) == 0 { + p = "!" + p + } + b.WriteString(p) + b.WriteByte('\n') + } + return b.String() +} + +// randomPaths builds a set of query paths from the same segment vocabulary +// as randomPatterns so they have a reasonable chance of matching. It avoids +// producing a file path that is also a parent of another path, so that +// everything can be materialised on disk consistently. +func randomPaths(rng *rand.Rand, n int) []conformancePath { + segs := []string{"a", "b", "c", "d", "ab", "ax", "xb", "x1", "app.log", "keep", "!a"} + + asDir := make(map[string]bool) + asFile := make(map[string]bool) + seen := make(map[string]bool) + var out []conformancePath +tries: + for len(out) < n { + depth := 1 + rng.Intn(4) + parts := make([]string, depth) + for j := range parts { + parts[j] = segs[rng.Intn(len(segs))] + } + rel := strings.Join(parts, "/") + if seen[rel] { + continue + } + isDir := rng.Intn(3) == 0 + // Every proper prefix must be a directory; reject if any is + // already a file. The full path must not already be a directory + // if we picked file, or a file if we picked directory. + for i := 1; i < depth; i++ { + if asFile[strings.Join(parts[:i], "/")] { + continue tries + } + } + if isDir && asFile[rel] || !isDir && asDir[rel] { + continue + } + for i := 1; i < depth; i++ { + asDir[strings.Join(parts[:i], "/")] = true + } + if isDir { + asDir[rel] = true + } else { + asFile[rel] = true + } + seen[rel] = true + out = append(out, conformancePath{rel: rel, isDir: isDir}) + } + return out +} diff --git a/gitignore.go b/gitignore.go index 6bb0864..2b3588b 100644 --- a/gitignore.go +++ b/gitignore.go @@ -16,16 +16,16 @@ type segment struct { } type pattern struct { - segments []segment - negate bool - dirOnly bool // trailing slash pattern or trailing /** pattern - hasConcrete bool // has at least one non-** segment - anchored bool - prefix []string // directory scope segments for nested .gitignore - text string // original pattern text before compilation - source string // file path this pattern came from, empty for programmatic - line int // 1-based line number in source file - literalSuffix string // fast-reject: last segment must end with this (e.g. ".log" from "*.log") + segments []segment + negate bool + dirOnly bool // trailing slash pattern + tailDoubleStar bool // pattern ends in "/**" + anchored bool + prefix []string // directory scope segments for nested .gitignore + text string // original pattern text before compilation + source string // file path this pattern came from, empty for programmatic + line int // 1-based line number in source file + literalSuffix string // fast-reject: last segment must end with this (e.g. ".log" from "*.log") } // Matcher checks paths against gitignore rules collected from .gitignore files, @@ -350,45 +350,62 @@ func (m *Matcher) MatchDetail(relPath string) MatchResult { return m.matchDetail(relPath, isDir) } +// match reports whether relPath is ignored. Git decides ignore status +// while walking the tree and does not enter an excluded directory, so a +// path is ignored if any of its parent directories is. That is checked +// here by testing each proper prefix of the path as a directory before +// testing the full path. func (m *Matcher) match(relPath string, isDir bool) bool { pathSegs := strings.Split(relPath, "/") - lastSeg := pathSegs[len(pathSegs)-1] - - for i := len(m.patterns) - 1; i >= 0; i-- { - p := &m.patterns[i] - if p.literalSuffix != "" && !p.dirOnly && !strings.HasSuffix(lastSeg, p.literalSuffix) { - continue - } - if !matchPattern(p, pathSegs, isDir) { - continue + for end := 1; end < len(pathSegs); end++ { + if idx := m.findMatch(pathSegs[:end], true); idx >= 0 && !m.patterns[idx].negate { + return true } - return !p.negate } - return false + idx := m.findMatch(pathSegs, isDir) + return idx >= 0 && !m.patterns[idx].negate } func (m *Matcher) matchDetail(relPath string, isDir bool) MatchResult { pathSegs := strings.Split(relPath, "/") - lastSeg := pathSegs[len(pathSegs)-1] + for end := 1; end < len(pathSegs); end++ { + if idx := m.findMatch(pathSegs[:end], true); idx >= 0 && !m.patterns[idx].negate { + return m.resultFor(idx) + } + } + if idx := m.findMatch(pathSegs, isDir); idx >= 0 { + return m.resultFor(idx) + } + return MatchResult{} +} +// findMatch returns the index of the last pattern that matches pathSegs, +// or -1 if none does. Patterns are scanned from last to first because +// gitignore uses last-match-wins ordering. +func (m *Matcher) findMatch(pathSegs []string, isDir bool) int { + lastSeg := pathSegs[len(pathSegs)-1] for i := len(m.patterns) - 1; i >= 0; i-- { p := &m.patterns[i] - if p.literalSuffix != "" && !p.dirOnly && !strings.HasSuffix(lastSeg, p.literalSuffix) { - continue - } - if !matchPattern(p, pathSegs, isDir) { + if p.literalSuffix != "" && !strings.HasSuffix(lastSeg, p.literalSuffix) { continue } - return MatchResult{ - Ignored: !p.negate, - Matched: true, - Pattern: p.text, - Source: p.source, - Line: p.line, - Negate: p.negate, + if matchPattern(p, pathSegs, isDir) { + return i } } - return MatchResult{} + return -1 +} + +func (m *Matcher) resultFor(idx int) MatchResult { + p := &m.patterns[idx] + return MatchResult{ + Ignored: !p.negate, + Matched: true, + Pattern: p.text, + Source: p.source, + Line: p.line, + Negate: p.negate, + } } // matchPattern checks whether pathSegs matches the compiled pattern, @@ -396,7 +413,9 @@ func (m *Matcher) matchDetail(relPath string, isDir bool) MatchResult { func matchPattern(p *pattern, pathSegs []string, isDir bool) bool { segs := pathSegs if n := len(p.prefix); n > 0 { - if len(segs) < n { + // Rules from a nested .gitignore apply only to entries strictly + // inside that directory, never to the directory itself. + if len(segs) <= n { return false } for i, ps := range p.prefix { @@ -406,34 +425,10 @@ func matchPattern(p *pattern, pathSegs []string, isDir bool) bool { } segs = segs[n:] } - - if p.dirOnly { - // Dir-only patterns (trailing slash): match the directory itself, - // or match descendants (files/dirs under the matched directory). - if matchSegments(p.segments, segs) { - // A non-dir path may still be under a matched directory, so let - // exclusions fall through; negations don't inherit downwards. - if isDir || p.negate { - return isDir - } - } - // Only do descendant matching when the pattern identifies a specific - // directory (has at least one non-** segment). Pure ** patterns like - // "**/" only match directory paths directly. - if !p.hasConcrete { - return false - } - // Check if the path is a descendant of a matched directory by trying - // the pattern against every prefix of the path segments. - for end := len(segs) - 1; end >= 1; end-- { - if matchSegments(p.segments, segs[:end]) { - return true - } - } + if p.dirOnly && !isDir { return false } - - return matchSegments(p.segments, segs) + return matchSegments(p.segments, segs, p.tailDoubleStar) } func (m *Matcher) addPatterns(data []byte, dir, source string) { @@ -516,6 +511,15 @@ func compilePattern(line, dir string) (pattern, string) { } } + // A pattern ending "/**" matches everything inside the named directory + // but not the directory itself. Record that here so matchSegments can + // require the trailing ** to consume at least one path segment. Git + // treats any run of two or more asterisks as ** when it forms a whole + // segment, so "/***" and beyond count too. + if i := strings.LastIndexByte(line, '/'); i >= 0 { + p.tailDoubleStar = allStars(line[i+1:]) + } + segs, anchored := buildSegments(line, hasLeadingSlash) p.anchored = anchored @@ -523,24 +527,7 @@ 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 - for _, s := range segs { - if !s.doubleStar { - p.hasConcrete = true - break - } - } p.literalSuffix = extractLiteralSuffix(segs) return p, "" } @@ -559,7 +546,7 @@ func buildSegments(line string, hasLeadingSlash bool) ([]segment, bool) { } for _, raw := range rawSegs { - if raw == "**" { + if allStars(raw) { segs = append(segs, segment{doubleStar: true}) } else { segs = append(segs, segment{raw: raw}) @@ -576,6 +563,20 @@ func buildSegments(line string, hasLeadingSlash bool) ([]segment, bool) { return collapsed, anchored } +// allStars reports whether s consists of two or more '*' bytes and nothing +// else. Git's wildmatch treats such a segment the same as **. +func allStars(s string) bool { + if len(s) < 2 { + return false + } + for i := 0; i < len(s); i++ { + if s[i] != '*' { + return false + } + } + return true +} + // validateSegmentBrackets checks bracket expressions in all concrete segments. func validateSegmentBrackets(segs []segment) string { for _, seg := range segs { @@ -589,15 +590,6 @@ func validateSegmentBrackets(segs []segment) string { return "" } -// appendTrailingDoubleStar adds an implicit ** at the end for non-dir-only -// patterns so that matching "foo" also matches "foo/anything". -func appendTrailingDoubleStar(segs []segment, dirOnly bool) []segment { - if !dirOnly && (len(segs) == 0 || !segs[len(segs)-1].doubleStar) { - segs = append(segs, segment{doubleStar: true}) - } - return segs -} - // extractLiteralSuffix finds the literal trailing portion of the last concrete // 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 @@ -628,10 +620,12 @@ func extractLiteralSuffix(segs []segment) string { return "" } - // Bail if the suffix contains wildcards, brackets, or escapes. + // Bail if the suffix contains wildcards, brackets, or escapes. A ']' + // here means the '*' found above was inside a bracket expression and + // is literal, so the suffix boundary is wrong. for i := 0; i < len(suffix); i++ { switch suffix[i] { - case '*', '?', '[', '\\': + case '*', '?', '[', ']', '\\': return "" } } diff --git a/gitignore_test.go b/gitignore_test.go index 5b6cdca..cd224f2 100644 --- a/gitignore_test.go +++ b/gitignore_test.go @@ -745,16 +745,18 @@ func TestMatchNegateAnchored(t *testing.T) { func TestMatchDoubleStarSlash(t *testing.T) { m := setupMatcher(t, "**/\n") - // **/ matches any directory + // **/ matches any directory, and therefore any path under a + // directory, since git does not enter an excluded directory. shouldMatch := []string{ "a/", "a/b/", "deep/nested/dir/", + "a/b", // file inside excluded directory a/ + "deep/nested/dir", // file at any depth } shouldNotMatch := []string{ - "a", // file, not directory - "b", // file - "a/b", // file inside directory + "a", // top-level file + "b", // top-level file } for _, path := range shouldMatch { @@ -780,11 +782,9 @@ func TestMatchEscapedCharacters(t *testing.T) { } } -// TestMatchAgainstGitCheckIgnore verifies our implementation matches -// git check-ignore for a variety of patterns. Each subtest creates a -// real git repo, writes a .gitignore, and compares our result against -// git's actual output. -func TestMatchAgainstGitCheckIgnore(t *testing.T) { +// TestMatchTable exercises a variety of pattern shapes against fixed +// expectations. See TestConformance for direct comparison with git. +func TestMatchTable(t *testing.T) { tests := []struct { name string patterns string @@ -994,21 +994,19 @@ func TestMatchDirOnlyFrotz(t *testing.T) { } func TestMatchCannotReincludeUnderExcludedParent(t *testing.T) { - // From docs: "It is not possible to re-include a file if a parent directory - // of that file is excluded." - // Since our callers SkipDir on excluded directories, we test that the - // directory itself is excluded (the caller won't descend into it). + // gitignore(5): "It is not possible to re-include a file if a parent + // directory of that file is excluded." dir/ excludes the directory, + // so the negation on line 2 has no effect. m := setupMatcher(t, "dir/\n!dir/important.txt\n") - // The directory is still excluded if !m.Match("dir/") { t.Error("expected dir/ to be ignored") } - // The file would be re-included by the pattern, but since callers - // SkipDir on dir/, they never check this file. We verify the pattern - // semantics still work for completeness. - if m.Match("dir/important.txt") { - t.Error("negation should re-include dir/important.txt in pattern matching") + if !m.Match("dir/important.txt") { + t.Error("expected dir/important.txt to be ignored (parent dir/ is excluded)") + } + if !m.Match("dir/other.txt") { + t.Error("expected dir/other.txt to be ignored (parent dir/ is excluded)") } } @@ -1072,8 +1070,9 @@ func TestMatchStarExtension(t *testing.T) { func TestMatchDoubleStarTrailingDir(t *testing.T) { m := setupMatcher(t, "foo/**/\n") - shouldMatch := []string{"foo/", "foo/abc/", "foo/x/y/z/"} - shouldNotMatch := []string{"foo"} + // foo/**/ matches directories inside foo, not foo itself. + shouldMatch := []string{"foo/abc/", "foo/x/y/z/"} + shouldNotMatch := []string{"foo", "foo/"} for _, path := range shouldMatch { if !m.Match(path) { @@ -1106,13 +1105,15 @@ func TestMatchDoubleStarWithExtension(t *testing.T) { } func TestMatchNegationSubdirectoryFilter(t *testing.T) { + // abc excludes the directory abc, so nothing under it can be + // re-included. abc/* would be needed for the negation to take effect. m := setupMatcher(t, "abc\n!abc/b\n") if !m.Match("abc/a.js") { t.Error("expected abc/a.js to match") } - if m.Match("abc/b/b.js") { - t.Error("expected abc/b/b.js to not match") + if !m.Match("abc/b/b.js") { + t.Error("expected abc/b/b.js to match (parent abc is excluded)") } } diff --git a/testdata/conformance/anchored-and-wildcard/gitignore b/testdata/conformance/anchored-and-wildcard/gitignore new file mode 100644 index 0000000..636205a --- /dev/null +++ b/testdata/conformance/anchored-and-wildcard/gitignore @@ -0,0 +1,3 @@ +/root-only +unanchored +foo/* diff --git a/testdata/conformance/anchored-and-wildcard/paths b/testdata/conformance/anchored-and-wildcard/paths new file mode 100644 index 0000000..d06761a --- /dev/null +++ b/testdata/conformance/anchored-and-wildcard/paths @@ -0,0 +1,8 @@ +root-only +sub/root-only +unanchored +sub/unanchored +foo/ +foo/test.json +foo/bar/ +foo/bar/hello.c diff --git a/testdata/conformance/bracket-with-star/gitignore b/testdata/conformance/bracket-with-star/gitignore new file mode 100644 index 0000000..7d1c722 --- /dev/null +++ b/testdata/conformance/bracket-with-star/gitignore @@ -0,0 +1,2 @@ +[a*]b +x[c*d]y diff --git a/testdata/conformance/bracket-with-star/paths b/testdata/conformance/bracket-with-star/paths new file mode 100644 index 0000000..a2098ba --- /dev/null +++ b/testdata/conformance/bracket-with-star/paths @@ -0,0 +1,6 @@ +ab +zb +xcy +xdy +xzy +xccdy diff --git a/testdata/conformance/dir-only-basics/gitignore b/testdata/conformance/dir-only-basics/gitignore new file mode 100644 index 0000000..b99f777 --- /dev/null +++ b/testdata/conformance/dir-only-basics/gitignore @@ -0,0 +1,2 @@ +build/ +*.egg-info/ diff --git a/testdata/conformance/dir-only-basics/paths b/testdata/conformance/dir-only-basics/paths new file mode 100644 index 0000000..8675090 --- /dev/null +++ b/testdata/conformance/dir-only-basics/paths @@ -0,0 +1,8 @@ +buildfile +build/ +build/output.js +sub/build/ +sub/build/x +mypkg.egg-info/ +mypkg.egg-info/PKG-INFO +mypkg.egg-info-backup diff --git a/testdata/conformance/doublestar-not-parent-dironly/gitignore b/testdata/conformance/doublestar-not-parent-dironly/gitignore new file mode 100644 index 0000000..90c35c9 --- /dev/null +++ b/testdata/conformance/doublestar-not-parent-dironly/gitignore @@ -0,0 +1 @@ +data/**/ diff --git a/testdata/conformance/doublestar-not-parent-dironly/paths b/testdata/conformance/doublestar-not-parent-dironly/paths new file mode 100644 index 0000000..a83adcc --- /dev/null +++ b/testdata/conformance/doublestar-not-parent-dironly/paths @@ -0,0 +1,4 @@ +data/ +data/x +data/sub/ +data/sub/y diff --git a/testdata/conformance/doublestar-not-parent/gitignore b/testdata/conformance/doublestar-not-parent/gitignore new file mode 100644 index 0000000..bae06e6 --- /dev/null +++ b/testdata/conformance/doublestar-not-parent/gitignore @@ -0,0 +1 @@ +data/** diff --git a/testdata/conformance/doublestar-not-parent/paths b/testdata/conformance/doublestar-not-parent/paths new file mode 100644 index 0000000..c8cfed7 --- /dev/null +++ b/testdata/conformance/doublestar-not-parent/paths @@ -0,0 +1,5 @@ +data/ +data/x +data/sub/ +data/sub/y +other diff --git a/testdata/conformance/doublestar-slash-only/gitignore b/testdata/conformance/doublestar-slash-only/gitignore new file mode 100644 index 0000000..530234e --- /dev/null +++ b/testdata/conformance/doublestar-slash-only/gitignore @@ -0,0 +1 @@ +**/ diff --git a/testdata/conformance/doublestar-slash-only/paths b/testdata/conformance/doublestar-slash-only/paths new file mode 100644 index 0000000..a4ab79b --- /dev/null +++ b/testdata/conformance/doublestar-slash-only/paths @@ -0,0 +1,7 @@ +topfile +a/ +a/b +sub/ +sub/nested/ +deep/nested/dir/ +deep/nested/file diff --git a/testdata/conformance/exclusion-then-negation-same-dir/gitignore b/testdata/conformance/exclusion-then-negation-same-dir/gitignore new file mode 100644 index 0000000..250f331 --- /dev/null +++ b/testdata/conformance/exclusion-then-negation-same-dir/gitignore @@ -0,0 +1,2 @@ +a/ +!a/ diff --git a/testdata/conformance/exclusion-then-negation-same-dir/paths b/testdata/conformance/exclusion-then-negation-same-dir/paths new file mode 100644 index 0000000..9f608e3 --- /dev/null +++ b/testdata/conformance/exclusion-then-negation-same-dir/paths @@ -0,0 +1,4 @@ +a/ +a/file +a/sub/ +a/sub/x diff --git a/testdata/conformance/literal-suffix/gitignore b/testdata/conformance/literal-suffix/gitignore new file mode 100644 index 0000000..ec5431d --- /dev/null +++ b/testdata/conformance/literal-suffix/gitignore @@ -0,0 +1,10 @@ +vendor/ +!vendor/keep +/root-only +nested/target +**/cache +**/contents/** +question? +br[ae]cket +escaped\ name +literal] diff --git a/testdata/conformance/literal-suffix/paths b/testdata/conformance/literal-suffix/paths new file mode 100644 index 0000000..4d8fe28 --- /dev/null +++ b/testdata/conformance/literal-suffix/paths @@ -0,0 +1,20 @@ +vendor/ +vendor/keep +myvendor/ +myvendor/keep +root-only +sub/root-only +nested/target +nested/mytarget +other/target +a/cache/ +a/cache/file +a/mycache +a/contents/ +a/contents/file +question1 +question +bracket +brecket +escaped name +literal] diff --git a/testdata/conformance/multi-star-segment/gitignore b/testdata/conformance/multi-star-segment/gitignore new file mode 100644 index 0000000..53f6bd6 --- /dev/null +++ b/testdata/conformance/multi-star-segment/gitignore @@ -0,0 +1,3 @@ +***/target +src/**** +mid/***/end diff --git a/testdata/conformance/multi-star-segment/paths b/testdata/conformance/multi-star-segment/paths new file mode 100644 index 0000000..246a9cc --- /dev/null +++ b/testdata/conformance/multi-star-segment/paths @@ -0,0 +1,11 @@ +target +a/target +a/b/target +src/ +src/f +src/x/ +src/x/y +mid/end +mid/a/end +mid/a/b/end +other diff --git a/testdata/conformance/negation-not-inherited-dironly/gitignore b/testdata/conformance/negation-not-inherited-dironly/gitignore new file mode 100644 index 0000000..2f18d06 --- /dev/null +++ b/testdata/conformance/negation-not-inherited-dironly/gitignore @@ -0,0 +1,2 @@ +a/** +!a/b/ diff --git a/testdata/conformance/negation-not-inherited-dironly/paths b/testdata/conformance/negation-not-inherited-dironly/paths new file mode 100644 index 0000000..531379d --- /dev/null +++ b/testdata/conformance/negation-not-inherited-dironly/paths @@ -0,0 +1,4 @@ +a/ +a/b/ +a/b/keep +a/c diff --git a/testdata/conformance/negation-not-inherited/gitignore b/testdata/conformance/negation-not-inherited/gitignore new file mode 100644 index 0000000..cf25cfa --- /dev/null +++ b/testdata/conformance/negation-not-inherited/gitignore @@ -0,0 +1,2 @@ +a/** +!a/b diff --git a/testdata/conformance/negation-not-inherited/paths b/testdata/conformance/negation-not-inherited/paths new file mode 100644 index 0000000..e35932e --- /dev/null +++ b/testdata/conformance/negation-not-inherited/paths @@ -0,0 +1,7 @@ +a/ +a/b/ +a/b/keep +a/c +a/d/ +a/b/sub/ +a/b/sub/x diff --git a/testdata/conformance/negation-reincluded-dir-contents/gitignore b/testdata/conformance/negation-reincluded-dir-contents/gitignore new file mode 100644 index 0000000..3df8ea9 --- /dev/null +++ b/testdata/conformance/negation-reincluded-dir-contents/gitignore @@ -0,0 +1,2 @@ +.vscode/* +!.vscode/settings.json diff --git a/testdata/conformance/negation-reincluded-dir-contents/paths b/testdata/conformance/negation-reincluded-dir-contents/paths new file mode 100644 index 0000000..90f5bfc --- /dev/null +++ b/testdata/conformance/negation-reincluded-dir-contents/paths @@ -0,0 +1,5 @@ +# settings.json is a directory here, not a file +.vscode/ +.vscode/settings.json/ +.vscode/settings.json/keep +.vscode/other diff --git a/testdata/conformance/negation-under-excluded-dir-slash/gitignore b/testdata/conformance/negation-under-excluded-dir-slash/gitignore new file mode 100644 index 0000000..452b7d6 --- /dev/null +++ b/testdata/conformance/negation-under-excluded-dir-slash/gitignore @@ -0,0 +1,2 @@ +dir/ +!dir/important.txt diff --git a/testdata/conformance/negation-under-excluded-dir-slash/paths b/testdata/conformance/negation-under-excluded-dir-slash/paths new file mode 100644 index 0000000..037144c --- /dev/null +++ b/testdata/conformance/negation-under-excluded-dir-slash/paths @@ -0,0 +1,5 @@ +dir/ +dir/important.txt +dir/other.txt +dir/sub/ +dir/sub/x diff --git a/testdata/conformance/negation-under-excluded-dir/gitignore b/testdata/conformance/negation-under-excluded-dir/gitignore new file mode 100644 index 0000000..76dec5f --- /dev/null +++ b/testdata/conformance/negation-under-excluded-dir/gitignore @@ -0,0 +1,2 @@ +.vscode +!.vscode/extensions.json diff --git a/testdata/conformance/negation-under-excluded-dir/paths b/testdata/conformance/negation-under-excluded-dir/paths new file mode 100644 index 0000000..0b8d8f1 --- /dev/null +++ b/testdata/conformance/negation-under-excluded-dir/paths @@ -0,0 +1,6 @@ +.vscode/ +.vscode/extensions.json +.vscode/settings.json +.vscode/sub/ +.vscode/sub/x +other diff --git a/testdata/conformance/nested-doublestar-both-ends/gitignore b/testdata/conformance/nested-doublestar-both-ends/gitignore new file mode 100644 index 0000000..6441311 --- /dev/null +++ b/testdata/conformance/nested-doublestar-both-ends/gitignore @@ -0,0 +1 @@ +**/foo/** diff --git a/testdata/conformance/nested-doublestar-both-ends/paths b/testdata/conformance/nested-doublestar-both-ends/paths new file mode 100644 index 0000000..b07dab4 --- /dev/null +++ b/testdata/conformance/nested-doublestar-both-ends/paths @@ -0,0 +1,7 @@ +foo/ +foo/foo/ +foo/foo/x +foo/bar +bar/foo/ +bar/foo/y +plain diff --git a/testdata/conformance/real-world-generated/gitignore b/testdata/conformance/real-world-generated/gitignore new file mode 100644 index 0000000..8f7081d --- /dev/null +++ b/testdata/conformance/real-world-generated/gitignore @@ -0,0 +1,2 @@ +**/*/generated/**/* +!**/*/generated/keepdir diff --git a/testdata/conformance/real-world-generated/paths b/testdata/conformance/real-world-generated/paths new file mode 100644 index 0000000..d173901 --- /dev/null +++ b/testdata/conformance/real-world-generated/paths @@ -0,0 +1,6 @@ +sample/generated/ +sample/generated/keepdir/ +sample/generated/keepdir/file +sample/generated/otherfile +sample/generated/otherdir/ +sample/generated/otherdir/x diff --git a/testdata/conformance/real-world-node/gitignore b/testdata/conformance/real-world-node/gitignore new file mode 100644 index 0000000..7749844 --- /dev/null +++ b/testdata/conformance/real-world-node/gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.log +!yarn-error.log +dist +/coverage diff --git a/testdata/conformance/real-world-node/paths b/testdata/conformance/real-world-node/paths new file mode 100644 index 0000000..60a5355 --- /dev/null +++ b/testdata/conformance/real-world-node/paths @@ -0,0 +1,16 @@ +node_modules/ +node_modules/lodash/index.js +packages/app/node_modules/ +packages/app/node_modules/x +debug.log +yarn-error.log +sub/yarn-error.log +distfile +dist/ +dist/bundle.js +sub/dist/bundle.js +coverage/ +coverage/lcov.info +sub/coverage/ +sub/coverage/x +src/index.js diff --git a/testdata/conformance/real-world-vscode/gitignore b/testdata/conformance/real-world-vscode/gitignore new file mode 100644 index 0000000..c7a48b7 --- /dev/null +++ b/testdata/conformance/real-world-vscode/gitignore @@ -0,0 +1,6 @@ +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.vsix diff --git a/testdata/conformance/real-world-vscode/paths b/testdata/conformance/real-world-vscode/paths new file mode 100644 index 0000000..aeacb11 --- /dev/null +++ b/testdata/conformance/real-world-vscode/paths @@ -0,0 +1,10 @@ +.vscode/ +.vscode/settings.json +.vscode/tasks.json +.vscode/launch.json +.vscode/extensions.json +.vscode/other.json +.vscode/sub/ +.vscode/sub/x +build.vsix +src/main.go diff --git a/testdata/conformance/vscode-star-idiom/gitignore b/testdata/conformance/vscode-star-idiom/gitignore new file mode 100644 index 0000000..6da365a --- /dev/null +++ b/testdata/conformance/vscode-star-idiom/gitignore @@ -0,0 +1,3 @@ +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json diff --git a/testdata/conformance/vscode-star-idiom/paths b/testdata/conformance/vscode-star-idiom/paths new file mode 100644 index 0000000..3507db4 --- /dev/null +++ b/testdata/conformance/vscode-star-idiom/paths @@ -0,0 +1,6 @@ +.vscode/ +.vscode/settings.json +.vscode/extensions.json +.vscode/launch.json +.vscode/asdir/ +.vscode/asdir/keep diff --git a/wildmatch.go b/wildmatch.go index a3de58f..1f56d63 100644 --- a/wildmatch.go +++ b/wildmatch.go @@ -4,9 +4,12 @@ package gitignore // "[:" and ":]", used when skipping past them during bracket parsing. const posixClassOffset = 2 -// matchSegments matches path segments against pattern segments using two-pointer -// backtracking. A doubleStar segment matches zero or more path segments. -func matchSegments(patSegs []segment, pathSegs []string) bool { +// matchSegments matches path segments against pattern segments using +// two-pointer backtracking. A doubleStar segment matches zero or more path +// segments. When tailAtLeastOne is set and the pattern ends in a doubleStar, +// that final doubleStar must match at least one path segment; this is how +// "foo/**" matches "foo/x" but not "foo" itself. +func matchSegments(patSegs []segment, pathSegs []string, tailAtLeastOne bool) bool { px, tx := 0, 0 // Backtrack point for the most recent ** we passed. starPx, starTx := -1, -1 @@ -34,13 +37,21 @@ func matchSegments(patSegs []segment, pathSegs []string) bool { return false } - // Remaining pattern segments must all be ** to match. + // Any remaining pattern segments were not entered by the main loop and + // so match zero path segments. That is fine for leading and interior ** + // but not for a trailing one when tailAtLeastOne is set. Consecutive ** + // are collapsed at compile time, so at most one segment remains here in + // the trailing case. + remaining := px for px < len(patSegs) { if !patSegs[px].doubleStar { return false } px++ } + if tailAtLeastOne && remaining < len(patSegs) { + return false + } return true }