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
16// commonIgnorePatterns contains commonly ignored files and directories
17var 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
76var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
77 home := home.Dir()
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})
90
91type directoryLister struct {
92 ignores *csync.Map[string, ignore.IgnoreParser]
93 rootPath string
94}
95
96func NewDirectoryLister(rootPath string) *directoryLister {
97 dl := &directoryLister{
98 rootPath: rootPath,
99 ignores: csync.NewMap[string, ignore.IgnoreParser](),
100 }
101 dl.getIgnore(rootPath)
102 return dl
103}
104
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
118func (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 }
125 }
126 }
127
128 // Don't apply gitignore rules to the root directory itself
129 // In gitignore semantics, patterns don't apply to the repo root
130 if path == dl.rootPath {
131 return false
132 }
133
134 relPath, err := filepath.Rel(dl.rootPath, path)
135 if err != nil {
136 relPath = path
137 }
138
139 if commonIgnorePatterns().MatchesPath(relPath) {
140 slog.Debug("ignoring common pattern", "path", relPath)
141 return true
142 }
143
144 parentDir := filepath.Dir(path)
145 ignoreParser := dl.getIgnore(parentDir)
146 if ignoreParser.MatchesPath(relPath) {
147 slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
148 return true
149 }
150
151 // For directories, also check with trailing slash (gitignore convention)
152 if ignoreParser.MatchesPath(relPath + "/") {
153 slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
154 return true
155 }
156
157 if dl.checkParentIgnores(relPath) {
158 return true
159 }
160
161 if homeIgnore().MatchesPath(relPath) {
162 slog.Debug("ignoring home dir pattern", "path", relPath)
163 return true
164 }
165
166 return false
167}
168
169func (dl *directoryLister) checkParentIgnores(path string) bool {
170 parent := filepath.Dir(filepath.Dir(path))
171 for parent != "." && path != "." {
172 if dl.getIgnore(parent).MatchesPath(path) {
173 slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
174 return true
175 }
176 if parent == dl.rootPath {
177 break
178 }
179 parent = filepath.Dir(parent)
180 }
181 return false
182}
183
184func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
185 return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
186 var lines []string
187 for _, ign := range []string{".crushignore", ".gitignore"} {
188 name := filepath.Join(path, ign)
189 if content, err := os.ReadFile(name); err == nil {
190 lines = append(lines, strings.Split(string(content), "\n")...)
191 }
192 }
193 if len(lines) == 0 {
194 // Return a no-op parser to avoid nil checks
195 return ignore.CompileIgnoreLines()
196 }
197 return ignore.CompileIgnoreLines(lines...)
198 })
199}
200
201// ListDirectory lists files and directories in the specified path,
202func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
203 var results []string
204 truncated := false
205 dl := NewDirectoryLister(initialPath)
206
207 conf := fastwalk.Config{
208 Follow: true,
209 // Use forward slashes when running a Windows binary under WSL or MSYS
210 ToSlash: fastwalk.DefaultToSlash(),
211 Sort: fastwalk.SortDirsFirst,
212 }
213
214 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
215 if err != nil {
216 return nil // Skip files we don't have permission to access
217 }
218
219 if dl.shouldIgnore(path, ignorePatterns) {
220 if d.IsDir() {
221 return filepath.SkipDir
222 }
223 return nil
224 }
225
226 if path != initialPath {
227 if d.IsDir() {
228 path = path + string(filepath.Separator)
229 }
230 results = append(results, path)
231 }
232
233 if limit > 0 && len(results) >= limit {
234 truncated = true
235 return filepath.SkipAll
236 }
237
238 return nil
239 })
240 if err != nil && len(results) == 0 {
241 return nil, truncated, err
242 }
243
244 return results, truncated, nil
245}