1// Package ignore provides a minimal .gitignore matcher shared across
2// zot. It is intentionally small: enough to drop obvious non-source
3// directories (build outputs, dependency and tool caches) from
4// recursive walks, not a faithful git reimplementation.
5package ignore
6
7import (
8 "os"
9 "path/filepath"
10 "strings"
11)
12
13// Gitignore is a minimal .gitignore matcher. It supports the common
14// patterns used in real repos: blank lines, comments (#), negation (!),
15// directory-only patterns (trailing /), anchored patterns (leading /),
16// and the * / ? / [..] wildcards via filepath.Match. It intentionally
17// does not implement ** globstar or nested per-directory .gitignore
18// files.
19type Gitignore struct {
20 rules []rule
21}
22
23type rule struct {
24 pattern string
25 negate bool
26 dirOnly bool
27 anchored bool
28}
29
30// Load reads the .gitignore at the root directory. A missing or
31// unreadable file yields an empty matcher that ignores nothing.
32func Load(root string) *Gitignore {
33 data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
34 if err != nil {
35 return &Gitignore{}
36 }
37 return Parse(string(data))
38}
39
40// Stack tracks the chain of .gitignore files from a walk's root down to
41// the directory currently being visited. Real repositories routinely
42// place a .gitignore inside a subdirectory (for example a vendored tool
43// directory that ignores its own node_modules), and those nested rules
44// are invisible to a single root-only matcher. During a recursive walk
45// such an unhonored node_modules can swamp the entry budget before the
46// walk ever reaches the files the user is actually looking for, so the
47// nested files must be applied.
48//
49// Each frame holds the matcher parsed from a directory's .gitignore
50// plus that directory's path relative to the walk root (slash
51// separated, "" for the root). A nested matcher's patterns are
52// evaluated against the path relative to the directory that owns them,
53// matching git's own semantics.
54type Stack struct {
55 root string
56 frames []stackFrame
57}
58
59type stackFrame struct {
60 relDir string // dir owning the .gitignore, relative to root, slash-sep
61 ig *Gitignore
62}
63
64// NewStack returns a Stack seeded with the root .gitignore (if any).
65func NewStack(root string) *Stack {
66 s := &Stack{root: root}
67 s.frames = append(s.frames, stackFrame{relDir: "", ig: Load(root)})
68 return s
69}
70
71// Push loads the .gitignore in dir (an absolute path under root, with
72// relDir its slash-separated path relative to root) and adds it to the
73// stack. Call when the walk descends into a directory that is being
74// kept; pair with Pop when leaving it. A directory with no .gitignore
75// still gets a frame so the push/pop bookkeeping stays balanced.
76func (s *Stack) Push(absDir, relDir string) {
77 data, err := os.ReadFile(filepath.Join(absDir, ".gitignore"))
78 var ig *Gitignore
79 if err != nil {
80 ig = &Gitignore{}
81 } else {
82 ig = Parse(string(data))
83 }
84 s.frames = append(s.frames, stackFrame{relDir: filepath.ToSlash(relDir), ig: ig})
85}
86
87// Pop removes the most recently pushed frame. The seeded root frame is
88// never popped.
89func (s *Stack) Pop() {
90 if len(s.frames) > 1 {
91 s.frames = s.frames[:len(s.frames)-1]
92 }
93}
94
95// Match reports whether rel (slash-separated, relative to the walk
96// root) should be ignored, consulting every .gitignore from the root
97// down to the current directory. Each frame matches against rel made
98// relative to the directory that owns it; a deeper frame's later rules
99// win, mirroring git's nearest-file-wins ordering.
100func (s *Stack) Match(rel string, isDir bool) bool {
101 ignored := false
102 for _, f := range s.frames {
103 sub := rel
104 if f.relDir != "" {
105 prefix := f.relDir + "/"
106 if !strings.HasPrefix(rel, prefix) {
107 continue
108 }
109 sub = rel[len(prefix):]
110 }
111 if matched, neg := f.ig.matchResult(sub, isDir); matched {
112 ignored = !neg
113 }
114 }
115 return ignored
116}
117
118// Parse builds a matcher from raw .gitignore file contents.
119func Parse(data string) *Gitignore {
120 g := &Gitignore{}
121 for _, line := range strings.Split(data, "\n") {
122 line = strings.TrimRight(line, "\r")
123 trimmed := strings.TrimSpace(line)
124 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
125 continue
126 }
127 r := rule{pattern: trimmed}
128 if strings.HasPrefix(r.pattern, "!") {
129 r.negate = true
130 r.pattern = r.pattern[1:]
131 }
132 if strings.HasSuffix(r.pattern, "/") {
133 r.dirOnly = true
134 r.pattern = strings.TrimSuffix(r.pattern, "/")
135 }
136 if strings.HasPrefix(r.pattern, "/") {
137 r.anchored = true
138 r.pattern = strings.TrimPrefix(r.pattern, "/")
139 }
140 if r.pattern == "" {
141 continue
142 }
143 g.rules = append(g.rules, r)
144 }
145 return g
146}
147
148// Match reports whether the slash-separated relative path should be
149// ignored. Later rules win, so a trailing negation can re-include a
150// previously ignored path.
151func (g *Gitignore) Match(rel string, isDir bool) bool {
152 matched, negate := g.matchResult(rel, isDir)
153 return matched && !negate
154}
155
156// matchResult reports whether any rule matched rel and, if so, whether
157// the winning (last) matching rule was a negation. This lets a Stack
158// combine matchers across nested .gitignore files while still honoring
159// negations correctly: a nested "!keep.me" must be able to re-include a
160// path a parent .gitignore excluded.
161func (g *Gitignore) matchResult(rel string, isDir bool) (matched, negate bool) {
162 for _, r := range g.rules {
163 if r.dirOnly && !isDir {
164 continue
165 }
166 if r.matchPath(rel) {
167 matched = true
168 negate = r.negate
169 }
170 }
171 return matched, negate
172}
173
174func (r rule) matchPath(rel string) bool {
175 if r.anchored || strings.Contains(r.pattern, "/") {
176 if ok, _ := filepath.Match(r.pattern, rel); ok {
177 return true
178 }
179 // Anchored directory pattern also matches everything beneath it.
180 return strings.HasPrefix(rel, r.pattern+"/")
181 }
182 // Unanchored: match the basename of any path component.
183 base := rel
184 if i := strings.LastIndex(rel, "/"); i >= 0 {
185 base = rel[i+1:]
186 }
187 if ok, _ := filepath.Match(r.pattern, base); ok {
188 return true
189 }
190 // Match a directory component anywhere in the path.
191 for _, part := range strings.Split(rel, "/") {
192 if ok, _ := filepath.Match(r.pattern, part); ok {
193 return true
194 }
195 }
196 return false
197}