pax_global_header00006660000000000000000000000064151722272210014513gustar00rootroot0000000000000052 comment=a332523a75759b177073c8bec7d66bc981f3afc6 golang-github-git-pkgs-gitignore-1.1.2/000077500000000000000000000000001517222722100177535ustar00rootroot00000000000000golang-github-git-pkgs-gitignore-1.1.2/.github/000077500000000000000000000000001517222722100213135ustar00rootroot00000000000000golang-github-git-pkgs-gitignore-1.1.2/.github/dependabot.yml000066400000000000000000000005151517222722100241440ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: gomod directory: / schedule: interval: weekly open-pull-requests-limit: 10 cooldown: default-days: 7 - package-ecosystem: github-actions directory: / schedule: interval: weekly open-pull-requests-limit: 5 cooldown: default-days: 7 golang-github-git-pkgs-gitignore-1.1.2/.github/workflows/000077500000000000000000000000001517222722100233505ustar00rootroot00000000000000golang-github-git-pkgs-gitignore-1.1.2/.github/workflows/ci.yml000066400000000000000000000023371517222722100244730ustar00rootroot00000000000000name: CI on: push: branches: [main] pull_request: branches: [main] permissions: {} jobs: test: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] go-version: ['1.25'] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: submodules: true persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: ${{ matrix.go-version }} cache: false - name: Build run: go build -v ./... - name: Test run: go test -v -race ./... lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: submodules: true persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: '1.25' cache: false - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 with: version: latest golang-github-git-pkgs-gitignore-1.1.2/LICENSE000066400000000000000000000020571517222722100207640ustar00rootroot00000000000000MIT License Copyright (c) 2026 Andrew Nesbitt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. golang-github-git-pkgs-gitignore-1.1.2/README.md000066400000000000000000000063131517222722100212350ustar00rootroot00000000000000# gitignore A Go library for matching paths against gitignore rules. Pattern matching uses a direct wildmatch implementation (two-pointer backtracking, same algorithm as git's `wildmatch.c`) rather than compiling patterns to regexes or delegating to `filepath.Match` - Wildmatch engine modeled on git's own `wildmatch.c`, tested against git's wildmatch test suite - Bracket expressions with ranges, negation, backslash escapes, and all 12 POSIX character classes (`[:alnum:]`, `[:alpha:]`, etc.) - Proper `**` handling (zero or more directories, only when standalone between separators) - `core.excludesfile` support with XDG fallback - Automatic nested `.gitignore` discovery via `NewFromDirectory` and `Walk` - Negation patterns with correct last-match-wins semantics - Directory-only patterns (trailing `/`) with descendant matching - Match provenance via `MatchDetail` (which pattern, file, and line number matched) - Invalid pattern surfacing via `Errors()` - Literal suffix fast-reject for common patterns like `*.log` ```go import "github.com/git-pkgs/gitignore" ``` ## Loading patterns `New` reads the user's global excludes file, `.git/info/exclude`, and the root `.gitignore`: ```go m := gitignore.New("/path/to/repo") m.Match("vendor/lib.go") // true if matched m.Match("vendor/") // trailing slash tests as directory ``` For repos with nested `.gitignore` files, `NewFromDirectory` walks the tree and loads them all, scoped to their containing directory: ```go m := gitignore.NewFromDirectory("/path/to/repo") ``` To create a matcher with only programmatic patterns (no filesystem loading), pass an empty root: ```go m := gitignore.New("") m.AddPatterns([]byte("*.tmp\n"), "") ``` You can also add patterns to any matcher manually: ```go m.AddFromFile("/path/to/repo/src/.gitignore", "src") m.AddPatterns([]byte("*.log\nbuild/\n"), "") ``` ## Matching `Match` uses the trailing-slash convention to distinguish files from directories. If you already know whether the path is a directory, `MatchPath` avoids that: ```go m.Match("vendor/") // directory m.MatchPath("vendor", true) // same thing, no trailing slash needed ``` To find out which pattern matched (useful for debugging), use `MatchDetail`: ```go r := m.MatchDetail("app.log") if r.Matched { fmt.Printf("ignored by %s (line %d of %s)\n", r.Pattern, r.Line, r.Source) } ``` ## Walking a directory tree `Walk` traverses the repo, loading `.gitignore` files as it descends and skipping ignored entries. It never descends into `.git` or ignored directories. ```go gitignore.Walk("/path/to/repo", func(path string, d fs.DirEntry) error { fmt.Println(path) return nil }) ``` ## Error handling Invalid patterns (like unknown POSIX character classes) are silently skipped during matching. To inspect them: ```go for _, err := range m.Errors() { fmt.Println(err) // includes source file, line number, and reason } ``` ## Thread safety A Matcher is safe for concurrent `Match`/`MatchPath`/`MatchDetail` calls once construction is complete. Don't call `AddPatterns` or `AddFromFile` concurrently with matching. ## Match semantics Paths should use forward slashes and be relative to the repository root. Last-match-wins, same as git. ## License MIT golang-github-git-pkgs-gitignore-1.1.2/gitignore.go000066400000000000000000000441201517222722100222720ustar00rootroot00000000000000package gitignore import ( "bufio" "bytes" "io/fs" "os" "os/exec" "path/filepath" "strings" ) type segment struct { doubleStar bool raw string // original glob text; empty if doubleStar } 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 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, // .git/info/exclude, and any additional patterns. Patterns from subdirectory // .gitignore files are scoped to paths within that directory. // // Paths passed to Match should use forward slashes. Directory paths must // have a trailing slash (e.g. "vendor/") so that directory-only patterns // (those written with a trailing slash in .gitignore) match correctly. // // A Matcher is safe for concurrent use by multiple goroutines once // construction is complete (after New, NewFromDirectory, or the last // AddPatterns/AddFromFile call). Do not call AddPatterns or AddFromFile // concurrently with Match. type Matcher struct { patterns []pattern errors []PatternError } // PatternError records a pattern that could not be compiled. type PatternError struct { Pattern string // the original pattern text Source string // file path, empty for programmatic patterns Line int // 1-based line number Message string } func (e PatternError) Error() string { if e.Source != "" { return e.Source + ":" + itoa(e.Line) + ": invalid pattern: " + e.Pattern + ": " + e.Message } return "invalid pattern: " + e.Pattern + ": " + e.Message } func itoa(n int) string { if n == 0 { return "0" } var buf [20]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } return string(buf[i:]) } // Errors returns any pattern compilation errors encountered while loading // patterns. Invalid patterns are silently skipped during matching; this // method lets callers detect and report them. func (m *Matcher) Errors() []PatternError { return m.errors } // New creates a Matcher that reads patterns from the user's global // excludes file (core.excludesfile), the repository's .git/info/exclude, // and the root .gitignore. Patterns are loaded in priority order: global // excludes first (lowest priority), then .git/info/exclude, then // .gitignore (highest priority). Last-match-wins semantics means later // patterns override earlier ones. // // The root parameter should be the repository working directory // (containing .git/). If root is empty, no filesystem patterns are // loaded and the returned Matcher is empty. Use AddPatterns or // AddFromFile to add patterns programmatically. func New(root string) *Matcher { m := &Matcher{} if root == "" { return m } // Read global excludes (lowest priority) if gef := globalExcludesFile(); gef != "" { if data, err := os.ReadFile(gef); err == nil { m.addPatterns(data, "", gef) } } // Read .git/info/exclude excludePath := filepath.Join(root, ".git", "info", "exclude") if data, err := os.ReadFile(excludePath); err == nil { m.addPatterns(data, "", excludePath) } // Read root .gitignore (highest priority) ignorePath := filepath.Join(root, ".gitignore") if data, err := os.ReadFile(ignorePath); err == nil { m.addPatterns(data, "", ignorePath) } return m } // globalExcludesFile returns the path to the user's global gitignore file. // It checks (in order): git config core.excludesfile, $XDG_CONFIG_HOME/git/ignore, // ~/.config/git/ignore. Returns empty string if none found. func globalExcludesFile() string { // Try git config first. out, err := exec.Command("git", "config", "--global", "core.excludesfile").Output() if err == nil { path := strings.TrimSpace(string(out)) if path != "" { return expandTilde(path) } } // Try XDG_CONFIG_HOME/git/ignore. if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { path := filepath.Join(xdg, "git", "ignore") if _, err := os.Stat(path); err == nil { return path } } // Fall back to ~/.config/git/ignore. home, err := os.UserHomeDir() if err != nil { return "" } path := filepath.Join(home, ".config", "git", "ignore") if _, err := os.Stat(path); err == nil { return path } return "" } // expandTilde replaces a leading ~ with the user's home directory. func expandTilde(path string) string { if !strings.HasPrefix(path, "~") { return path } home, err := os.UserHomeDir() if err != nil { return path } return filepath.Join(home, path[1:]) } // NewFromDirectory creates a Matcher by walking the directory tree rooted // at root, loading every .gitignore file found along the way. Each nested // .gitignore is scoped to its containing directory. The .git directory is // skipped. func NewFromDirectory(root string) *Matcher { m := New(root) _ = walkRecursive(root, "", m, nil) return m } // Walk walks the directory tree rooted at root, calling fn for each file // and directory that is not ignored by gitignore rules. It loads .gitignore // files as it descends, so patterns from deeper directories take effect for // their subtrees. The .git directory is always skipped. // // Paths passed to fn are relative to root and use the OS path separator. // The root directory itself is not passed to fn. func Walk(root string, fn func(path string, d fs.DirEntry) error) error { m := New(root) return walkRecursive(root, "", m, fn) } func walkRecursive(root, rel string, m *Matcher, fn func(string, fs.DirEntry) error) error { dir := root if rel != "" { dir = filepath.Join(root, rel) } // Load .gitignore for this directory before processing entries. if rel != "" { igPath := filepath.Join(dir, ".gitignore") if _, err := os.Stat(igPath); err == nil { m.AddFromFile(igPath, filepath.ToSlash(rel)) } } entries, err := os.ReadDir(dir) if err != nil { return err } for _, entry := range entries { name := entry.Name() // Always skip .git directories. if name == ".git" && entry.IsDir() { continue } entryRel := name if rel != "" { entryRel = filepath.Join(rel, name) } matchPath := filepath.ToSlash(entryRel) if entry.IsDir() { matchPath += "/" } if m.Match(matchPath) { continue } if fn != nil { if err := fn(entryRel, entry); err != nil { return err } } if entry.IsDir() { if err := walkRecursive(root, entryRel, m, fn); err != nil { return err } } } return nil } // AddPatterns parses gitignore pattern lines from data and scopes them to // the given relative directory. Pass an empty dir for root-level patterns. func (m *Matcher) AddPatterns(data []byte, dir string) { m.addPatterns(data, dir, "") } // AddFromFile reads a .gitignore file at the given absolute path and scopes // its patterns to the given relative directory. func (m *Matcher) AddFromFile(absPath, relDir string) { data, err := os.ReadFile(absPath) if err != nil { return } m.addPatterns(data, relDir, absPath) } // Match returns true if the given path should be ignored. // The path should be slash-separated and relative to the repository root. // For directories, append a trailing slash (e.g. "vendor/"). // Uses last-match-wins semantics: iterates patterns in reverse and returns // on the first match. func (m *Matcher) Match(relPath string) bool { isDir := strings.HasSuffix(relPath, "/") if isDir { relPath = relPath[:len(relPath)-1] } return m.match(relPath, isDir) } // MatchPath returns true if the given path should be ignored. // Unlike Match, it takes an explicit isDir flag instead of requiring // a trailing slash convention. The path should be slash-separated, // relative to the repository root, and should not have a trailing slash. func (m *Matcher) MatchPath(relPath string, isDir bool) bool { return m.match(relPath, isDir) } // MatchResult describes which pattern matched a path and whether // the path is ignored. type MatchResult struct { Ignored bool // true if the path should be ignored Matched bool // true if any pattern matched (false means no pattern applied) Pattern string // original pattern text (empty if no match) Source string // file the pattern came from (empty for programmatic patterns) Line int // 1-based line number in Source (0 if no match) Negate bool // true if the matching pattern was a negation (!) } // MatchDetail returns detailed information about which pattern matched // the given path. If no pattern matches, Matched is false and Ignored // is false. The path uses the same trailing-slash convention as Match. func (m *Matcher) MatchDetail(relPath string) MatchResult { isDir := strings.HasSuffix(relPath, "/") if isDir { relPath = relPath[:len(relPath)-1] } return m.matchDetail(relPath, isDir) } 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 != "" && !strings.HasSuffix(lastSeg, p.literalSuffix) { continue } if !matchPattern(p, pathSegs, isDir) { continue } return !p.negate } return false } func (m *Matcher) matchDetail(relPath string, isDir bool) MatchResult { 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 != "" && !strings.HasSuffix(lastSeg, p.literalSuffix) { continue } if !matchPattern(p, pathSegs, isDir) { continue } return MatchResult{ Ignored: !p.negate, Matched: true, Pattern: p.text, Source: p.source, Line: p.line, Negate: p.negate, } } return MatchResult{} } // matchPattern checks whether pathSegs matches the compiled pattern, // including the directory prefix scope and dirOnly handling. func matchPattern(p *pattern, pathSegs []string, isDir bool) bool { segs := pathSegs if p.prefix != "" { prefixSegs := strings.Split(p.prefix, "/") if len(segs) < len(prefixSegs) { return false } for i, ps := range prefixSegs { if segs[i] != ps { return false } } segs = segs[len(prefixSegs):] } 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) { // Exact match. For non-dir paths, the pattern requires a directory. 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 } } return false } return matchSegments(p.segments, segs) } func (m *Matcher) addPatterns(data []byte, dir, source string) { scanner := bufio.NewScanner(bytes.NewReader(data)) lineNum := 0 for scanner.Scan() { lineNum++ line := trimTrailingSpaces(scanner.Text()) if line == "" || line[0] == '#' { continue } p, errMsg := compilePattern(line, dir) if errMsg != "" { m.errors = append(m.errors, PatternError{ Pattern: line, Source: source, Line: lineNum, Message: errMsg, }) continue } p.text = line p.source = source p.line = lineNum m.patterns = append(m.patterns, p) } } // trimTrailingSpaces removes unescaped trailing spaces per gitignore spec. // Tabs are not stripped (git only strips spaces). A backslash before a space // escapes it, so "foo\ " keeps the trailing "\ ". func trimTrailingSpaces(s string) string { i := len(s) for i > 0 && s[i-1] == ' ' { if i >= 2 && s[i-2] == '\\' { // This space is escaped; stop stripping here. break } i-- } return s[:i] } // compilePattern compiles a gitignore pattern line into a pattern struct. // Returns the compiled pattern and an empty string on success, or a zero // pattern and an error message on failure. func compilePattern(line, dir string) (pattern, string) { p := pattern{prefix: dir} // Handle negation if strings.HasPrefix(line, "!") { p.negate = true line = line[1:] } // Handle escaped leading characters (after negation is stripped) if len(line) >= 2 && line[0] == '\\' && (line[1] == '#' || line[1] == '!') { line = line[1:] } if line == "" || line == "/" { return pattern{}, "empty pattern" } // Detect and strip trailing slash (directory-only pattern). if len(line) > 1 && line[len(line)-1] == '/' { p.dirOnly = true line = line[:len(line)-1] } // Detect and strip leading slash (anchoring). hasLeadingSlash := line[0] == '/' if hasLeadingSlash { line = line[1:] if line == "" { return pattern{}, "empty pattern" } } segs, anchored := buildSegments(line, hasLeadingSlash) p.anchored = anchored if msg := validateSegmentBrackets(segs); msg != "" { 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, "" } // buildSegments splits a pattern line into segments, prepends ** for unanchored // patterns, and collapses consecutive ** segments. func buildSegments(line string, hasLeadingSlash bool) ([]segment, bool) { rawSegs := strings.Split(line, "/") anchored := hasLeadingSlash || len(rawSegs) > 1 const extraStarSegments = 2 segs := make([]segment, 0, len(rawSegs)+extraStarSegments) if !anchored { segs = append(segs, segment{doubleStar: true}) } for _, raw := range rawSegs { if raw == "**" { segs = append(segs, segment{doubleStar: true}) } else { segs = append(segs, segment{raw: raw}) } } collapsed := segs[:1] for i := 1; i < len(segs); i++ { if segs[i].doubleStar && collapsed[len(collapsed)-1].doubleStar { continue } collapsed = append(collapsed, segs[i]) } return collapsed, anchored } // validateSegmentBrackets checks bracket expressions in all concrete segments. func validateSegmentBrackets(segs []segment) string { for _, seg := range segs { if seg.doubleStar { continue } if msg := validateBrackets(seg.raw); msg != "" { return msg } } 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 // 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 { 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 "" } // Find the last * in the segment. Everything after it must be literal. starIdx := strings.LastIndex(last, "*") if starIdx < 0 { return "" } suffix := last[starIdx+1:] if suffix == "" { return "" } // Bail if the suffix contains wildcards, brackets, or escapes. for i := 0; i < len(suffix); i++ { switch suffix[i] { case '*', '?', '[', '\\': return "" } } return suffix } // validateBrackets checks that all bracket expressions in a glob segment // have valid closing brackets and known POSIX class names. // Returns empty string on success, or an error message. func validateBrackets(glob string) string { for i := 0; i < len(glob); i++ { if glob[i] == '\\' && i+1 < len(glob) { i++ // skip escaped char continue } if glob[i] != '[' { continue } msg, end := validateBracketAt(glob, i) if msg != "" { return msg } if end >= 0 { i = end } } return "" } // validateBracketAt validates the bracket expression starting at glob[pos]. // Returns an error message if invalid, and the index of the closing ']' (or -1 // if the bracket has no closing ']' and should be treated as literal). func validateBracketAt(glob string, pos int) (string, int) { j := pos + 1 if j < len(glob) && (glob[j] == '!' || glob[j] == '^') { j++ } if j < len(glob) && glob[j] == ']' { j++ // ] as first char is literal } for j < len(glob) && glob[j] != ']' { if glob[j] == '\\' && j+1 < len(glob) { j += posixClassOffset continue } if glob[j] == '[' && j+1 < len(glob) && glob[j+1] == ':' { end := findPosixClassEnd(glob, j+posixClassOffset) if end >= 0 { name := glob[j+posixClassOffset : end] if !validPosixClassName(name) { return "unknown POSIX class [:" + name + ":]", -1 } j = end + posixClassOffset continue } } j++ } if j >= len(glob) { return "unclosed bracket expression", -1 } return "", j } func validPosixClassName(name string) bool { switch name { case "alnum", "alpha", "blank", "cntrl", "digit", "graph", "lower", "print", "punct", "space", "upper", "xdigit": return true } return false } golang-github-git-pkgs-gitignore-1.1.2/gitignore_bench_test.go000066400000000000000000000053561517222722100245000ustar00rootroot00000000000000package gitignore_test import ( "fmt" "os" "path/filepath" "strings" "testing" "github.com/git-pkgs/gitignore" ) func benchMatcher(b *testing.B, patterns string) *gitignore.Matcher { b.Helper() root := b.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { b.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(patterns), 0644); err != nil { b.Fatal(err) } return gitignore.New(root) } func realisticPatterns() string { var b strings.Builder // Extensions exts := []string{"log", "tmp", "bak", "swp", "swo", "o", "a", "so", "dylib", "pyc", "pyo", "class", "jar", "war", "ear", "dll", "exe", "obj", "lib", "out", "app", "DS_Store", "thumbs.db", "desktop.ini", "iml", "ipr", "iws"} for _, ext := range exts { fmt.Fprintf(&b, "*.%s\n", ext) } // Directories dirs := []string{"node_modules/", "vendor/", "build/", "dist/", "target/", ".cache/", ".tmp/", "__pycache__/", ".pytest_cache/", "coverage/", ".nyc_output/", ".next/", ".nuxt/", ".output/", ".vscode/", ".idea/", ".gradle/", ".mvn/", "bin/", "obj/"} for _, d := range dirs { b.WriteString(d) b.WriteByte('\n') } // Doublestar patterns dsPats := []string{"**/logs/**", "**/.env", "**/.env.*", "**/secret*", "**/credentials.*", "**/*.min.js", "**/*.min.css", "**/*.map"} for _, p := range dsPats { b.WriteString(p) b.WriteByte('\n') } // Negation b.WriteString("!.env.example\n") b.WriteString("!important.log\n") // Anchored b.WriteString("/Makefile.local\n") b.WriteString("/config/local.yml\n") // Bracket b.WriteString("*.[oa]\n") b.WriteString("*~\n") b.WriteString(".*.sw[a-p]\n") return b.String() } func BenchmarkCompile(b *testing.B) { patterns := realisticPatterns() root := b.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { b.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(patterns), 0644); err != nil { b.Fatal(err) } b.ResetTimer() for b.Loop() { gitignore.New(root) } } func BenchmarkMatchHit(b *testing.B) { m := benchMatcher(b, realisticPatterns()) b.ResetTimer() for b.Loop() { m.Match("src/app.log") } } func BenchmarkMatchMiss(b *testing.B) { m := benchMatcher(b, realisticPatterns()) b.ResetTimer() for b.Loop() { m.Match("src/main.go") } } func BenchmarkMatchLargePatternSet(b *testing.B) { var sb strings.Builder sb.WriteString(realisticPatterns()) for i := range 200 { fmt.Fprintf(&sb, "pattern_%d_*.txt\n", i) } m := benchMatcher(b, sb.String()) b.ResetTimer() for b.Loop() { m.Match("src/components/Button.tsx") } } func BenchmarkMatchDeepPath(b *testing.B) { m := benchMatcher(b, realisticPatterns()) b.ResetTimer() for b.Loop() { m.Match("a/b/c/d/e/f/g/file.txt") } } golang-github-git-pkgs-gitignore-1.1.2/gitignore_test.go000066400000000000000000001776421517222722100233510ustar00rootroot00000000000000package gitignore_test import ( "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "github.com/git-pkgs/gitignore" ) func setupMatcher(t *testing.T, gitignoreContent string) *gitignore.Matcher { t.Helper() return gitignore.New(setupMatcherRoot(t, gitignoreContent)) } type checkPath struct { path string isDir bool } // initGitRepo creates a temporary git repo with the given .gitignore content // and returns the root directory. func initGitRepo(t *testing.T, patterns string) string { t.Helper() root := t.TempDir() for _, args := range [][]string{ {"git", "init", "--initial-branch=main"}, {"git", "config", "user.email", "test@test.com"}, {"git", "config", "user.name", "Test"}, {"git", "config", "commit.gpgsign", "false"}, } { cmd := exec.Command(args[0], args[1:]...) cmd.Dir = root if err := cmd.Run(); err != nil { t.Fatalf("git init: %v", err) } } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(patterns), 0644); err != nil { t.Fatal(err) } return root } // createCheckPaths creates files and directories in root for each checkPath. func createCheckPaths(t *testing.T, root string, paths []checkPath) { t.Helper() for _, cp := range paths { full := filepath.Join(root, cp.path) if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { t.Fatal(err) } if cp.isDir { if err := os.MkdirAll(full, 0755); err != nil { t.Fatal(err) } } else { if err := os.WriteFile(full, []byte("x"), 0644); err != nil { t.Fatal(err) } } } } // compareWithGit compares our matcher results against git check-ignore for // each path, failing on any disagreement. func compareWithGit(t *testing.T, root string, m *gitignore.Matcher, paths []checkPath) { t.Helper() for _, cp := range paths { matchPath := cp.path if cp.isDir { matchPath += "/" } ourResult := m.Match(matchPath) cmd := exec.Command("git", "check-ignore", "-q", cp.path) cmd.Dir = root err := cmd.Run() gitResult := err == nil if ourResult != gitResult { t.Errorf("path %q: our matcher says ignored=%v, git check-ignore says ignored=%v", cp.path, ourResult, gitResult) } } } func TestMatchBasicPatterns(t *testing.T) { m := setupMatcher(t, "vendor/\n*.log\nbuild\n") tests := []struct { path string want bool }{ {"vendor/", true}, {"vendor/gem/lib.rb", true}, {"vendor", false}, // no trailing slash, dir-only pattern doesn't match {"app.log", true}, {"logs/app.log", true}, {"build", true}, {"build/", true}, {"build/output.js", true}, // "build" without trailing slash matches descendants {"src/main.go", false}, {"README.md", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchNegationPatterns(t *testing.T) { // Deny-by-default pattern: ignore everything at root, then allow specific paths m := setupMatcher(t, "/*\n!.github/\n!src/\n!README.md\n") tests := []struct { path string want bool }{ {".github/", false}, {".github/workflows/", false}, {".github/workflows/ci.yml", false}, {"src/", false}, {"src/main.go", false}, {"README.md", false}, {"vendor/", true}, {"node_modules/", true}, {"random-file.txt", true}, {".gitignore", true}, // gitignore itself is ignored by /* } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchDoubleStarPatterns(t *testing.T) { m := setupMatcher(t, "**/logs\n**/logs/**\nfoo/**/bar\n") tests := []struct { path string want bool }{ {"logs", true}, {"logs/", true}, {"deep/nested/logs", true}, {"logs/debug.log", true}, {"logs/monday/foo.bar", true}, {"foo/bar", true}, {"foo/a/bar", true}, {"foo/a/b/c/bar", true}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } // setupMatcherRoot creates a temp dir with .git/info and a root .gitignore. func setupMatcherRoot(t *testing.T, gitignoreContent string) string { t.Helper() root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(gitignoreContent), 0644); err != nil { t.Fatal(err) } return root } // setupScopedMatcher creates a matcher with a root .gitignore and a nested // .gitignore in the given subdirectory. func setupScopedMatcher(t *testing.T, rootPatterns, subDir, subPatterns string) *gitignore.Matcher { t.Helper() root := setupMatcherRoot(t, rootPatterns) if err := os.MkdirAll(filepath.Join(root, subDir), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, subDir, ".gitignore"), []byte(subPatterns), 0644); err != nil { t.Fatal(err) } m := gitignore.New(root) m.AddFromFile(filepath.Join(root, subDir, ".gitignore"), subDir) return m } func TestMatchScopedPatterns(t *testing.T) { m := setupScopedMatcher(t, "", "src", "*.generated.go\ntmp/\n") tests := []struct { path string want bool }{ {"src/foo.generated.go", true}, {"src/deep/bar.generated.go", true}, {"other/foo.generated.go", false}, // pattern scoped to src/ {"src/tmp/", true}, {"src/tmp/cache.dat", true}, {"tmp/", false}, // not under src/ } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchScopedMultipleLevels(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(root, "src", "lib"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "src", ".gitignore"), []byte("*.tmp\n"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "src", "lib", ".gitignore"), []byte("*.gen.go\n"), 0644); err != nil { t.Fatal(err) } m := gitignore.New(root) m.AddFromFile(filepath.Join(root, "src", ".gitignore"), "src") m.AddFromFile(filepath.Join(root, "src", "lib", ".gitignore"), "src/lib") tests := []struct { path string want bool }{ // Root pattern applies everywhere {"app.log", true}, {"src/app.log", true}, {"src/lib/app.log", true}, // src/ pattern scoped to src/ {"src/cache.tmp", true}, {"src/lib/cache.tmp", true}, {"cache.tmp", false}, // src/lib/ pattern scoped to src/lib/ {"src/lib/foo.gen.go", true}, {"src/foo.gen.go", false}, {"foo.gen.go", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchScopedNestedOverridesParent(t *testing.T) { m := setupScopedMatcher(t, "*.txt\n", "docs", "!*.txt\n") tests := []struct { path string want bool }{ // Root .txt exclusion still applies outside docs/ {"README.txt", true}, {"src/notes.txt", true}, // docs/ negation re-includes .txt under docs/ {"docs/guide.txt", false}, {"docs/api/ref.txt", false}, // Non-.txt files unaffected {"docs/image.png", false}, {"src/main.go", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchScopedNestedNegationWithParentExclusion(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } // Root: deny-by-default, allow src/ if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("/*\n!src/\n"), 0644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(root, "src"), 0755); err != nil { t.Fatal(err) } // src/.gitignore: ignore test fixtures but keep .go files if err := os.WriteFile(filepath.Join(root, "src", ".gitignore"), []byte("testdata/\n"), 0644); err != nil { t.Fatal(err) } m := gitignore.New(root) m.AddFromFile(filepath.Join(root, "src", ".gitignore"), "src") tests := []struct { path string want bool }{ // Root deny-by-default {"vendor/", true}, {"random.txt", true}, // src/ is re-included {"src/", false}, {"src/main.go", false}, // src/testdata/ is excluded by nested .gitignore {"src/testdata/", true}, {"src/testdata/fixture.json", true}, // testdata/ outside src/ is not affected by nested pattern // (but IS caught by root /*) {"testdata/", true}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchScopedSiblingDirectories(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(""), 0644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(root, "frontend"), 0755); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(root, "backend"), 0755); err != nil { t.Fatal(err) } // frontend ignores node_modules and dist if err := os.WriteFile(filepath.Join(root, "frontend", ".gitignore"), []byte("node_modules/\ndist/\n"), 0644); err != nil { t.Fatal(err) } // backend ignores __pycache__ and *.pyc if err := os.WriteFile(filepath.Join(root, "backend", ".gitignore"), []byte("__pycache__/\n*.pyc\n"), 0644); err != nil { t.Fatal(err) } m := gitignore.New(root) m.AddFromFile(filepath.Join(root, "frontend", ".gitignore"), "frontend") m.AddFromFile(filepath.Join(root, "backend", ".gitignore"), "backend") tests := []struct { path string want bool }{ // frontend patterns only under frontend/ {"frontend/node_modules/", true}, {"frontend/node_modules/react/index.js", true}, {"frontend/dist/", true}, {"frontend/src/app.js", false}, // backend patterns only under backend/ {"backend/__pycache__/", true}, {"backend/app.pyc", true}, {"backend/app.py", false}, // No cross-contamination {"backend/node_modules/", false}, {"backend/dist/", false}, {"frontend/__pycache__/", false}, {"frontend/app.pyc", false}, // Root level unaffected {"node_modules/", false}, {"__pycache__/", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchExcludeFile(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".git", "info", "exclude"), []byte("secret.key\n.env\n"), 0644); err != nil { t.Fatal(err) } m := gitignore.New(root) tests := []struct { path string want bool }{ {"secret.key", true}, {".env", true}, {"src/secret.key", true}, {"README.md", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchLeadingSlashAnchoring(t *testing.T) { m := setupMatcher(t, "/build\n/dist/\n") tests := []struct { path string want bool }{ {"build", true}, {"build/", true}, {"src/build", false}, // anchored to root {"dist/", true}, {"dist/bundle.js", true}, {"src/dist/", false}, // anchored to root } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchLastPatternWins(t *testing.T) { // Ignore all .txt files, but then re-include important.txt m := setupMatcher(t, "*.txt\n!important.txt\n") tests := []struct { path string want bool }{ {"notes.txt", true}, {"important.txt", false}, {"docs/notes.txt", true}, {"docs/important.txt", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchBracketExpressions(t *testing.T) { m := setupMatcher(t, ".*.sw[a-z]\n/b[!a]r/\n") tests := []struct { path string want bool }{ {".foo.swp", true}, {"src/.bar.swa", true}, {".foo.sw1", false}, {"bbr/", true}, {"bcr/", true}, {"bar/", false}, // [!a] excludes 'a' } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchPlainPath(t *testing.T) { m := setupMatcher(t, "abcdef\n") shouldMatch := []string{ "abcdef", "subdir/abcdef", "abcdef/", "subdir/abcdef/", } shouldNotMatch := []string{ "someotherfile", } 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 TestMatchRootPath(t *testing.T) { m := setupMatcher(t, "/abcdef\n") shouldMatch := []string{ "abcdef", "abcdef/", } shouldNotMatch := []string{ "subdir/abcdef", "subdir/abcdef/", } 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 TestMatchDirectoryOnlyPattern(t *testing.T) { m := setupMatcher(t, "abcdef/\n") shouldMatch := []string{ "abcdef/", "subdir/abcdef/", } shouldNotMatch := []string{ "abcdef", "subdir/abcdef", } 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 TestMatchInnerDoubleAsterisk(t *testing.T) { m := setupMatcher(t, "abc/**/def\n") shouldMatch := []string{ "abc/x/def", "abc/def", "abc/x/y/z/def", } shouldNotMatch := []string{ "a/b/def", "abc", "def", } 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 TestMatchWildcardStar(t *testing.T) { m := setupMatcher(t, "*.txt\na/*\n") shouldMatch := []string{ "file.txt", "CMakeLists.txt", "a/b", "a/c", } shouldNotMatch := []string{ "file.gif", "filetxt", } 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 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") if !m.Match("deadbeef") { t.Error("expected deadbeef to match") } if m.Match("deabeef") { t.Error("expected deabeef to not match") } } func TestMatchMultiSegmentAnchored(t *testing.T) { // A pattern with a slash (other than trailing) but no leading slash // is still anchored to the gitignore's directory m := setupMatcher(t, "subdir/zoo\n") if !m.Match("subdir/zoo") { t.Error("expected subdir/zoo to match") } if m.Match("other/subdir/zoo") { t.Error("expected other/subdir/zoo to not match") } } func TestMatchNegateAnchored(t *testing.T) { m := setupMatcher(t, "deadbeef\n!/x/deadbeef\n") if !m.Match("deadbeef") { t.Error("expected deadbeef to match") } if m.Match("x/deadbeef") { t.Error("expected x/deadbeef to not match (negated)") } } func TestMatchDoubleStarSlash(t *testing.T) { m := setupMatcher(t, "**/\n") // **/ matches any directory shouldMatch := []string{ "a/", "a/b/", "deep/nested/dir/", } shouldNotMatch := []string{ "a", // file, not directory "b", // file "a/b", // file inside directory } 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 TestMatchEscapedCharacters(t *testing.T) { m := setupMatcher(t, "\\!important\n\\#comment\n") if !m.Match("!important") { t.Error("expected !important to match") } if !m.Match("#comment") { t.Error("expected #comment to match") } } // 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) { tests := []struct { name string patterns string paths []string // paths to check wantDir []bool // expected result when path is a directory wantFile []bool // expected result when path is a file }{ { name: "simple wildcard", patterns: "*.log\n", paths: []string{"app.log", "debug.log", "app.txt", "dir/app.log"}, wantFile: []bool{true, true, false, true}, }, { name: "deny-by-default with negation", patterns: "/*\n!src/\n!README.md\n", paths: []string{"random.txt", "src", "README.md", "other"}, wantFile: []bool{true, true, false, true}, // src as file stays ignored (!src/ is dir-only) wantDir: []bool{true, false, false, true}, // src as dir is re-included by !src/ }, { name: "anchored vs unanchored", patterns: "/root-only\nunanchored\n", paths: []string{"root-only", "sub/root-only", "unanchored", "sub/unanchored"}, wantFile: []bool{true, false, true, true}, }, { name: "directory only trailing slash", patterns: "build/\n", paths: []string{"build", "sub/build"}, wantFile: []bool{false, false}, wantDir: []bool{true, true}, }, { name: "double star patterns", patterns: "**/logs\nlogs/**\nfoo/**/bar\n", paths: []string{"logs", "a/logs", "logs/x", "foo/bar", "foo/a/b/bar"}, wantFile: []bool{true, true, true, true, true}, }, { name: "negation override", patterns: "*.txt\n!important.txt\n", paths: []string{"notes.txt", "important.txt", "sub/notes.txt", "sub/important.txt"}, wantFile: []bool{true, false, true, false}, }, { name: "multi-segment anchored", patterns: "foo/bar\n", paths: []string{"foo/bar", "x/foo/bar"}, wantFile: []bool{true, false}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := setupMatcher(t, tt.patterns) for i, path := range tt.paths { if tt.wantFile != nil { got := m.Match(path) if got != tt.wantFile[i] { t.Errorf("Match(%q) [file] = %v, want %v", path, got, tt.wantFile[i]) } } if tt.wantDir != nil { got := m.Match(path + "/") if got != tt.wantDir[i] { t.Errorf("Match(%q) [dir] = %v, want %v", path+"/", got, tt.wantDir[i]) } } } }) } } // TestMatchVsGitCheckIgnore runs our matcher against the real git check-ignore // command to verify correctness. Each case creates a git repo, writes a .gitignore, // and compares our result with git's. func TestMatchVsGitCheckIgnore(t *testing.T) { tests := []struct { name string patterns string paths []checkPath }{ { name: "deny-by-default with negation", patterns: "/*\n!.github/\n!src/\n!README.md\n", paths: []checkPath{ {"README.md", false}, {"random.txt", false}, {".github/workflows/ci.yml", false}, {"src/main.go", false}, {"vendor/lib.go", false}, {".gitignore", false}, }, }, { name: "mixed patterns", patterns: "*.log\n!important.log\nbuild/\n/dist\nfoo/**/bar\n", paths: []checkPath{ {"app.log", false}, {"important.log", false}, {"sub/debug.log", false}, {"build/output.js", false}, {"dist/bundle.js", false}, {"src/dist/x.js", false}, {"foo/baz/bar", false}, {"foo/bar", false}, {"main.go", false}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := initGitRepo(t, tt.patterns) createCheckPaths(t, root, tt.paths) m := gitignore.New(root) compareWithGit(t, root, m, tt.paths) }) } } // Tests from git docs examples func TestMatchMiddleSlashAnchors(t *testing.T) { // From docs: "doc/frotz" and "/doc/frotz" have the same effect m1 := setupMatcher(t, "doc/frotz\n") m2 := setupMatcher(t, "/doc/frotz\n") paths := []struct { path string want bool }{ {"doc/frotz", true}, {"doc/frotz/", true}, {"a/doc/frotz", false}, // anchored, not matched in subdirs } for _, tt := range paths { if got := m1.Match(tt.path); got != tt.want { t.Errorf("doc/frotz: Match(%q) = %v, want %v", tt.path, got, tt.want) } if got := m2.Match(tt.path); got != tt.want { t.Errorf("/doc/frotz: Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchWildcardDoesNotCrossSlash(t *testing.T) { // From docs: "foo/*" matches "foo/test.json" but not "foo/bar/hello.c" m := setupMatcher(t, "foo/*\n") if !m.Match("foo/test.json") { t.Error("expected foo/test.json to match") } if !m.Match("foo/bar") { t.Error("expected foo/bar to match") } // foo/bar/hello.c: the * in foo/* should NOT match "bar/hello.c" // But foo/bar is matched by the pattern (bar matches *), and since // non-dir-only patterns match descendants, foo/bar/hello.c is matched too. // This aligns with git behavior where foo/* ignoring foo/bar as a directory // means its contents are also ignored. } func TestMatchDirOnlyFrotz(t *testing.T) { // From docs: "doc/frotz/" matches "doc/frotz" directory, but not "a/doc/frotz" directory // And "frotz/" matches "frotz" and "a/frotz" (any level) m1 := setupMatcher(t, "doc/frotz/\n") m2 := setupMatcher(t, "frotz/\n") if !m1.Match("doc/frotz/") { t.Error("expected doc/frotz/ to match doc/frotz/") } if m1.Match("a/doc/frotz/") { t.Error("expected a/doc/frotz/ to NOT match doc/frotz/") } if !m2.Match("frotz/") { t.Error("expected frotz/ to match frotz/") } if !m2.Match("a/frotz/") { t.Error("expected a/frotz/ to match frotz/") } } 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). 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") } } // Tests below are adapted from sabhiram/go-gitignore (MIT license). // https://github.com/sabhiram/go-gitignore func TestMatchDotFile(t *testing.T) { m := setupMatcher(t, ".d\n") shouldMatch := []string{".d", "d/.d", ".d/", ".d/a"} shouldNotMatch := []string{".dd", "d.d", "d/d.d", "d/e"} 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 TestMatchDotDir(t *testing.T) { m := setupMatcher(t, ".e\n") shouldMatch := []string{".e/", ".e/e", "e/.e"} shouldNotMatch := []string{".ee/", "e.e/", "e/e.e", "e/f"} 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 TestMatchStarExtension(t *testing.T) { m := setupMatcher(t, ".js*\n") shouldMatch := []string{".js", ".jsa", ".js/", ".js/a"} shouldNotMatch := []string{"a.js", "a.js/a"} 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 TestMatchDoubleStarTrailingDir(t *testing.T) { m := setupMatcher(t, "foo/**/\n") shouldMatch := []string{"foo/", "foo/abc/", "foo/x/y/z/"} shouldNotMatch := []string{"foo"} 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 TestMatchDoubleStarWithExtension(t *testing.T) { m := setupMatcher(t, "foo/**/*.bar\n") shouldMatch := []string{"foo/abc.bar", "foo/abc.bar/", "foo/x/y/z.bar", "foo/x/y/z.bar/"} shouldNotMatch := []string{"foo/", "abc.bar"} 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 TestMatchNegationSubdirectoryFilter(t *testing.T) { 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") } } func TestMatchNodeModulesDeepNesting(t *testing.T) { m := setupMatcher(t, "node_modules/\n") if !m.Match("node_modules/gulp/node_modules/abc.md") { t.Error("expected deeply nested node_modules path to match") } if !m.Match("node_modules/gulp/node_modules/abc.json") { t.Error("expected deeply nested node_modules path to match") } } func TestMatchDirEndedWithStar(t *testing.T) { m := setupMatcher(t, "abc/*\n") if m.Match("abc") { t.Error("expected bare abc to not match abc/*") } if !m.Match("abc/x") { t.Error("expected abc/x to match abc/*") } } func TestMatchDenyByDefaultGitDocsExample(t *testing.T) { // From git docs: "exclude everything except directory foo/bar" m := setupMatcher(t, "/*\n!/foo\n/foo/*\n!/foo/bar\n") if m.Match("foo") { t.Error("expected foo to not match (re-included by !/foo)") } if m.Match("foo/bar") { t.Error("expected foo/bar to not match (re-included by !/foo/bar)") } if !m.Match("a") { t.Error("expected a to match (caught by /*)") } if !m.Match("foo/baz") { t.Error("expected foo/baz to match (caught by /foo/*)") } } func TestMatchFileEndedWithStar(t *testing.T) { m := setupMatcher(t, "abc.js*\n") shouldMatch := []string{"abc.js", "abc.js/", "abc.js/abc", "abc.jsa", "abc.jsa/", "abc.jsa/abc"} for _, path := range shouldMatch { if !m.Match(path) { t.Errorf("expected %q to match", path) } } } func TestMatchWildcardAsFilename(t *testing.T) { m := setupMatcher(t, "*.b\n") shouldMatch := []string{"b/a.b", "b/.b", "b/c/a.b"} shouldNotMatch := []string{"b/.ba"} 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 TestMatchNestedDoubleStarDotFiles(t *testing.T) { m := setupMatcher(t, "**/external/**/*.md\n**/external/**/*.json\n**/external/**/.*ignore\n") if !m.Match("external/foobar/.gitignore") { t.Error("expected external/foobar/.gitignore to match") } if !m.Match("external/barfoo/.bower.json") { t.Error("expected external/barfoo/.bower.json to match") } } // Spec edge cases func TestMatchEscapedWildcards(t *testing.T) { // \* matches a literal *, \? matches a literal ? m := setupMatcher(t, "hello\\*\nhello\\?\n") tests := []struct { path string want bool }{ {"hello*", true}, // literal * {"hello?", true}, // literal ? {"dir/hello*", true}, // unanchored, matches in subdirs {"helloX", false}, // \* is not a wildcard {"helloworld", false}, // \* is not a wildcard {"dir/helloworld", false}, // still not a wildcard in subdirs } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchEscapedTrailingSpace(t *testing.T) { // A trailing space escaped with \ should be preserved as part of the pattern. // "hello\ " matches the filename "hello " (with a space). m := setupMatcher(t, "hello\\ \n") tests := []struct { path string want bool }{ {"hello ", true}, // literal trailing space {"hello", false}, // no space {"helloX", false}, // different char {"dir/hello ", true}, // unanchored } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchTripleStar(t *testing.T) { // Per spec: "Other consecutive asterisks are considered invalid." // In practice git treats *** like * within a segment (no special ** meaning). // Since the pattern has no slash, it matches basenames at any level. m := setupMatcher(t, "***foo\n") tests := []struct { path string want bool }{ {"foo", true}, {"afoo", true}, {"a/b/c/xfoo", true}, {"bar", false}, {"foobar", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchBracketWithClosingBracketFirst(t *testing.T) { // Per POSIX, ] as the first character in a bracket class is treated as // a literal member of the class. So []abc] matches ], a, b, or c. m := setupMatcher(t, "[]abc]\n") tests := []struct { path string want bool }{ {"]", true}, {"a", true}, {"b", true}, {"c", true}, {"d", false}, {"[", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchPOSIXCharacterClasses(t *testing.T) { // POSIX character classes like [[:space:]], [[:alpha:]] are valid in // gitignore patterns (git's wildmatch supports them). // Test cases adapted from git/t/t3070-wildmatch.sh tests := []struct { pattern string path string want bool }{ // Basic classes {"foo[[:space:]]bar", "foo bar", true}, {"foo[[:space:]]bar", "foo\tbar", true}, {"foo[[:space:]]bar", "fooXbar", false}, {"[[:digit:]]*.log", "3debug.log", true}, {"[[:digit:]]*.log", "0.log", true}, {"[[:digit:]]*.log", "debug.log", false}, // Multiple character classes from wildmatch suite {"[[:alpha:]][[:digit:]][[:upper:]]", "a1B", true}, {"[[:digit:][:upper:][:space:]]", "A", true}, {"[[:digit:][:upper:][:space:]]", "1", true}, {"[[:digit:][:upper:][:space:]]", " ", true}, {"[[:digit:][:upper:][:space:]]", "a", false}, {"[[:digit:][:upper:][:space:]]", ".", false}, {"[[:digit:][:punct:][:space:]]", ".", true}, {"[[:xdigit:]]", "5", true}, {"[[:xdigit:]]", "f", true}, {"[[:xdigit:]]", "D", true}, // Mixing ranges and POSIX classes {"[a-c[:digit:]x-z]", "5", true}, {"[a-c[:digit:]x-z]", "b", true}, {"[a-c[:digit:]x-z]", "y", true}, {"[a-c[:digit:]x-z]", "q", false}, } for _, tt := range tests { m := setupMatcher(t, tt.pattern+"\n") got := m.Match(tt.path) if got != tt.want { t.Errorf("pattern %q, Match(%q) = %v, want %v", tt.pattern, tt.path, got, tt.want) } } } func TestMatchBracketRange(t *testing.T) { m := setupMatcher(t, "file[0-9].txt\n") tests := []struct { path string want bool }{ {"file0.txt", true}, {"file5.txt", true}, {"file9.txt", true}, {"filea.txt", false}, {"file10.txt", false}, {"dir/file3.txt", true}, // unanchored } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchBracketNegationCaret(t *testing.T) { // Both ! and ^ should work as negation inside brackets m := setupMatcher(t, "file[^0-9].txt\n") tests := []struct { path string want bool }{ {"filea.txt", true}, {"fileZ.txt", true}, {"file0.txt", false}, {"file9.txt", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchUnclosedBracket(t *testing.T) { // 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", false}, {"filea.txt", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { 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) { // Unescaped trailing spaces should be stripped from patterns m := setupMatcher(t, "hello \n") tests := []struct { path string want bool }{ {"hello", true}, // trailing spaces stripped, matches "hello" {"hello ", false}, // the pattern is "hello", not "hello " {"hello ", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchTrailingSpacesEdgeCases(t *testing.T) { tests := []struct { name string pattern string path string want bool }{ // Spaces before an escaped space: "foo \ " → pattern is "foo \ " {"spaces before escaped space", "foo \\ ", "foo ", true}, {"spaces before escaped space no match", "foo \\ ", "foo", false}, // Multiple escaped spaces: "hello\ \ " → pattern is "hello\ \ " {"multiple escaped spaces", "hello\\ \\ ", "hello ", true}, {"multiple escaped spaces no match short", "hello\\ \\ ", "hello ", false}, // Trailing tabs preserved (git only strips spaces, not tabs) {"trailing tab preserved", "hello\t", "hello\t", true}, {"trailing tab not stripped", "hello\t", "hello", false}, // Leading spaces preserved {"leading spaces preserved", " hello", " hello", true}, {"leading spaces preserved no match", " hello", "hello", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := setupMatcher(t, tt.pattern+"\n") got := m.Match(tt.path) if got != tt.want { t.Errorf("pattern %q, Match(%q) = %v, want %v", tt.pattern, tt.path, got, tt.want) } }) } } func TestMatchCommentLines(t *testing.T) { m := setupMatcher(t, "# this is a comment\nfoo\n# another comment\nbar\n") tests := []struct { path string want bool }{ {"foo", true}, {"bar", true}, {"# this is a comment", false}, {"# another comment", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchBlankLines(t *testing.T) { // Blank lines should be ignored as separators m := setupMatcher(t, "\n\nfoo\n\n\nbar\n\n") if !m.Match("foo") { t.Error("expected foo to match") } if !m.Match("bar") { t.Error("expected bar to match") } if m.Match("baz") { t.Error("expected baz to not match") } } // Verify edge cases against git check-ignore func TestMatchEdgeCasesVsGitCheckIgnore(t *testing.T) { tests := []struct { name string patterns string paths []checkPath }{ { name: "escaped wildcards", patterns: "hello\\*\nhello\\?\n", paths: []checkPath{ {"hello*", false}, {"hello?", false}, {"helloX", false}, {"helloworld", false}, }, }, { name: "bracket with closing bracket first", patterns: "[]abc]\n", paths: []checkPath{ {"]", false}, {"a", false}, {"b", false}, {"c", false}, {"d", false}, }, }, { name: "triple star", patterns: "***foo\n", paths: []checkPath{ {"foo", false}, {"afoo", false}, {"bar", false}, }, }, { name: "POSIX character classes", patterns: "[[:digit:]]*.log\n", paths: []checkPath{ {"3debug.log", false}, {"0.log", false}, {"debug.log", false}, }, }, { name: "range and negated bracket", patterns: "file[0-9].txt\nlog[^a-z].out\n", paths: []checkPath{ {"file5.txt", false}, {"filea.txt", false}, {"log1.out", false}, {"loga.out", false}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if runtime.GOOS == "windows" && tt.name == "escaped wildcards" { t.Skip("Windows does not allow * or ? in filenames") } root := initGitRepo(t, tt.patterns) createCheckPaths(t, root, tt.paths) m := gitignore.New(root) compareWithGit(t, root, m, tt.paths) }) } } // Tests below are adapted from git/t/t3070-wildmatch.sh // https://github.com/git/git/blob/8d8387116ae8c3e73f6184471f0c46edbd2c7601/t/t3070-wildmatch.sh // // The wildmatch test format is: match // We use the first column (wildmatch mode) since gitignore uses wildmatch semantics. // Cases marked 'x' in the original are implementation-defined; we include them where // our behavior is well-defined. // // Some wildmatch tests don't map directly to gitignore because gitignore adds // directory-content matching (a pattern matching "foo" also matches "foo/anything") // and unanchored patterns match at any directory level. Tests are adapted accordingly. func TestWildmatchBasicGlob(t *testing.T) { tests := []struct { pattern string path string want bool }{ // Exact and simple wildcard matching {"foo", "foo", true}, {"foo", "bar", false}, {"???", "foo", true}, {"??", "foo", false}, {"*", "foo", true}, {"f*", "foo", true}, {"*f", "foo", false}, {"*foo*", "foo", true}, {"*ob*a*r*", "foobar", true}, {"*ab", "aaaaaaabababab", true}, // Escaped special characters {"foo\\*", "foo*", true}, {"foo\\*bar", "foobar", false}, {"f\\\\oo", "f\\oo", true}, // Bracket with glob operators {"*[al]?", "ball", true}, {"[ten]", "ten", false}, // [ten] matches single char t, e, or n {"t[a-g]n", "ten", true}, {"t[!a-g]n", "ten", false}, {"t[!a-g]n", "ton", true}, {"t[^a-g]n", "ton", true}, // Question mark and escape combinations {"\\??\\?b", "?a?b", true}, {"\\a\\b\\c", "abc", true}, // Literal bracket via escape {"\\[ab]", "[ab]", true}, {"[[]ab]", "[ab]", true}, // Range edge cases from wildmatch suite {"a[c-c]st", "acrt", false}, {"a[c-c]rt", "acrt", true}, // Simple patterns {"foo", "foo", true}, {"@foo", "@foo", true}, {"@foo", "foo", false}, } for _, tt := range tests { m := setupMatcher(t, tt.pattern+"\n") got := m.Match(tt.path) if got != tt.want { t.Errorf("pattern %q, Match(%q) = %v, want %v", tt.pattern, tt.path, got, tt.want) } } } func TestWildmatchBracketEdgeCases(t *testing.T) { tests := []struct { pattern string path string want bool }{ // ] as first char in bracket (literal member) {"a[]]b", "a]b", true}, {"a[]-]b", "a-b", true}, {"a[]-]b", "a]b", true}, {"a[]-]b", "aab", false}, {"a[]a-]b", "aab", true}, {"]", "]", true}, // Negation with ] edge case {"[!]-]", "]", false}, // Backslash escapes inside brackets (wildmatch: \X = literal X) {"[\\-_]", "-", true}, // \- = literal dash {"[\\-_]", "_", true}, {"[\\-_]", "a", false}, {"[\\]]", "]", true}, // \] = literal ] {"[\\\\]", "\\", true}, // \\ = literal backslash {"[!\\\\]", "\\", false}, // negated literal backslash {"[!\\\\]", "a", true}, {"[A-\\\\]", "G", true}, // range A(65) to \(92) // Range with \\ as endpoint: range \(92) to ^(94) {"[\\\\-^]", "]", true}, // ](93) is in range {"[\\\\-^]", "[", false}, // [(91) is not // Range via escaped endpoints: \1=1, \3=3, range 1-3 {"[\\1-\\3]", "2", true}, {"[\\1-\\3]", "3", true}, {"[\\1-\\3]", "4", false}, // Range from [ to ] via escaped ]: [(91) to ](93) {"[[-\\]]", "\\", true}, // \(92) in range {"[[-\\]]", "[", true}, // [(91) in range {"[[-\\]]", "]", true}, // ](93) in range {"[[-\\]]", "-", false}, // -(45) not in range // Various dash/range positions {"[-]", "-", true}, {"[,-.]", "-", true}, {"[,-.]", "+", false}, {"[,-.]", "-.]", false}, // Comma in bracket {"[,]", ",", true}, {"[\\\\,]", ",", true}, // \\=literal backslash, comma=literal {"[\\\\,]", "\\", true}, {"[\\,]", ",", true}, // \,=literal comma // Caret as literal in bracket (not at start) {"[a^bc]", "^", true}, // Space range {"[ --]", " ", true}, {"[ --]", "$", true}, {"[ --]", "-", true}, {"[ --]", "0", false}, // Multiple dashes {"[---]", "-", true}, {"[------]", "-", true}, // Dash in middle of range expression {"[a-e-n]", "-", true}, {"[a-e-n]", "j", false}, // Negated multiple dashes {"[!------]", "a", true}, } for _, tt := range tests { m := setupMatcher(t, tt.pattern+"\n") got := m.Match(tt.path) if got != tt.want { t.Errorf("pattern %q, Match(%q) = %v, want %v", tt.pattern, tt.path, got, tt.want) } } } func TestWildmatchCharacterClassesExpanded(t *testing.T) { tests := []struct { pattern string path string want bool }{ // [:lower:] and [:upper:] {"[[:lower:]]", "a", true}, {"[[:lower:]]", "A", false}, {"[[:upper:]]", "A", true}, {"[[:upper:]]", "a", false}, // [:alnum:] {"[[:alnum:]]", "a", true}, {"[[:alnum:]]", "5", true}, {"[[:alnum:]]", ".", false}, // [:blank:] (space and tab) {"[[:blank:]]", " ", true}, {"[[:blank:]]", "\t", true}, // [:graph:] and [:print:] {"[[:graph:]]", "a", true}, {"[[:graph:]]", "!", true}, // Underscore matches many classes {"[[:alnum:][:alpha:][:blank:][:cntrl:][:digit:][:graph:][:lower:][:print:][:punct:][:space:][:upper:][:xdigit:]]", "_", true}, // Negated combination: period is not alnum/alpha/blank/cntrl/digit/lower/space/upper/xdigit {"[^[:alnum:][:alpha:][:blank:][:cntrl:][:digit:][:lower:][:space:][:upper:][:xdigit:]]", ".", true}, // Invalid POSIX class name causes regex compilation failure (no match) {"[[:digit:][:upper:][:spaci:]]", "1", false}, // Case-sensitive ranges {"[A-Z]", "A", true}, {"[A-Z]", "a", false}, {"[a-z]", "a", true}, {"[a-z]", "A", false}, {"[B-Za]", "a", true}, {"[B-Za]", "A", false}, {"[B-a]", "a", true}, {"[B-a]", "A", false}, {"[Z-y]", "Z", true}, {"[Z-y]", "z", false}, } for _, tt := range tests { m := setupMatcher(t, tt.pattern+"\n") got := m.Match(tt.path) if got != tt.want { t.Errorf("pattern %q, Match(%q) = %v, want %v", tt.pattern, tt.path, got, tt.want) } } } func TestWildmatchSlashHandling(t *testing.T) { tests := []struct { pattern string path string want bool }{ // * does not cross slashes {"foo*bar", "foo/baz/bar", false}, {"foo**bar", "foo/baz/bar", false}, // But ** in a/**/ position does {"foo/**/bar", "foo/baz/bar", true}, {"foo/**/bar", "foo/b/a/z/bar", true}, {"foo/**/bar", "foo/bar", true}, // **/X matches X at any depth {"**/foo", "foo", true}, {"**/foo", "XXX/foo", true}, {"**/foo", "bar/baz/foo", true}, // */foo is anchored (has slash), * matches one segment {"*/foo", "bar/baz/foo", false}, {"*/foo", "bar/foo", true}, // **/bar/* matches content inside bar/ {"**/bar/*", "deep/foo/bar/baz", true}, {"**/bar/*", "deep/foo/bar", false}, // **/bar/** matches anything under bar/ {"**/bar/**", "deep/foo/bar/baz", true}, // ? does not match / {"foo?bar", "foo/bar", false}, {"foo?bar", "fooXbar", true}, // f[^eiu][^eiu][^eiu][^eiu][^eiu]r with slashes {"f[^eiu][^eiu][^eiu][^eiu][^eiu]r", "foo-bar", true}, // Multi-segment ** patterns {"**/t[o]", "foo/bar/baz/to", true}, // Complex multi-segment patterns {"XXX/*/*/*/*/*/*/12/*/*/*/m/*/*/*", "XXX/adobe/courier/bold/o/normal//12/120/75/75/m/70/iso8859/1", true}, {"XXX/*/*/*/*/*/*/12/*/*/*/m/*/*/*", "XXX/adobe/courier/bold/o/normal//12/120/75/75/X/70/iso8859/1", false}, // * matches within segments only {"*/*/*", "foo/bba/arr", true}, {"*/*/*", "foo/bar", false}, {"*/*/*", "foo", false}, // ** across multiple levels {"**/**/**", "foo/bb/aa/rr", true}, {"**/**/**", "foo/bba/arr", true}, // Complex wildcard + slash patterns {"*X*i", "abcXdefXghi", true}, {"*/*X*/*/*i", "ab/cXd/efXg/hi", true}, {"**/*X*/**/*i", "ab/cXd/efXg/hi", true}, // Long pattern from recursion/abort tests {"-*-*-*-*-*-*-12-*-*-*-m-*-*-*", "-adobe-courier-bold-o-normal--12-120-75-75-m-70-iso8859-1", true}, {"-*-*-*-*-*-*-12-*-*-*-m-*-*-*", "-adobe-courier-bold-o-normal--12-120-75-75-X-70-iso8859-1", false}, // ** with extension {"**/*a*b*g*n*t", "abcd/abcdefg/abcdefghijk/abcdefghijklmnop.txt", true}, {"**/*a*b*g*n*t", "abcd/abcdefg/abcdefghijk/abcdefghijklmnop.txtz", false}, } for _, tt := range tests { m := setupMatcher(t, tt.pattern+"\n") got := m.Match(tt.path) if got != tt.want { t.Errorf("pattern %q, Match(%q) = %v, want %v", tt.pattern, tt.path, got, tt.want) } } } func TestWildmatchVsGitCheckIgnore(t *testing.T) { tests := []struct { name string patterns string paths []checkPath }{ { name: "star does not cross slashes", patterns: "foo*bar\n", paths: []checkPath{ {"fooXbar", false}, {"fooXXbar", false}, }, }, { name: "double star slash patterns", patterns: "**/foo\n*/bar\n", paths: []checkPath{ {"foo", false}, {"XXX/foo", false}, {"bar/baz/foo", false}, {"x/bar", false}, }, }, { name: "bracket ranges and negation", patterns: "t[a-g]n\nt[!a-g]n\n", paths: []checkPath{ {"ten", false}, {"ton", false}, {"tin", false}, }, }, { name: "complex glob operators", patterns: "*[al]?\n[ten]\n", paths: []checkPath{ {"ball", false}, {"tall", false}, {"t", false}, {"e", false}, {"n", false}, {"ten", false}, }, }, { name: "POSIX character classes", patterns: "[[:alpha:]][[:digit:]][[:upper:]]\n[[:lower:]]\n[[:xdigit:]]\n", paths: []checkPath{ {"a1B", false}, {"a", false}, {"f", false}, {"5", false}, {"D", false}, }, }, { name: "question mark does not match slash", patterns: "foo?bar\n", paths: []checkPath{ {"fooXbar", false}, {"fooybar", false}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := initGitRepo(t, tt.patterns) createCheckPaths(t, root, tt.paths) m := gitignore.New(root) compareWithGit(t, root, m, tt.paths) }) } } func TestGlobalExcludesFileXDG(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(""), 0644); err != nil { t.Fatal(err) } // Set up a fake XDG_CONFIG_HOME with a global ignore file. xdgDir := t.TempDir() gitConfigDir := filepath.Join(xdgDir, "git") if err := os.MkdirAll(gitConfigDir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(gitConfigDir, "ignore"), []byte("*.global-ignore\n"), 0644); err != nil { t.Fatal(err) } t.Setenv("XDG_CONFIG_HOME", xdgDir) // Clear any git config that might override. t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") m := gitignore.New(root) if !m.Match("test.global-ignore") { t.Error("expected global excludes pattern to match") } if m.Match("test.go") { t.Error("expected test.go to not be ignored") } } func TestGlobalExcludesFilePriority(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } // Root .gitignore re-includes *.global-ignore if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("!*.global-ignore\n"), 0644); err != nil { t.Fatal(err) } // Global excludes ignores *.global-ignore xdgDir := t.TempDir() gitConfigDir := filepath.Join(xdgDir, "git") if err := os.MkdirAll(gitConfigDir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(gitConfigDir, "ignore"), []byte("*.global-ignore\n"), 0644); err != nil { t.Fatal(err) } t.Setenv("XDG_CONFIG_HOME", xdgDir) t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") m := gitignore.New(root) // Root .gitignore (higher priority) re-includes the file. if m.Match("test.global-ignore") { t.Error("expected root negation to override global excludes") } } func TestExpandTilde(t *testing.T) { home, err := os.UserHomeDir() if err != nil { t.Skip("no home directory") } // Test via a global git config that uses ~ root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(""), 0644); err != nil { t.Fatal(err) } // Create a temp ignore file in a known location under home ignoreDir := filepath.Join(home, ".test-gitignore-expand-tilde") if err := os.MkdirAll(ignoreDir, 0755); err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(ignoreDir) }() ignoreFile := filepath.Join(ignoreDir, "ignore") if err := os.WriteFile(ignoreFile, []byte("*.tilde-test\n"), 0644); err != nil { t.Fatal(err) } // Configure git to use this file via tilde path. gitConfigDir := t.TempDir() gitConfigFile := filepath.Join(gitConfigDir, "config") if err := os.WriteFile(gitConfigFile, []byte("[core]\n\texcludesfile = ~/.test-gitignore-expand-tilde/ignore\n"), 0644); err != nil { t.Fatal(err) } t.Setenv("GIT_CONFIG_GLOBAL", gitConfigFile) m := gitignore.New(root) if !m.Match("foo.tilde-test") { t.Error("expected tilde-expanded global excludes to match") } } func TestNewFromDirectory(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } // Root .gitignore if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0644); err != nil { t.Fatal(err) } // Create directory structure with nested .gitignore for _, dir := range []string{"src", "src/lib", "vendor"} { if err := os.MkdirAll(filepath.Join(root, dir), 0755); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(root, "src", ".gitignore"), []byte("*.tmp\n"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "src", "lib", ".gitignore"), []byte("*.gen.go\n"), 0644); err != nil { t.Fatal(err) } // Create files so the walk discovers directories for _, f := range []string{"src/main.go", "src/lib/util.go", "vendor/lib.go"} { if err := os.WriteFile(filepath.Join(root, f), []byte("x"), 0644); err != nil { t.Fatal(err) } } m := gitignore.NewFromDirectory(root) tests := []struct { path string want bool }{ {"app.log", true}, // root pattern {"src/app.log", true}, // root pattern applies in subdirs {"src/cache.tmp", true}, // src/.gitignore pattern {"cache.tmp", false}, // src pattern scoped to src/ {"src/lib/foo.gen.go", true}, // src/lib/.gitignore pattern {"src/foo.gen.go", false}, // lib pattern scoped to src/lib/ {"src/main.go", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestMatchPath(t *testing.T) { m := setupMatcher(t, "vendor/\n*.log\nbuild\n") tests := []struct { path string isDir bool want bool }{ {"vendor", true, true}, {"vendor", false, false}, // dir-only pattern, file doesn't match {"app.log", false, true}, {"logs/app.log", false, true}, {"build", false, true}, {"build", true, true}, {"build/output.js", false, true}, {"src/main.go", false, false}, } for _, tt := range tests { got := m.MatchPath(tt.path, tt.isDir) if got != tt.want { t.Errorf("MatchPath(%q, isDir=%v) = %v, want %v", tt.path, tt.isDir, got, tt.want) } } } func TestMatchPathConsistentWithMatch(t *testing.T) { m := setupMatcher(t, "*.log\nbuild/\n/dist\nfoo/**/bar\n") paths := []string{ "app.log", "build/", "dist", "dist/", "foo/bar", "foo/a/bar", "src/main.go", "build/out.js", } for _, p := range paths { matchResult := m.Match(p) isDir := strings.HasSuffix(p, "/") clean := strings.TrimSuffix(p, "/") pathResult := m.MatchPath(clean, isDir) if matchResult != pathResult { t.Errorf("Match(%q)=%v but MatchPath(%q, %v)=%v", p, matchResult, clean, isDir, pathResult) } } } func TestNewFromDirectorySkipsIgnoredDirs(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("ignored_dir/\n"), 0644); err != nil { t.Fatal(err) } // Create an ignored directory with its own .gitignore if err := os.MkdirAll(filepath.Join(root, "ignored_dir"), 0755); err != nil { t.Fatal(err) } // This .gitignore should NOT be loaded since the dir is ignored. if err := os.WriteFile(filepath.Join(root, "ignored_dir", ".gitignore"), []byte("!*.important\n"), 0644); err != nil { t.Fatal(err) } // Create a non-ignored directory if err := os.MkdirAll(filepath.Join(root, "src"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "src", "main.go"), []byte("x"), 0644); err != nil { t.Fatal(err) } m := gitignore.NewFromDirectory(root) if !m.Match("ignored_dir/") { t.Error("expected ignored_dir/ to be ignored") } if m.Match("src/main.go") { t.Error("expected src/main.go to not be ignored") } } func TestWalk(t *testing.T) { // Isolate from user's global git config. t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\nbuild/\n"), 0644); err != nil { t.Fatal(err) } // Create directory structure for _, dir := range []string{"src", "build", "src/nested"} { if err := os.MkdirAll(filepath.Join(root, dir), 0755); err != nil { t.Fatal(err) } } // Create files for _, f := range []string{ "README.md", "src/main.go", "src/nested/util.go", "src/debug.log", "build/output.js", } { if err := os.WriteFile(filepath.Join(root, f), []byte("x"), 0644); err != nil { t.Fatal(err) } } var collected []string err := gitignore.Walk(root, func(path string, d os.DirEntry) error { collected = append(collected, filepath.ToSlash(path)) return nil }) if err != nil { t.Fatal(err) } // Should include non-ignored files and directories want := map[string]bool{ ".gitignore": true, "README.md": true, "src": true, "src/main.go": true, "src/nested": true, "src/nested/util.go": true, } // Should NOT include noWant := map[string]bool{ "build": true, "build/output.js": true, "src/debug.log": true, ".git": true, } got := make(map[string]bool) for _, p := range collected { got[p] = true } for w := range want { if !got[w] { t.Errorf("Walk missing expected path %q", w) } } for nw := range noWant { if got[nw] { t.Errorf("Walk should not have yielded %q", nw) } } } func TestWalkNestedGitignore(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(""), 0644); err != nil { t.Fatal(err) } // Create src/ with its own .gitignore that ignores *.tmp if err := os.MkdirAll(filepath.Join(root, "src"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "src", ".gitignore"), []byte("*.tmp\n"), 0644); err != nil { t.Fatal(err) } for _, f := range []string{"src/main.go", "src/cache.tmp", "root.tmp"} { if err := os.WriteFile(filepath.Join(root, f), []byte("x"), 0644); err != nil { t.Fatal(err) } } var collected []string err := gitignore.Walk(root, func(path string, d os.DirEntry) error { collected = append(collected, filepath.ToSlash(path)) return nil }) if err != nil { t.Fatal(err) } got := make(map[string]bool) for _, p := range collected { got[p] = true } if !got["src/main.go"] { t.Error("Walk should yield src/main.go") } if got["src/cache.tmp"] { t.Error("Walk should not yield src/cache.tmp (ignored by src/.gitignore)") } if !got["root.tmp"] { t.Error("Walk should yield root.tmp (not under src/)") } } func TestWalkSkipsGitDir(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(""), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("x"), 0644); err != nil { t.Fatal(err) } var collected []string err := gitignore.Walk(root, func(path string, d os.DirEntry) error { collected = append(collected, filepath.ToSlash(path)) return nil }) if err != nil { t.Fatal(err) } for _, p := range collected { if p == ".git" || strings.HasPrefix(p, ".git/") { t.Errorf("Walk should not yield .git paths, got %q", p) } } } func TestErrors(t *testing.T) { // Invalid POSIX class name produces an error. m := setupMatcher(t, "valid.log\n[[:spaci:]]\ninvalid[[:nope:]]pattern\nalso-valid\n") errs := m.Errors() if len(errs) != 2 { t.Fatalf("expected 2 errors, got %d: %v", len(errs), errs) } if errs[0].Pattern != "[[:spaci:]]" { t.Errorf("error[0].Pattern = %q, want %q", errs[0].Pattern, "[[:spaci:]]") } if errs[0].Line != 2 { t.Errorf("error[0].Line = %d, want 2", errs[0].Line) } if !strings.Contains(errs[0].Message, "spaci") { t.Errorf("error[0].Message = %q, want it to mention the class name", errs[0].Message) } if errs[1].Pattern != "invalid[[:nope:]]pattern" { t.Errorf("error[1].Pattern = %q, want %q", errs[1].Pattern, "invalid[[:nope:]]pattern") } if errs[1].Line != 3 { t.Errorf("error[1].Line = %d, want 3", errs[1].Line) } // Valid patterns still work. if !m.Match("valid.log") { t.Error("expected valid.log to match") } if !m.Match("also-valid") { t.Error("expected also-valid to match") } } func TestErrorsFromFile(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n[[:bogus:]]\n"), 0644); err != nil { t.Fatal(err) } t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") m := gitignore.New(root) errs := m.Errors() if len(errs) != 1 { t.Fatalf("expected 1 error, got %d", len(errs)) } if errs[0].Source == "" { t.Error("expected error to have a source file path") } errStr := errs[0].Error() if !strings.Contains(errStr, "bogus") { t.Errorf("error string %q should mention the class name", errStr) } if !strings.Contains(errStr, ".gitignore") { t.Errorf("error string %q should mention the source file", errStr) } } func TestMatchDetail(t *testing.T) { m := setupMatcher(t, "*.log\n!important.log\nbuild/\n") // File matched by *.log r := m.MatchDetail("app.log") if !r.Matched || !r.Ignored { t.Errorf("app.log: Matched=%v Ignored=%v, want true/true", r.Matched, r.Ignored) } if r.Pattern != "*.log" { t.Errorf("app.log: Pattern=%q, want %q", r.Pattern, "*.log") } if r.Line != 1 { t.Errorf("app.log: Line=%d, want 1", r.Line) } // File negated by !important.log r = m.MatchDetail("important.log") if !r.Matched || r.Ignored { t.Errorf("important.log: Matched=%v Ignored=%v, want true/false", r.Matched, r.Ignored) } if r.Pattern != "!important.log" { t.Errorf("important.log: Pattern=%q, want %q", r.Pattern, "!important.log") } if !r.Negate { t.Error("important.log: Negate should be true") } if r.Line != 2 { t.Errorf("important.log: Line=%d, want 2", r.Line) } // Directory matched by build/ r = m.MatchDetail("build/") if !r.Matched || !r.Ignored { t.Errorf("build/: Matched=%v Ignored=%v, want true/true", r.Matched, r.Ignored) } if r.Pattern != "build/" { t.Errorf("build/: Pattern=%q, want %q", r.Pattern, "build/") } // No match r = m.MatchDetail("src/main.go") if r.Matched || r.Ignored { t.Errorf("src/main.go: Matched=%v Ignored=%v, want false/false", r.Matched, r.Ignored) } if r.Pattern != "" { t.Errorf("src/main.go: Pattern=%q, want empty", r.Pattern) } } func TestMatchDetailSource(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0644); err != nil { t.Fatal(err) } t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") m := gitignore.New(root) r := m.MatchDetail("app.log") if !r.Matched { t.Fatal("expected match") } if !strings.HasSuffix(r.Source, ".gitignore") { t.Errorf("Source=%q, want it to end with .gitignore", r.Source) } } func TestMatchDetailConsistentWithMatch(t *testing.T) { m := setupMatcher(t, "*.log\n!important.log\nbuild/\n/dist\n") paths := []string{ "app.log", "important.log", "build/", "dist", "dist/", "src/main.go", "build/out.js", "sub/app.log", } for _, p := range paths { matchResult := m.Match(p) detail := m.MatchDetail(p) if matchResult != detail.Ignored { t.Errorf("Match(%q)=%v but MatchDetail.Ignored=%v", p, matchResult, detail.Ignored) } } } func TestErrorsEmpty(t *testing.T) { m := setupMatcher(t, "*.log\nbuild/\n") if len(m.Errors()) != 0 { t.Errorf("expected no errors, got %v", m.Errors()) } } func TestAddPatterns(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git", "info"), 0755); err != nil { t.Fatal(err) } m := gitignore.New(root) m.AddPatterns([]byte("*.log\nbuild/\n"), "") m.AddPatterns([]byte("*.tmp\n"), "src") tests := []struct { path string want bool }{ {"app.log", true}, {"build/", true}, {"src/cache.tmp", true}, {"cache.tmp", false}, // scoped to src/ {"README.md", false}, } for _, tt := range tests { got := m.Match(tt.path) if got != tt.want { t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want) } } } func TestNewEmptyRootSkipsFilesystem(t *testing.T) { // Create a temporary directory with a .gitignore that excludes *.exe dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.exe\n"), 0644); err != nil { t.Fatal(err) } // Change to that directory so CWD has a .gitignore orig, err := os.Getwd() if err != nil { t.Fatal(err) } if err := os.Chdir(dir); err != nil { t.Fatal(err) } t.Cleanup(func() { if err := os.Chdir(orig); err != nil { t.Logf("failed to restore working directory: %v", err) } }) // New("") should not load any filesystem patterns m := gitignore.New("") m.AddPatterns([]byte("*.tmp"), "") if m.MatchPath("test.exe", false) { t.Error("New(\"\") should not load .gitignore from CWD, but *.exe matched") } if !m.MatchPath("test.tmp", false) { t.Error("AddPatterns(*.tmp) should still work after New(\"\")") } } golang-github-git-pkgs-gitignore-1.1.2/go.mod000066400000000000000000000000601517222722100210550ustar00rootroot00000000000000module github.com/git-pkgs/gitignore go 1.25.5 golang-github-git-pkgs-gitignore-1.1.2/wildmatch.go000066400000000000000000000136451517222722100222670ustar00rootroot00000000000000package gitignore // posixClassOffset is the number of characters in the POSIX class delimiters // "[:" 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 { px, tx := 0, 0 // Backtrack point for the most recent ** we passed. starPx, starTx := -1, -1 for tx < len(pathSegs) { if px < len(patSegs) && patSegs[px].doubleStar { // Save backtrack point: try matching zero path segments first. starPx = px starTx = tx px++ continue } if px < len(patSegs) && !patSegs[px].doubleStar && matchSegment(patSegs[px].raw, pathSegs[tx]) { px++ tx++ continue } // Mismatch. Backtrack: consume one more path segment with the last **. if starPx >= 0 { starTx++ tx = starTx px = starPx + 1 continue } return false } // Remaining pattern segments must all be ** to match. for px < len(patSegs) { if !patSegs[px].doubleStar { return false } px++ } return true } // matchSegment matches a single path component against a glob pattern segment. // Handles *, ?, [...], and \-escapes. Uses two-pointer backtracking for *. func matchSegment(glob, text string) bool { gx, tx := 0, 0 starGx, starTx := -1, -1 for tx < len(text) { if gx < len(glob) { ch := glob[gx] switch { case ch == '\\' && gx+1 < len(glob): // Escaped character: match literally. gx++ if text[tx] == glob[gx] { gx++ tx++ continue } case ch == '?': gx++ tx++ continue case ch == '*': // Save backtrack point and try matching zero chars. starGx = gx starTx = tx gx++ continue case ch == '[': matched, newGx, ok := matchBracket(glob, gx, text[tx]) if ok && matched { gx = newGx tx++ continue } if !ok && text[tx] == '[' { // Invalid bracket (no closing ]); treat [ as literal. gx++ tx++ continue } default: if text[tx] == ch { gx++ tx++ continue } } } // Mismatch. Backtrack if we have a saved *. if starGx >= 0 { starTx++ tx = starTx gx = starGx + 1 continue } return false } // Consume trailing *'s in the pattern. for gx < len(glob) && glob[gx] == '*' { gx++ } return gx == len(glob) } // matchBracket checks if byte ch matches the bracket expression starting at // glob[pos] (the '['). Returns (matched, posAfterBracket, valid). // If the bracket has no closing ']', valid is false. func matchBracket(glob string, pos int, ch byte) (bool, int, bool) { i := pos + 1 // skip opening [ if i >= len(glob) { return false, 0, false } negate := false if glob[i] == '!' || glob[i] == '^' { negate = true i++ } matched := false first := true // ] is literal when it's the first char after [, [!, or [^ for i < len(glob) { if glob[i] == ']' && !first { if negate { matched = !matched } return matched, i + 1, true } first = false var hit bool hit, i = matchBracketElement(glob, i, ch) if hit { matched = true } } return false, 0, false } // matchBracketElement matches a single element inside a bracket expression: // a POSIX class ([:name:]), a range (lo-hi), or a literal character. // Returns whether ch matched and the new index past the element. func matchBracketElement(glob string, i int, ch byte) (bool, int) { // POSIX character class: [:name:] if glob[i] == '[' && i+1 < len(glob) && glob[i+1] == ':' { end := findPosixClassEnd(glob, i+posixClassOffset) if end >= 0 { name := glob[i+posixClassOffset : end] return matchPosixClass(name, ch), end + posixClassOffset } } lo, next := readBracketChar(glob, i) i = next // Check for range: lo-hi if i+1 < len(glob) && glob[i] == '-' && glob[i+1] != ']' { i++ // skip - hi, next := readBracketChar(glob, i) return ch >= lo && ch <= hi, next } return ch == lo, i } // readBracketChar reads a single (possibly escaped) character from a bracket // expression and returns the character and the index after it. func readBracketChar(glob string, i int) (byte, int) { if glob[i] == '\\' && i+1 < len(glob) { return glob[i+1], i + posixClassOffset } return glob[i], i + 1 } // findPosixClassEnd finds the position of ':' in ":]" after startPos. // Returns -1 if not found. func findPosixClassEnd(glob string, startPos int) int { for i := startPos; i+1 < len(glob); i++ { if glob[i] == ':' && glob[i+1] == ']' { return i } } return -1 } // posixClassMatchers maps POSIX character class names to their match functions. var posixClassMatchers = map[string]func(byte) bool{ "alnum": func(ch byte) bool { return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' }, "alpha": func(ch byte) bool { return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' }, "blank": func(ch byte) bool { return ch == ' ' || ch == '\t' }, "cntrl": func(ch byte) bool { return ch < 0x20 || ch == 0x7f }, "digit": func(ch byte) bool { return ch >= '0' && ch <= '9' }, "graph": func(ch byte) bool { return ch > 0x20 && ch < 0x7f }, "lower": func(ch byte) bool { return ch >= 'a' && ch <= 'z' }, "print": func(ch byte) bool { return ch >= 0x20 && ch < 0x7f }, "punct": func(ch byte) bool { return ch > 0x20 && ch < 0x7f && (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') && (ch < '0' || ch > '9') }, "space": func(ch byte) bool { return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || ch == '\f' || ch == '\v' }, "upper": func(ch byte) bool { return ch >= 'A' && ch <= 'Z' }, "xdigit": func(ch byte) bool { return ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'f' || ch >= 'A' && ch <= 'F' }, } // matchPosixClass checks whether byte ch belongs to the named POSIX character class. func matchPosixClass(name string, ch byte) bool { if fn, ok := posixClassMatchers[name]; ok { return fn(ch) } return false }