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