package builtin import ( "bufio" "os" "path/filepath" "strings" ignore "github.com/sabhiram/go-gitignore" ) // ignoreFrame is the cumulative ignore state at a directory: every applicable // .gitignore pattern from the repo root down to dir, re-anchored relative to the // repo root and compiled into one matcher. Combining them into a single matcher // is what lets a nested "!keep" re-include a file an ancestor ignored — // go-gitignore applies last-match-wins across the whole ordered list. type ignoreFrame struct { dir string patterns []string gi *ignore.GitIgnore } // walkIgnorer prunes a recursive grep walk to mirror ripgrep: it skips hidden // entries, the fixed vendorDirs, and anything matched by the repository's ignore // rules — every applicable .gitignore (root + ancestors + per-directory), plus // .git/info/exclude and the global core.excludesFile. The walk root is never // pruned, and pointing grep straight at a hidden or ignored path searches it in // full, matching ripgrep's handling of explicitly named paths. // // Stateful across one WalkDir: enter pushes a directory's cumulative frame before // its children are visited; skip pops frames once the walk leaves them. type walkIgnorer struct { root string repoRoot string disabled bool frames []ignoreFrame // shallow→deep; the deepest is the active matcher compiled map[string]*ignore.GitIgnore forbidRoots []string // directories the walk must never enter } func newWalkIgnorer(root string, forbidRoots []string) *walkIgnorer { ig := &walkIgnorer{root: absClean(root), compiled: map[string]*ignore.GitIgnore{}, forbidRoots: forbidRoots} rr := findRepoRoot(ig.root) if rr == "" { return ig } ig.repoRoot = rr var rootLines []string if gx := globalExcludesFile(); gx != "" { rootLines = append(rootLines, reanchorLines(readIgnoreLines(gx), "")...) } rootLines = append(rootLines, reanchorLines(readIgnoreLines(filepath.Join(rr, ".git", "info", "exclude")), "")...) rootLines = append(rootLines, reanchorLines(readIgnoreLines(filepath.Join(rr, ".gitignore")), "")...) ig.push(rr, nil, rootLines) for _, dir := range ancestorsBetween(rr, ig.root) { lines := reanchorLines(readIgnoreLines(filepath.Join(dir, ".gitignore")), relSlash(rr, dir)) ig.push(dir, ig.topPatterns(), lines) } if isHiddenName(filepath.Base(ig.root)) || ig.ignored(ig.root, true) { ig.disabled = true } return ig } // enter loads a kept directory's own .gitignore as a cumulative frame governing // its children. Called after skip clears the directory, before the walk descends. func (ig *walkIgnorer) enter(path string) { if ig.disabled { return } abs := absClean(path) if abs == ig.root { return // the root's frames are already in place } lines := reanchorLines(readIgnoreLines(filepath.Join(abs, ".gitignore")), relSlash(ig.repoRoot, abs)) ig.push(abs, ig.topPatterns(), lines) } // skip reports whether a walked entry should be pruned, popping frames the walk // has moved past. The root is never pruned; hidden entries and vendorDirs always // are; everything else is pruned when the active matcher ignores it. func (ig *walkIgnorer) skip(path, name string, isDir bool) bool { abs := absClean(path) for len(ig.frames) > 1 && !underDir(ig.frames[len(ig.frames)-1].dir, abs) { ig.frames = ig.frames[:len(ig.frames)-1] } if abs == ig.root || ig.disabled { return false } if isHiddenName(name) { return true } if isDir && (vendorDirs[name] || isProtectedDir(abs)) { return true } if isDir && skipForbidDir(abs, ig.forbidRoots) { return true } return ig.ignored(abs, isDir) } func (ig *walkIgnorer) ignored(abs string, isDir bool) bool { if len(ig.frames) == 0 { return false } f := ig.frames[len(ig.frames)-1] if f.gi == nil { return false } rel := relSlash(ig.repoRoot, abs) if rel == "" || rel == "." || strings.HasPrefix(rel, "..") { return false } if isDir && f.gi.MatchesPath(rel+"/") { return true } return f.gi.MatchesPath(rel) } func (ig *walkIgnorer) topPatterns() []string { if len(ig.frames) == 0 { return nil } return ig.frames[len(ig.frames)-1].patterns } // push records a frame combining parent patterns with this dir's new ones. A // frame is added only when it contributes rules (keeping the stack sparse), but // the repo-root frame is always recorded so it anchors the bottom of the stack. func (ig *walkIgnorer) push(dir string, parent, add []string) { if len(add) == 0 && dir != ig.repoRoot { return } pat := append(append([]string{}, parent...), add...) var gi *ignore.GitIgnore if len(pat) > 0 { key := strings.Join(pat, "\n") if c, ok := ig.compiled[key]; ok { gi = c } else { gi = ignore.CompileIgnoreLines(pat...) ig.compiled[key] = gi } } ig.frames = append(ig.frames, ignoreFrame{dir: dir, patterns: pat, gi: gi}) } // reanchorLines drops comments/blanks and re-anchors each pattern from a // .gitignore in relDir (relative to the repo root, "" for the root) so every // pattern is expressed relative to the repo root and can share one matcher. func reanchorLines(lines []string, relDir string) []string { var out []string for _, raw := range lines { line := strings.TrimRight(raw, " \t\r") if line == "" || strings.HasPrefix(line, "#") { continue } out = append(out, reanchorPattern(line, relDir)) } return out } // reanchorPattern rewrites one .gitignore pattern from relDir to be relative to // the repo root: an anchored pattern (leading or embedded "/") becomes // "/relDir/pat"; an unanchored one (matches at any depth) becomes // "/relDir/**/pat". Root patterns (relDir "") keep git's native semantics. func reanchorPattern(line, relDir string) string { neg := "" if strings.HasPrefix(line, "!") { neg = "!" line = line[1:] } line = strings.TrimPrefix(line, `\`) // escaped leading '#' or '!' if relDir == "" || relDir == "." { return neg + line } anchored := strings.HasPrefix(line, "/") || strings.Contains(strings.TrimSuffix(line, "/"), "/") line = strings.TrimPrefix(line, "/") if anchored { return neg + "/" + relDir + "/" + line } return neg + "/" + relDir + "/**/" + line } func isHiddenName(name string) bool { return len(name) > 1 && name[0] == '.' && name != ".." } // underDir reports whether path is at or below dir. func underDir(dir, path string) bool { return path == dir || strings.HasPrefix(path, dir+string(os.PathSeparator)) } func absClean(p string) string { if abs, err := filepath.Abs(p); err == nil { return abs } return filepath.Clean(p) } func relSlash(base, target string) string { rel, err := filepath.Rel(base, target) if err != nil { return "" } return filepath.ToSlash(rel) } func readIgnoreLines(path string) []string { body, _, err := readFileEncoded(path) if err != nil { return nil } return strings.Split(body, "\n") } // ancestorsBetween returns the directories in (repoRoot, root], shallow-first. func ancestorsBetween(repoRoot, root string) []string { var dirs []string for d := root; d != repoRoot && d != filepath.Dir(d); d = filepath.Dir(d) { dirs = append(dirs, d) } for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 { dirs[i], dirs[j] = dirs[j], dirs[i] } return dirs } // findRepoRoot returns the nearest ancestor of start (inclusive) holding a .git // entry, or "" if start is not inside a git repository. A file start begins the // search from its directory. func findRepoRoot(start string) string { abs, err := filepath.Abs(start) if err != nil { return "" } if fi, err := os.Stat(abs); err == nil && !fi.IsDir() { abs = filepath.Dir(abs) } for { if _, err := os.Stat(filepath.Join(abs, ".git")); err == nil { return abs } parent := filepath.Dir(abs) if parent == abs { return "" } abs = parent } } // globalExcludesFile returns git's effective global ignore file: core.excludesFile // from the user/global git config when set and present, else git's default // ($XDG_CONFIG_HOME/git/ignore, then ~/.config/git/ignore). "" when none exists. func globalExcludesFile() string { if p := gitConfigExcludesFile(); p != "" && statFile(p) { return p } base := os.Getenv("XDG_CONFIG_HOME") if base == "" { if home, err := os.UserHomeDir(); err == nil { base = filepath.Join(home, ".config") } } if base != "" { if p := filepath.Join(base, "git", "ignore"); statFile(p) { return p } } return "" } // gitConfigExcludesFile reads core.excludesFile from the global git config files // directly (no git binary needed). Include directives are not followed. func gitConfigExcludesFile() string { for _, cfg := range gitConfigPaths() { if p := scanGitConfigExcludes(cfg); p != "" { return expandHome(p) } } return "" } func gitConfigPaths() []string { if p := os.Getenv("GIT_CONFIG_GLOBAL"); p != "" { return []string{p} } home, _ := os.UserHomeDir() base := os.Getenv("XDG_CONFIG_HOME") if base == "" && home != "" { base = filepath.Join(home, ".config") } var paths []string if base != "" { paths = append(paths, filepath.Join(base, "git", "config")) } if home != "" { paths = append(paths, filepath.Join(home, ".gitconfig")) } return paths } func scanGitConfigExcludes(path string) string { body, _, err := readFileEncoded(path) if err != nil { return "" } sc := bufio.NewScanner(strings.NewReader(body)) inCore := false for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { continue } if strings.HasPrefix(line, "[") { sec := strings.ToLower(strings.Trim(line, "[]")) inCore = strings.TrimSpace(strings.SplitN(sec, " ", 2)[0]) == "core" continue } if !inCore { continue } if k, v, ok := strings.Cut(line, "="); ok && strings.EqualFold(strings.TrimSpace(k), "excludesfile") { return strings.Trim(strings.TrimSpace(v), `"`) } } return "" } func expandHome(p string) string { if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) { if home, err := os.UserHomeDir(); err == nil { return filepath.Join(home, strings.TrimLeft(p[1:], `/\`)) } } return p } func statFile(p string) bool { _, err := os.Stat(p) return err == nil }