返回 DeepSeek-Reasonix
gitignore.go
根目录 / internal / tool / builtin / gitignore.go
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
346 lines GO