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