1package fsext
2
3import (
4 "log/slog"
5 "os"
6 "path/filepath"
7 "strings"
8 "sync"
9
10 "github.com/charlievieth/fastwalk"
11 "github.com/charmbracelet/crush/internal/csync"
12 "github.com/charmbracelet/crush/internal/home"
13 ignore "github.com/sabhiram/go-gitignore"
14)
15
16var IgnorePatters = []string{
17 // Version control
18 ".git",
19 ".svn",
20 ".hg",
21 ".bzr",
22
23 // IDE and editor files
24 ".vscode",
25 ".idea",
26 "*.swp",
27 "*.swo",
28 "*~",
29 ".DS_Store",
30 "Thumbs.db",
31
32 // Build artifacts and dependencies
33 "node_modules",
34 "target",
35 "build",
36 "dist",
37 "out",
38 "bin",
39 "obj",
40 "*.o",
41 "*.so",
42 "*.dylib",
43 "*.dll",
44 "*.exe",
45
46 // Logs and temporary files
47 "*.log",
48 "*.tmp",
49 "*.temp",
50 ".cache",
51 ".tmp",
52
53 // Language-specific
54 "__pycache__",
55 "*.pyc",
56 "*.pyo",
57 ".pytest_cache",
58 "vendor",
59 "Cargo.lock",
60 "package-lock.json",
61 "yarn.lock",
62 "pnpm-lock.yaml",
63
64 // OS generated files
65 ".Trash",
66 ".Spotlight-V100",
67 ".fseventsd",
68
69 // Crush
70 ".crush",
71}
72
73// commonIgnorePatterns contains commonly ignored files and directories
74var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
75 return ignore.CompileIgnoreLines(IgnorePatters...)
76})
77
78var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
79 home := home.Dir()
80 var lines []string
81 for _, name := range []string{
82 filepath.Join(home, ".gitignore"),
83 filepath.Join(home, ".config", "git", "ignore"),
84 filepath.Join(home, ".config", "crush", "ignore"),
85 } {
86 if bts, err := os.ReadFile(name); err == nil {
87 lines = append(lines, strings.Split(string(bts), "\n")...)
88 }
89 }
90 return ignore.CompileIgnoreLines(lines...)
91})
92
93type directoryLister struct {
94 ignores *csync.Map[string, ignore.IgnoreParser]
95 rootPath string
96}
97
98func NewDirectoryLister(rootPath string) *directoryLister {
99 dl := &directoryLister{
100 rootPath: rootPath,
101 ignores: csync.NewMap[string, ignore.IgnoreParser](),
102 }
103 dl.getIgnore(rootPath)
104 return dl
105}
106
107// git checks, in order:
108// - ./.gitignore, ../.gitignore, etc, until repo root
109// ~/.config/git/ignore
110// ~/.gitignore
111//
112// This will do the following:
113// - the given ignorePatterns
114// - [commonIgnorePatterns]
115// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
116// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
117// ~/.config/git/ignore
118// ~/.gitignore
119// ~/.config/crush/ignore
120func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
121 if len(ignorePatterns) > 0 {
122 base := filepath.Base(path)
123 for _, pattern := range ignorePatterns {
124 if matched, err := filepath.Match(pattern, base); err == nil && matched {
125 return true
126 }
127 }
128 }
129
130 // Don't apply gitignore rules to the root directory itself
131 // In gitignore semantics, patterns don't apply to the repo root
132 if path == dl.rootPath {
133 return false
134 }
135
136 relPath, err := filepath.Rel(dl.rootPath, path)
137 if err != nil {
138 relPath = path
139 }
140
141 if commonIgnorePatterns().MatchesPath(relPath) {
142 slog.Debug("ignoring common pattern", "path", relPath)
143 return true
144 }
145
146 parentDir := filepath.Dir(path)
147 ignoreParser := dl.getIgnore(parentDir)
148 if ignoreParser.MatchesPath(relPath) {
149 slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
150 return true
151 }
152
153 // For directories, also check with trailing slash (gitignore convention)
154 if ignoreParser.MatchesPath(relPath + "/") {
155 slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
156 return true
157 }
158
159 if dl.checkParentIgnores(relPath) {
160 return true
161 }
162
163 if homeIgnore().MatchesPath(relPath) {
164 slog.Debug("ignoring home dir pattern", "path", relPath)
165 return true
166 }
167
168 return false
169}
170
171func (dl *directoryLister) checkParentIgnores(path string) bool {
172 parent := filepath.Dir(filepath.Dir(path))
173 for parent != "." && path != "." {
174 if dl.getIgnore(parent).MatchesPath(path) {
175 slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
176 return true
177 }
178 if parent == dl.rootPath {
179 break
180 }
181 parent = filepath.Dir(parent)
182 }
183 return false
184}
185
186func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
187 return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
188 var lines []string
189 for _, ign := range []string{".crushignore", ".gitignore"} {
190 name := filepath.Join(path, ign)
191 if content, err := os.ReadFile(name); err == nil {
192 lines = append(lines, strings.Split(string(content), "\n")...)
193 }
194 }
195 if len(lines) == 0 {
196 // Return a no-op parser to avoid nil checks
197 return ignore.CompileIgnoreLines()
198 }
199 return ignore.CompileIgnoreLines(lines...)
200 })
201}
202
203// ListDirectory lists files and directories in the specified path,
204func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
205 var results []string
206 truncated := false
207 dl := NewDirectoryLister(initialPath)
208
209 conf := fastwalk.Config{
210 Follow: true,
211 // Use forward slashes when running a Windows binary under WSL or MSYS
212 ToSlash: fastwalk.DefaultToSlash(),
213 Sort: fastwalk.SortDirsFirst,
214 }
215
216 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
217 if err != nil {
218 return nil // Skip files we don't have permission to access
219 }
220
221 if dl.shouldIgnore(path, ignorePatterns) {
222 if d.IsDir() {
223 return filepath.SkipDir
224 }
225 return nil
226 }
227
228 if path != initialPath {
229 if d.IsDir() {
230 path = path + string(filepath.Separator)
231 }
232 results = append(results, path)
233 }
234
235 if limit > 0 && len(results) >= limit {
236 truncated = true
237 return filepath.SkipAll
238 }
239
240 return nil
241 })
242 if err != nil && len(results) == 0 {
243 return nil, truncated, err
244 }
245
246 return results, truncated, nil
247}