@@ -12,29 +12,45 @@ import (
12 "github.com/charlievieth/fastwalk"
12 "github.com/charlievieth/fastwalk"
13 "github.com/charmbracelet/crush/internal/csync"
13 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/home"
14 "github.com/charmbracelet/crush/internal/home"
15− ignore "github.com/sabhiram/go-gitignore"
15+ "github.com/go-git/go-git/v5/plumbing/format/gitignore"
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{
27+ "node_modules": true,
29+ ".pytest_cache": true,
33+ ".Spotlight-V100": true,
18−// commonIgnorePatterns contains commonly ignored files and directories
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.
19−var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
44+var commonIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern {
20− return ignore.CompileIgnoreLines(
45+ patterns := []string{
27− // IDE and editor files
46+ // IDE and editor files (wildcards)
33 ".DS_Store",
50 ".DS_Store",
34 "Thumbs.db",
51 "Thumbs.db",
36− // Build artifacts and dependencies
53+ // Build artifacts (non-fastIgnoreDirs)
@@ -47,84 +63,147 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
50− // Logs and temporary files
66+ // Logs and temporary files (wildcards)
57− // Language-specific
71+ // Language-specific (wildcards and non-fastIgnoreDirs)
63 "Cargo.lock",
75 "Cargo.lock",
64 "package-lock.json",
76 "package-lock.json",
65 "yarn.lock",
77 "yarn.lock",
66 "pnpm-lock.yaml",
78 "pnpm-lock.yaml",
68− // OS generated files
80+ return parsePatterns(patterns, nil)
83−var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
83+var homeIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern {
84− home := home.Dir()
84+ homeDir := home.Dir()
85 var lines []string
85 var lines []string
86 for _, name := range []string{
86 for _, name := range []string{
87− filepath.Join(home, ".gitignore"),
87+ filepath.Join(homeDir, ".gitignore"),
88− filepath.Join(home, ".config", "git", "ignore"),
88+ filepath.Join(homeDir, ".config", "git", "ignore"),
89− filepath.Join(home, ".config", "crush", "ignore"),
89+ filepath.Join(homeDir, ".config", "crush", "ignore"),
91 if bts, err := os.ReadFile(name); err == nil {
91 if bts, err := os.ReadFile(name); err == nil {
92 lines = append(lines, strings.Split(string(bts), "\n")...)
92 lines = append(lines, strings.Split(string(bts), "\n")...)
95− return ignore.CompileIgnoreLines(lines...)
95+ return parsePatterns(lines, nil)
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, "#") {
107+ patterns = append(patterns, gitignore.ParsePattern(line, domain))
98type directoryLister struct {
112type directoryLister struct {
99− ignores *csync.Map[string, ignore.IgnoreParser]
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]
100− rootPath string
119+ rootPath string
103func NewDirectoryLister(rootPath string) *directoryLister {
122func NewDirectoryLister(rootPath string) *directoryLister {
104− dl := &directoryLister{
123+ return &directoryLister{
105− rootPath: rootPath,
124+ rootPath: rootPath,
106− ignores: csync.NewMap[string, ignore.IgnoreParser](),
125+ dirPatterns: csync.NewMap[string, []gitignore.Pattern](),
126+ combinedMatchers: csync.NewMap[string, gitignore.Matcher](),
108− dl.getIgnore(rootPath)
112−// git checks, in order:
130+// pathToComponents splits a path into its components for gitignore matching.
113−// - ./.gitignore, ../.gitignore, etc, until repo root
131+func pathToComponents(path string) []string {
114−// ~/.config/git/ignore
132+ path = filepath.ToSlash(path)
115−// ~/.gitignore
133+ if path == "" || path == "." {
117−// This will do the following:
135+ }
118−// - the given ignorePatterns
136+ return strings.Split(path, "/")
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
145+ relPath, _ := filepath.Rel(dl.rootPath, dir)
147+ if relPath != "" && relPath != "." {
148+ domain = pathToComponents(relPath)
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)...)
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
169+ // Add common patterns first (lowest priority).
119−// - [commonIgnorePatterns]
170+ allPatterns = append(allPatterns, commonIgnorePatterns()...)
120−// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
171+
121−// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
172+ // Add home ignore patterns.
122−// ~/.config/git/ignore
173+ allPatterns = append(allPatterns, homeIgnorePatterns()...)
124−// ~/.config/crush/ignore
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)
182+ // Add patterns from each directory from root to current.
183+ currentPath := dl.rootPath
184+ allPatterns = append(allPatterns, dl.getDirPatterns(currentPath)...)
186+ for _, part := range pathParts {
187+ currentPath = filepath.Join(currentPath, part)
188+ allPatterns = append(allPatterns, dl.getDirPatterns(currentPath)...)
191+ return gitignore.NewMatcher(allPatterns)
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.
125−func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
197+func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string, isDir bool) bool {
198+ base := filepath.Base(path)
200+ // Fast path: O(1) lookup for commonly ignored directories.
201+ if isDir && fastIgnoreDirs[base] {
205+ // Check explicit ignore patterns.
126 if len(ignorePatterns) > 0 {
206 if len(ignorePatterns) > 0 {
127− base := filepath.Base(path)
128 for _, pattern := range ignorePatterns {
207 for _, pattern := range ignorePatterns {
129 if matched, err := filepath.Match(pattern, base); err == nil && matched {
208 if matched, err := filepath.Match(pattern, base); err == nil && matched {
130 return true
209 return true
@@ -132,8 +211,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
135− // Don't apply gitignore rules to the root directory itself
214+ // Don't apply gitignore rules to the root directory itself.
136− // In gitignore semantics, patterns don't apply to the repo root
137 if path == dl.rootPath {
215 if path == dl.rootPath {
138 return false
216 return false
@@ -143,69 +221,24 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
143 relPath = path
221 relPath = path
146− if commonIgnorePatterns().MatchesPath(relPath) {
224+ pathComponents := pathToComponents(relPath)
147− slog.Debug("Ignoring common pattern", "path", relPath)
225+ if len(pathComponents) == 0 {
148− return true
226+ return false
229+ // Get the combined matcher for the parent directory.
151 parentDir := filepath.Dir(path)
230 parentDir := filepath.Dir(path)
152− ignoreParser := dl.getIgnore(parentDir)
231+ matcher := dl.getCombinedMatcher(parentDir)
153− if ignoreParser.MatchesPath(relPath) {
154− slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir)
158− // For directories, also check with trailing slash (gitignore convention)
233+ if matcher.Match(pathComponents, isDir) {
159− if ignoreParser.MatchesPath(relPath + "/") {
160− slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
234+ slog.Debug("Ignoring path", "path", relPath)
161 return true
235 return true
164− if dl.checkParentIgnores(relPath) {
168− if homeIgnore().MatchesPath(relPath) {
169− slog.Debug("Ignoring home dir pattern", "path", relPath)
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)
183− if parent == dl.rootPath {
186− parent = filepath.Dir(parent)
188 return false
238 return false
191−func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
192− return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
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")...)
200− if len(lines) == 0 {
201− // Return a no-op parser to avoid nil checks
202− return ignore.CompileIgnoreLines()
204− return ignore.CompileIgnoreLines(lines...)
208−// ListDirectory lists files and directories in the specified path,
241+// ListDirectory lists files and directories in the specified path.
209func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
242func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
210 found := csync.NewSlice[string]()
243 found := csync.NewSlice[string]()
211 dl := NewDirectoryLister(initialPath)
244 dl := NewDirectoryLister(initialPath)
@@ -224,15 +257,16 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int
224 return nil // Skip files we don't have permission to access
257 return nil // Skip files we don't have permission to access
227− if dl.shouldIgnore(path, ignorePatterns) {
261+ if dl.shouldIgnore(path, ignorePatterns, isDir) {
228− if d.IsDir() {
262+ if isDir {
229 return filepath.SkipDir
263 return filepath.SkipDir
231 return nil
265 return nil
234 if path != initialPath {
268 if path != initialPath {
235− if d.IsDir() {
269+ if isDir {
236 path = path + string(filepath.Separator)
270 path = path + string(filepath.Separator)
238 found.Append(path)
272 found.Append(path)