| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | |
| 9 | ignore "github.com/sabhiram/go-gitignore" |
| 10 | ) |
| 11 | |
| 12 | // ignoreFrame is the cumulative ignore state at a directory: every applicable |
| 13 | // .gitignore pattern from the repo root down to dir, re-anchored relative to the |
| 14 | // repo root and compiled into one matcher. Combining them into a single matcher |
| 15 | // is what lets a nested "!keep" re-include a file an ancestor ignored — |
| 16 | // go-gitignore applies last-match-wins across the whole ordered list. |
| 17 | type ignoreFrame struct { |
| 18 | dir string |
| 19 | patterns []string |
| 20 | gi *ignore.GitIgnore |
| 21 | } |
| 22 | |
| 23 | // walkIgnorer prunes a recursive grep walk to mirror ripgrep: it skips hidden |
| 24 | // entries, the fixed vendorDirs, and anything matched by the repository's ignore |
| 25 | // rules — every applicable .gitignore (root + ancestors + per-directory), plus |
| 26 | // .git/info/exclude and the global core.excludesFile. The walk root is never |
| 27 | // pruned, and pointing grep straight at a hidden or ignored path searches it in |
| 28 | // full, matching ripgrep's handling of explicitly named paths. |
| 29 | // |
| 30 | // Stateful across one WalkDir: enter pushes a directory's cumulative frame before |
| 31 | // its children are visited; skip pops frames once the walk leaves them. |
| 32 | type walkIgnorer struct { |
| 33 | root string |
| 34 | repoRoot string |
| 35 | disabled bool |
| 36 | frames []ignoreFrame // shallow→deep; the deepest is the active matcher |
| 37 | compiled map[string]*ignore.GitIgnore |
| 38 | forbidRoots []string // directories the walk must never enter |
| 39 | } |
| 40 | |
| 41 | func newWalkIgnorer(root string, forbidRoots []string) *walkIgnorer { |
| 42 | ig := &walkIgnorer{root: absClean(root), compiled: map[string]*ignore.GitIgnore{}, forbidRoots: forbidRoots} |
| 43 | rr := findRepoRoot(ig.root) |
| 44 | if rr == "" { |
| 45 | return ig |
| 46 | } |
| 47 | ig.repoRoot = rr |
| 48 | |
| 49 | var rootLines []string |
| 50 | if gx := globalExcludesFile(); gx != "" { |
| 51 | rootLines = append(rootLines, reanchorLines(readIgnoreLines(gx), "")...) |
| 52 | } |
| 53 | rootLines = append(rootLines, reanchorLines(readIgnoreLines(filepath.Join(rr, ".git", "info", "exclude")), "")...) |
| 54 | rootLines = append(rootLines, reanchorLines(readIgnoreLines(filepath.Join(rr, ".gitignore")), "")...) |
| 55 | ig.push(rr, nil, rootLines) |
| 56 | |
| 57 | for _, dir := range ancestorsBetween(rr, ig.root) { |
| 58 | lines := reanchorLines(readIgnoreLines(filepath.Join(dir, ".gitignore")), relSlash(rr, dir)) |
| 59 | ig.push(dir, ig.topPatterns(), lines) |
| 60 | } |
| 61 | |
| 62 | if isHiddenName(filepath.Base(ig.root)) || ig.ignored(ig.root, true) { |
| 63 | ig.disabled = true |
| 64 | } |
| 65 | return ig |
| 66 | } |
| 67 | |
| 68 | // enter loads a kept directory's own .gitignore as a cumulative frame governing |
| 69 | // its children. Called after skip clears the directory, before the walk descends. |
| 70 | func (ig *walkIgnorer) enter(path string) { |
| 71 | if ig.disabled { |
| 72 | return |
| 73 | } |
| 74 | abs := absClean(path) |
| 75 | if abs == ig.root { |
| 76 | return // the root's frames are already in place |
| 77 | } |
| 78 | lines := reanchorLines(readIgnoreLines(filepath.Join(abs, ".gitignore")), relSlash(ig.repoRoot, abs)) |
| 79 | ig.push(abs, ig.topPatterns(), lines) |
| 80 | } |
| 81 | |
| 82 | // skip reports whether a walked entry should be pruned, popping frames the walk |
| 83 | // has moved past. The root is never pruned; hidden entries and vendorDirs always |
| 84 | // are; everything else is pruned when the active matcher ignores it. |
| 85 | func (ig *walkIgnorer) skip(path, name string, isDir bool) bool { |
| 86 | abs := absClean(path) |
| 87 | for len(ig.frames) > 1 && !underDir(ig.frames[len(ig.frames)-1].dir, abs) { |
| 88 | ig.frames = ig.frames[:len(ig.frames)-1] |
| 89 | } |
| 90 | if abs == ig.root || ig.disabled { |
| 91 | return false |
| 92 | } |
| 93 | if isHiddenName(name) { |
| 94 | return true |
| 95 | } |
| 96 | if isDir && (vendorDirs[name] || isProtectedDir(abs)) { |
| 97 | return true |
| 98 | } |
| 99 | if isDir && skipForbidDir(abs, ig.forbidRoots) { |
| 100 | return true |
| 101 | } |
| 102 | return ig.ignored(abs, isDir) |
| 103 | } |
| 104 | |
| 105 | func (ig *walkIgnorer) ignored(abs string, isDir bool) bool { |
| 106 | if len(ig.frames) == 0 { |
| 107 | return false |
| 108 | } |
| 109 | f := ig.frames[len(ig.frames)-1] |
| 110 | if f.gi == nil { |
| 111 | return false |
| 112 | } |
| 113 | rel := relSlash(ig.repoRoot, abs) |
| 114 | if rel == "" || rel == "." || strings.HasPrefix(rel, "..") { |
| 115 | return false |
| 116 | } |
| 117 | if isDir && f.gi.MatchesPath(rel+"/") { |
| 118 | return true |
| 119 | } |
| 120 | return f.gi.MatchesPath(rel) |
| 121 | } |
| 122 | |
| 123 | func (ig *walkIgnorer) topPatterns() []string { |
| 124 | if len(ig.frames) == 0 { |
| 125 | return nil |
| 126 | } |
| 127 | return ig.frames[len(ig.frames)-1].patterns |
| 128 | } |
| 129 | |
| 130 | // push records a frame combining parent patterns with this dir's new ones. A |
| 131 | // frame is added only when it contributes rules (keeping the stack sparse), but |
| 132 | // the repo-root frame is always recorded so it anchors the bottom of the stack. |
| 133 | func (ig *walkIgnorer) push(dir string, parent, add []string) { |
| 134 | if len(add) == 0 && dir != ig.repoRoot { |
| 135 | return |
| 136 | } |
| 137 | pat := append(append([]string{}, parent...), add...) |
| 138 | var gi *ignore.GitIgnore |
| 139 | if len(pat) > 0 { |
| 140 | key := strings.Join(pat, "\n") |
| 141 | if c, ok := ig.compiled[key]; ok { |
| 142 | gi = c |
| 143 | } else { |
| 144 | gi = ignore.CompileIgnoreLines(pat...) |
| 145 | ig.compiled[key] = gi |
| 146 | } |
| 147 | } |
| 148 | ig.frames = append(ig.frames, ignoreFrame{dir: dir, patterns: pat, gi: gi}) |
| 149 | } |
| 150 | |
| 151 | // reanchorLines drops comments/blanks and re-anchors each pattern from a |
| 152 | // .gitignore in relDir (relative to the repo root, "" for the root) so every |
| 153 | // pattern is expressed relative to the repo root and can share one matcher. |
| 154 | func reanchorLines(lines []string, relDir string) []string { |
| 155 | var out []string |
| 156 | for _, raw := range lines { |
| 157 | line := strings.TrimRight(raw, " \t\r") |
| 158 | if line == "" || strings.HasPrefix(line, "#") { |
| 159 | continue |
| 160 | } |
| 161 | out = append(out, reanchorPattern(line, relDir)) |
| 162 | } |
| 163 | return out |
| 164 | } |
| 165 | |
| 166 | // reanchorPattern rewrites one .gitignore pattern from relDir to be relative to |
| 167 | // the repo root: an anchored pattern (leading or embedded "/") becomes |
| 168 | // "/relDir/pat"; an unanchored one (matches at any depth) becomes |
| 169 | // "/relDir/**/pat". Root patterns (relDir "") keep git's native semantics. |
| 170 | func reanchorPattern(line, relDir string) string { |
| 171 | neg := "" |
| 172 | if strings.HasPrefix(line, "!") { |
| 173 | neg = "!" |
| 174 | line = line[1:] |
| 175 | } |
| 176 | line = strings.TrimPrefix(line, `\`) // escaped leading '#' or '!' |
| 177 | if relDir == "" || relDir == "." { |
| 178 | return neg + line |
| 179 | } |
| 180 | anchored := strings.HasPrefix(line, "/") || strings.Contains(strings.TrimSuffix(line, "/"), "/") |
| 181 | line = strings.TrimPrefix(line, "/") |
| 182 | if anchored { |
| 183 | return neg + "/" + relDir + "/" + line |
| 184 | } |
| 185 | return neg + "/" + relDir + "/**/" + line |
| 186 | } |
| 187 | |
| 188 | func isHiddenName(name string) bool { |
| 189 | return len(name) > 1 && name[0] == '.' && name != ".." |
| 190 | } |
| 191 | |
| 192 | // underDir reports whether path is at or below dir. |
| 193 | func underDir(dir, path string) bool { |
| 194 | return path == dir || strings.HasPrefix(path, dir+string(os.PathSeparator)) |
| 195 | } |
| 196 | |
| 197 | func absClean(p string) string { |
| 198 | if abs, err := filepath.Abs(p); err == nil { |
| 199 | return abs |
| 200 | } |
| 201 | return filepath.Clean(p) |
| 202 | } |
| 203 | |
| 204 | func relSlash(base, target string) string { |
| 205 | rel, err := filepath.Rel(base, target) |
| 206 | if err != nil { |
| 207 | return "" |
| 208 | } |
| 209 | return filepath.ToSlash(rel) |
| 210 | } |
| 211 | |
| 212 | func readIgnoreLines(path string) []string { |
| 213 | body, _, err := readFileEncoded(path) |
| 214 | if err != nil { |
| 215 | return nil |
| 216 | } |
| 217 | return strings.Split(body, "\n") |
| 218 | } |
| 219 | |
| 220 | // ancestorsBetween returns the directories in (repoRoot, root], shallow-first. |
| 221 | func ancestorsBetween(repoRoot, root string) []string { |
| 222 | var dirs []string |
| 223 | for d := root; d != repoRoot && d != filepath.Dir(d); d = filepath.Dir(d) { |
| 224 | dirs = append(dirs, d) |
| 225 | } |
| 226 | for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 { |
| 227 | dirs[i], dirs[j] = dirs[j], dirs[i] |
| 228 | } |
| 229 | return dirs |
| 230 | } |
| 231 | |
| 232 | // findRepoRoot returns the nearest ancestor of start (inclusive) holding a .git |
| 233 | // entry, or "" if start is not inside a git repository. A file start begins the |
| 234 | // search from its directory. |
| 235 | func findRepoRoot(start string) string { |
| 236 | abs, err := filepath.Abs(start) |
| 237 | if err != nil { |
| 238 | return "" |
| 239 | } |
| 240 | if fi, err := os.Stat(abs); err == nil && !fi.IsDir() { |
| 241 | abs = filepath.Dir(abs) |
| 242 | } |
| 243 | for { |
| 244 | if _, err := os.Stat(filepath.Join(abs, ".git")); err == nil { |
| 245 | return abs |
| 246 | } |
| 247 | parent := filepath.Dir(abs) |
| 248 | if parent == abs { |
| 249 | return "" |
| 250 | } |
| 251 | abs = parent |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // globalExcludesFile returns git's effective global ignore file: core.excludesFile |
| 256 | // from the user/global git config when set and present, else git's default |
| 257 | // ($XDG_CONFIG_HOME/git/ignore, then ~/.config/git/ignore). "" when none exists. |
| 258 | func globalExcludesFile() string { |
| 259 | if p := gitConfigExcludesFile(); p != "" && statFile(p) { |
| 260 | return p |
| 261 | } |
| 262 | base := os.Getenv("XDG_CONFIG_HOME") |
| 263 | if base == "" { |
| 264 | if home, err := os.UserHomeDir(); err == nil { |
| 265 | base = filepath.Join(home, ".config") |
| 266 | } |
| 267 | } |
| 268 | if base != "" { |
| 269 | if p := filepath.Join(base, "git", "ignore"); statFile(p) { |
| 270 | return p |
| 271 | } |
| 272 | } |
| 273 | return "" |
| 274 | } |
| 275 | |
| 276 | // gitConfigExcludesFile reads core.excludesFile from the global git config files |
| 277 | // directly (no git binary needed). Include directives are not followed. |
| 278 | func gitConfigExcludesFile() string { |
| 279 | for _, cfg := range gitConfigPaths() { |
| 280 | if p := scanGitConfigExcludes(cfg); p != "" { |
| 281 | return expandHome(p) |
| 282 | } |
| 283 | } |
| 284 | return "" |
| 285 | } |
| 286 | |
| 287 | func gitConfigPaths() []string { |
| 288 | if p := os.Getenv("GIT_CONFIG_GLOBAL"); p != "" { |
| 289 | return []string{p} |
| 290 | } |
| 291 | home, _ := os.UserHomeDir() |
| 292 | base := os.Getenv("XDG_CONFIG_HOME") |
| 293 | if base == "" && home != "" { |
| 294 | base = filepath.Join(home, ".config") |
| 295 | } |
| 296 | var paths []string |
| 297 | if base != "" { |
| 298 | paths = append(paths, filepath.Join(base, "git", "config")) |
| 299 | } |
| 300 | if home != "" { |
| 301 | paths = append(paths, filepath.Join(home, ".gitconfig")) |
| 302 | } |
| 303 | return paths |
| 304 | } |
| 305 | |
| 306 | func scanGitConfigExcludes(path string) string { |
| 307 | body, _, err := readFileEncoded(path) |
| 308 | if err != nil { |
| 309 | return "" |
| 310 | } |
| 311 | sc := bufio.NewScanner(strings.NewReader(body)) |
| 312 | inCore := false |
| 313 | for sc.Scan() { |
| 314 | line := strings.TrimSpace(sc.Text()) |
| 315 | if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { |
| 316 | continue |
| 317 | } |
| 318 | if strings.HasPrefix(line, "[") { |
| 319 | sec := strings.ToLower(strings.Trim(line, "[]")) |
| 320 | inCore = strings.TrimSpace(strings.SplitN(sec, " ", 2)[0]) == "core" |
| 321 | continue |
| 322 | } |
| 323 | if !inCore { |
| 324 | continue |
| 325 | } |
| 326 | if k, v, ok := strings.Cut(line, "="); ok && strings.EqualFold(strings.TrimSpace(k), "excludesfile") { |
| 327 | return strings.Trim(strings.TrimSpace(v), `"`) |
| 328 | } |
| 329 | } |
| 330 | return "" |
| 331 | } |
| 332 | |
| 333 | func expandHome(p string) string { |
| 334 | if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) { |
| 335 | if home, err := os.UserHomeDir(); err == nil { |
| 336 | return filepath.Join(home, strings.TrimLeft(p[1:], `/\`)) |
| 337 | } |
| 338 | } |
| 339 | return p |
| 340 | } |
| 341 | |
| 342 | func statFile(p string) bool { |
| 343 | _, err := os.Stat(p) |
| 344 | return err == nil |
| 345 | } |
| 346 |