@@ -12,29 +12,45 @@ import (
1212 "github.com/charlievieth/fastwalk"
1313 "github.com/charmbracelet/crush/internal/csync"
1414 "github.com/charmbracelet/crush/internal/home"
15โ ignore "github.com/sabhiram/go-gitignore"
15+ "github.com/go-git/go-git/v5/plumbing/format/gitignore"
1616)
1717
18โ// commonIgnorePatterns contains commonly ignored files and directories
19โvar commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
20โ return ignore.CompileIgnoreLines(
21โ // Version control
22โ ".git",
23โ ".svn",
24โ ".hg",
25โ ".bzr",
26โ
27โ // IDE and editor files
28โ ".vscode",
29โ ".idea",
18+// fastIgnoreDirs is a set of directory names that are always ignored.
19+// This provides O(1) lookup for common cases to avoid expensive pattern matching.
20+var fastIgnoreDirs = map[string]bool{
21+ ".git": true,
22+ ".svn": true,
23+ ".hg": true,
24+ ".bzr": true,
25+ ".vscode": true,
26+ ".idea": true,
27+ "node_modules": true,
28+ "__pycache__": true,
29+ ".pytest_cache": true,
30+ ".cache": true,
31+ ".tmp": true,
32+ ".Trash": true,
33+ ".Spotlight-V100": true,
34+ ".fseventsd": true,
35+ ".crush": true,
36+ "OrbStack": true,
37+ ".local": true,
38+ ".share": true,
39+}
40+
41+// commonIgnorePatterns contains commonly ignored files and directories.
42+// Note: Exact directory names that are in fastIgnoreDirs are handled there for O(1) lookup.
43+// This list contains wildcard patterns and file-specific patterns.
44+var commonIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern {
45+ patterns := []string{
46+ // IDE and editor files (wildcards)
3047 "*.swp",
3148 "*.swo",
3249 "*~",
3350 ".DS_Store",
3451 "Thumbs.db",
3552
36โ // Build artifacts and dependencies
37โ "node_modules",
53+ // Build artifacts (non-fastIgnoreDirs)
3854 "target",
3955 "build",
4056 "dist",
@@ -47,84 +63,147 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
4763 "*.dll",
4864 "*.exe",
4965
50โ // Logs and temporary files
66+ // Logs and temporary files (wildcards)
5167 "*.log",
5268 "*.tmp",
5369 "*.temp",
54โ ".cache",
55โ ".tmp",
5670
57โ // Language-specific
58โ "__pycache__",
71+ // Language-specific (wildcards and non-fastIgnoreDirs)
5972 "*.pyc",
6073 "*.pyo",
61โ ".pytest_cache",
6274 "vendor",
6375 "Cargo.lock",
6476 "package-lock.json",
6577 "yarn.lock",
6678 "pnpm-lock.yaml",
67โ
68โ // OS generated files
69โ ".Trash",
70โ ".Spotlight-V100",
71โ ".fseventsd",
72โ
73โ // Crush
74โ ".crush",
75โ
76โ // macOS stuff
77โ "OrbStack",
78โ ".local",
79โ ".share",
80โ )
79+ }
80+ return parsePatterns(patterns, nil)
8181})
8282
83โvar homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
84โ home := home.Dir()
83+var homeIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern {
84+ homeDir := home.Dir()
8585 var lines []string
8686 for _, name := range []string{
87โ filepath.Join(home, ".gitignore"),
88โ filepath.Join(home, ".config", "git", "ignore"),
89โ filepath.Join(home, ".config", "crush", "ignore"),
87+ filepath.Join(homeDir, ".gitignore"),
88+ filepath.Join(homeDir, ".config", "git", "ignore"),
89+ filepath.Join(homeDir, ".config", "crush", "ignore"),
9090 } {
9191 if bts, err := os.ReadFile(name); err == nil {
9292 lines = append(lines, strings.Split(string(bts), "\n")...)
9393 }
9494 }
95โ return ignore.CompileIgnoreLines(lines...)
95+ return parsePatterns(lines, nil)
9696})
9797
98+// parsePatterns parses gitignore pattern strings into Pattern objects.
99+// domain is the path components where the patterns are defined (nil for global).
100+func parsePatterns(lines []string, domain []string) []gitignore.Pattern {
101+ var patterns []gitignore.Pattern
102+ for _, line := range lines {
103+ line = strings.TrimSpace(line)
104+ if line == "" || strings.HasPrefix(line, "#") {
105+ continue
106+ }
107+ patterns = append(patterns, gitignore.ParsePattern(line, domain))
108+ }
109+ return patterns
110+}
111+
98112type directoryLister struct {
99โ ignores *csync.Map[string, ignore.IgnoreParser]
100โ rootPath string
113+ // dirPatterns caches parsed patterns from .gitignore/.crushignore for each directory.
114+ // This avoids re-reading files when building combined matchers.
115+ dirPatterns *csync.Map[string, []gitignore.Pattern]
116+ // combinedMatchers caches a combined matcher for each directory that includes
117+ // all ancestor patterns. This allows O(1) matching per file.
118+ combinedMatchers *csync.Map[string, gitignore.Matcher]
119+ rootPath string
101120}
102121
103122func NewDirectoryLister(rootPath string) *directoryLister {
104โ dl := &directoryLister{
105โ rootPath: rootPath,
106โ ignores: csync.NewMap[string, ignore.IgnoreParser](),
123+ return &directoryLister{
124+ rootPath: rootPath,
125+ dirPatterns: csync.NewMap[string, []gitignore.Pattern](),
126+ combinedMatchers: csync.NewMap[string, gitignore.Matcher](),
107127 }
108โ dl.getIgnore(rootPath)
109โ return dl
110128}
111129
112โ// git checks, in order:
113โ// - ./.gitignore, ../.gitignore, etc, until repo root
114โ// ~/.config/git/ignore
115โ// ~/.gitignore
116โ//
117โ// This will do the following:
118โ// - the given ignorePatterns
119โ// - [commonIgnorePatterns]
120โ// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
121โ// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
122โ// ~/.config/git/ignore
123โ// ~/.gitignore
124โ// ~/.config/crush/ignore
125โfunc (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
130+// pathToComponents splits a path into its components for gitignore matching.
131+func pathToComponents(path string) []string {
132+ path = filepath.ToSlash(path)
133+ if path == "" || path == "." {
134+ return nil
135+ }
136+ return strings.Split(path, "/")
137+}
138+
139+// getDirPatterns returns the parsed patterns for a specific directory's
140+// .gitignore and .crushignore files. Results are cached.
141+func (dl *directoryLister) getDirPatterns(dir string) []gitignore.Pattern {
142+ return dl.dirPatterns.GetOrSet(dir, func() []gitignore.Pattern {
143+ var allPatterns []gitignore.Pattern
144+
145+ relPath, _ := filepath.Rel(dl.rootPath, dir)
146+ var domain []string
147+ if relPath != "" && relPath != "." {
148+ domain = pathToComponents(relPath)
149+ }
150+
151+ for _, ignoreFile := range []string{".gitignore", ".crushignore"} {
152+ ignPath := filepath.Join(dir, ignoreFile)
153+ if content, err := os.ReadFile(ignPath); err == nil {
154+ lines := strings.Split(string(content), "\n")
155+ allPatterns = append(allPatterns, parsePatterns(lines, domain)...)
156+ }
157+ }
158+ return allPatterns
159+ })
160+}
161+
162+// getCombinedMatcher returns a matcher that combines all gitignore patterns
163+// from the root to the given directory, plus common patterns and home patterns.
164+// Results are cached per directory, and we reuse parent directory matchers.
165+func (dl *directoryLister) getCombinedMatcher(dir string) gitignore.Matcher {
166+ return dl.combinedMatchers.GetOrSet(dir, func() gitignore.Matcher {
167+ var allPatterns []gitignore.Pattern
168+
169+ // Add common patterns first (lowest priority).
170+ allPatterns = append(allPatterns, commonIgnorePatterns()...)
171+
172+ // Add home ignore patterns.
173+ allPatterns = append(allPatterns, homeIgnorePatterns()...)
174+
175+ // Collect patterns from root to this directory.
176+ relDir, _ := filepath.Rel(dl.rootPath, dir)
177+ var pathParts []string
178+ if relDir != "" && relDir != "." {
179+ pathParts = pathToComponents(relDir)
180+ }
181+
182+ // Add patterns from each directory from root to current.
183+ currentPath := dl.rootPath
184+ allPatterns = append(allPatterns, dl.getDirPatterns(currentPath)...)
185+
186+ for _, part := range pathParts {
187+ currentPath = filepath.Join(currentPath, part)
188+ allPatterns = append(allPatterns, dl.getDirPatterns(currentPath)...)
189+ }
190+
191+ return gitignore.NewMatcher(allPatterns)
192+ })
193+}
194+
195+// shouldIgnore checks if a path should be ignored based on gitignore rules.
196+// This uses a combined matcher that includes all ancestor patterns for O(1) matching.
197+func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string, isDir bool) bool {
198+ base := filepath.Base(path)
199+
200+ // Fast path: O(1) lookup for commonly ignored directories.
201+ if isDir && fastIgnoreDirs[base] {
202+ return true
203+ }
204+
205+ // Check explicit ignore patterns.
126206 if len(ignorePatterns) > 0 {
127โ base := filepath.Base(path)
128207 for _, pattern := range ignorePatterns {
129208 if matched, err := filepath.Match(pattern, base); err == nil && matched {
130209 return true
@@ -132,8 +211,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
132211 }
133212 }
134213
135โ // Don't apply gitignore rules to the root directory itself
136โ // In gitignore semantics, patterns don't apply to the repo root
214+ // Don't apply gitignore rules to the root directory itself.
137215 if path == dl.rootPath {
138216 return false
139217 }
@@ -143,69 +221,24 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
143221 relPath = path
144222 }
145223
146โ if commonIgnorePatterns().MatchesPath(relPath) {
147โ slog.Debug("Ignoring common pattern", "path", relPath)
148โ return true
224+ pathComponents := pathToComponents(relPath)
225+ if len(pathComponents) == 0 {
226+ return false
149227 }
150228
229+ // Get the combined matcher for the parent directory.
151230 parentDir := filepath.Dir(path)
152โ ignoreParser := dl.getIgnore(parentDir)
153โ if ignoreParser.MatchesPath(relPath) {
154โ slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir)
155โ return true
156โ }
231+ matcher := dl.getCombinedMatcher(parentDir)
157232
158โ // For directories, also check with trailing slash (gitignore convention)
159โ if ignoreParser.MatchesPath(relPath + "/") {
160โ slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
233+ if matcher.Match(pathComponents, isDir) {
234+ slog.Debug("Ignoring path", "path", relPath)
161235 return true
162236 }
163237
164โ if dl.checkParentIgnores(relPath) {
165โ return true
166โ }
167โ
168โ if homeIgnore().MatchesPath(relPath) {
169โ slog.Debug("Ignoring home dir pattern", "path", relPath)
170โ return true
171โ }
172โ
173โ return false
174โ}
175โ
176โfunc (dl *directoryLister) checkParentIgnores(path string) bool {
177โ parent := filepath.Dir(filepath.Dir(path))
178โ for parent != "." && path != "." {
179โ if dl.getIgnore(parent).MatchesPath(path) {
180โ slog.Debug("Ignoring parent dir pattern", "path", path, "dir", parent)
181โ return true
182โ }
183โ if parent == dl.rootPath {
184โ break
185โ }
186โ parent = filepath.Dir(parent)
187โ }
188238 return false
189239}
190240
191โfunc (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
192โ return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
193โ var lines []string
194โ for _, ign := range []string{".crushignore", ".gitignore"} {
195โ name := filepath.Join(path, ign)
196โ if content, err := os.ReadFile(name); err == nil {
197โ lines = append(lines, strings.Split(string(content), "\n")...)
198โ }
199โ }
200โ if len(lines) == 0 {
201โ // Return a no-op parser to avoid nil checks
202โ return ignore.CompileIgnoreLines()
203โ }
204โ return ignore.CompileIgnoreLines(lines...)
205โ })
206โ}
207โ
208โ// ListDirectory lists files and directories in the specified path,
241+// ListDirectory lists files and directories in the specified path.
209242func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
210243 found := csync.NewSlice[string]()
211244 dl := NewDirectoryLister(initialPath)
@@ -224,15 +257,16 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int
224257 return nil // Skip files we don't have permission to access
225258 }
226259
227โ if dl.shouldIgnore(path, ignorePatterns) {
228โ if d.IsDir() {
260+ isDir := d.IsDir()
261+ if dl.shouldIgnore(path, ignorePatterns, isDir) {
262+ if isDir {
229263 return filepath.SkipDir
230264 }
231265 return nil
232266 }
233267
234268 if path != initialPath {
235โ if d.IsDir() {
269+ if isDir {
236270 path = path + string(filepath.Separator)
237271 }
238272 found.Append(path)