@@ -1,137 +1,186 @@
11package fsext
22
33import (
4+ "log/slog"
45 "os"
56 "path/filepath"
7+ "strings"
8+ "sync"
69
710 "github.com/charlievieth/fastwalk"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/csync"
813 ignore "github.com/sabhiram/go-gitignore"
914)
1015
11−// CommonIgnorePatterns contains commonly ignored files and directories
12−var CommonIgnorePatterns = []string{
13− // Version control
14− ".git",
15− ".svn",
16− ".hg",
17− ".bzr",
18−
19− // IDE and editor files
20− ".vscode",
21− ".idea",
22− "*.swp",
23− "*.swo",
24− "*~",
25− ".DS_Store",
26− "Thumbs.db",
27−
28− // Build artifacts and dependencies
29− "node_modules",
30− "target",
31− "build",
32− "dist",
33− "out",
34− "bin",
35− "obj",
36− "*.o",
37− "*.so",
38− "*.dylib",
39− "*.dll",
40− "*.exe",
41−
42− // Logs and temporary files
43− "*.log",
44− "*.tmp",
45− "*.temp",
46− ".cache",
47− ".tmp",
48−
49− // Language-specific
50− "__pycache__",
51− "*.pyc",
52− "*.pyo",
53− ".pytest_cache",
54− "vendor",
55− "Cargo.lock",
56− "package-lock.json",
57− "yarn.lock",
58− "pnpm-lock.yaml",
59−
60− // OS generated files
61− ".Trash",
62− ".Spotlight-V100",
63− ".fseventsd",
64−
65− // Crush
66− ".crush",
67−}
16+// commonIgnorePatterns contains commonly ignored files and directories
17+var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
18+ return ignore.CompileIgnoreLines(
19+ // Version control
20+ ".git",
21+ ".svn",
22+ ".hg",
23+ ".bzr",
24+
25+ // IDE and editor files
26+ ".vscode",
27+ ".idea",
28+ "*.swp",
29+ "*.swo",
30+ "*~",
31+ ".DS_Store",
32+ "Thumbs.db",
33+
34+ // Build artifacts and dependencies
35+ "node_modules",
36+ "target",
37+ "build",
38+ "dist",
39+ "out",
40+ "bin",
41+ "obj",
42+ "*.o",
43+ "*.so",
44+ "*.dylib",
45+ "*.dll",
46+ "*.exe",
47+
48+ // Logs and temporary files
49+ "*.log",
50+ "*.tmp",
51+ "*.temp",
52+ ".cache",
53+ ".tmp",
54+
55+ // Language-specific
56+ "__pycache__",
57+ "*.pyc",
58+ "*.pyo",
59+ ".pytest_cache",
60+ "vendor",
61+ "Cargo.lock",
62+ "package-lock.json",
63+ "yarn.lock",
64+ "pnpm-lock.yaml",
65+
66+ // OS generated files
67+ ".Trash",
68+ ".Spotlight-V100",
69+ ".fseventsd",
70+
71+ // Crush
72+ ".crush",
73+ )
74+})
75+
76+var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
77+ home := config.HomeDir()
78+ var lines []string
79+ for _, name := range []string{
80+ filepath.Join(home, ".gitignore"),
81+ filepath.Join(home, ".config", "git", "ignore"),
82+ filepath.Join(home, ".config", "crush", "ignore"),
83+ } {
84+ if bts, err := os.ReadFile(name); err == nil {
85+ lines = append(lines, strings.Split(string(bts), "\n")...)
86+ }
87+ }
88+ return ignore.CompileIgnoreLines(lines...)
89+})
6890
69−type DirectoryLister struct {
70− gitignore *ignore.GitIgnore
71− crushignore *ignore.GitIgnore
72− commonIgnore *ignore.GitIgnore
73− rootPath string
91+type directoryLister struct {
92+ ignores *csync.Map[string, ignore.IgnoreParser]
93+ rootPath string
7494}
7595
76−func NewDirectoryLister(rootPath string) *DirectoryLister {
77− dl := &DirectoryLister{
96+func NewDirectoryLister(rootPath string) *directoryLister {
97+ dl := &directoryLister{
7898 rootPath: rootPath,
99+ ignores: csync.NewMap[string, ignore.IgnoreParser](),
79100 }
101+ dl.getIgnore(rootPath)
102+ return dl
103+}
80104
81− // Load gitignore if it exists
82− gitignorePath := filepath.Join(rootPath, ".gitignore")
83− if _, err := os.Stat(gitignorePath); err == nil {
84− if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
85− dl.gitignore = gi
86− }
87− }
88−
89− // Load crushignore if it exists
90− crushignorePath := filepath.Join(rootPath, ".crushignore")
91− if _, err := os.Stat(crushignorePath); err == nil {
92− if ci, err := ignore.CompileIgnoreFile(crushignorePath); err == nil {
93− dl.crushignore = ci
105+// git checks, in order:
106+// - ./.gitignore, ../.gitignore, etc, until repo root
107+// ~/.config/git/ignore
108+// ~/.gitignore
109+//
110+// This will do the following:
111+// - the given ignorePatterns
112+// - [commonIgnorePatterns]
113+// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
114+// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
115+// ~/.config/git/ignore
116+// ~/.gitignore
117+// ~/.config/crush/ignore
118+func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
119+ if len(ignorePatterns) > 0 {
120+ base := filepath.Base(path)
121+ for _, pattern := range ignorePatterns {
122+ if matched, err := filepath.Match(pattern, base); err == nil && matched {
123+ return true
124+ }
94125 }
95126 }
96127
97− // Create common ignore patterns
98− dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...)
99−
100− return dl
101−}
102−
103−func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
104128 relPath, err := filepath.Rel(dl.rootPath, path)
105129 if err != nil {
106130 relPath = path
107131 }
108132
109− // Check common ignore patterns
110− if dl.commonIgnore.MatchesPath(relPath) {
133+ if commonIgnorePatterns().MatchesPath(relPath) {
134+ slog.Debug("ingoring common pattern", "path", relPath)
111135 return true
112136 }
113137
114− // Check gitignore patterns if available
115− if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) {
138+ if dl.getIgnore(filepath.Dir(path)).MatchesPath(relPath) {
139+ slog.Debug("ingoring dir pattern", "path", relPath, "dir", filepath.Dir(path))
116140 return true
117141 }
118142
119− // Check crushignore patterns if available
120− if dl.crushignore != nil && dl.crushignore.MatchesPath(relPath) {
143+ if dl.checkParentIgnores(relPath) {
121144 return true
122145 }
123146
124− base := filepath.Base(path)
147+ if homeIgnore().MatchesPath(relPath) {
148+ slog.Debug("ingoring home dir pattern", "path", relPath)
149+ return true
150+ }
151+
152+ return false
153+}
125154
126− for _, pattern := range ignorePatterns {
127− matched, err := filepath.Match(pattern, base)
128− if err == nil && matched {
155+func (dl *directoryLister) checkParentIgnores(path string) bool {
156+ parent := filepath.Dir(filepath.Dir(path))
157+ for parent != dl.rootPath && parent != "." && path != "." {
158+ if dl.getIgnore(parent).MatchesPath(path) {
159+ slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
129160 return true
130161 }
162+ parent = filepath.Dir(parent)
131163 }
132164 return false
133165}
134166
167+func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
168+ return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
169+ var lines []string
170+ for _, ign := range []string{".crushignore", ".gitignore"} {
171+ name := filepath.Join(path, ign)
172+ if content, err := os.ReadFile(name); err == nil {
173+ lines = append(lines, strings.Split(string(content), "\n")...)
174+ }
175+ }
176+ if len(lines) == 0 {
177+ // Return a no-op parser to avoid nil checks
178+ return ignore.CompileIgnoreLines()
179+ }
180+ return ignore.CompileIgnoreLines(lines...)
181+ })
182+}
183+
135184// ListDirectory lists files and directories in the specified path,
136185func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
137186 var results []string
@@ -144,6 +193,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
144193 ToSlash: fastwalk.DefaultToSlash(),
145194 Sort: fastwalk.SortDirsFirst,
146195 }
196+
147197 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
148198 if err != nil {
149199 return nil // Skip files we don't have permission to access