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/config"
12 "github.com/charmbracelet/crush/internal/csync"
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 := 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})
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 if dl.getIgnore(filepath.Dir(path)).MatchesPath(relPath) {
145 slog.Debug("ignoring dir pattern", "path", relPath, "dir", filepath.Dir(path))
146 return true
147 }
148
149 if dl.checkParentIgnores(relPath) {
150 return true
151 }
152
153 if homeIgnore().MatchesPath(relPath) {
154 slog.Debug("ignoring home dir pattern", "path", relPath)
155 return true
156 }
157
158 return false
159}
160
161func (dl *directoryLister) checkParentIgnores(path string) bool {
162 parent := filepath.Dir(filepath.Dir(path))
163 for parent != dl.rootPath && parent != "." && path != "." {
164 if dl.getIgnore(parent).MatchesPath(path) {
165 slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
166 return true
167 }
168 parent = filepath.Dir(parent)
169 }
170 return false
171}
172
173func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
174 return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
175 var lines []string
176 for _, ign := range []string{".crushignore", ".gitignore"} {
177 name := filepath.Join(path, ign)
178 if content, err := os.ReadFile(name); err == nil {
179 lines = append(lines, strings.Split(string(content), "\n")...)
180 }
181 }
182 if len(lines) == 0 {
183 // Return a no-op parser to avoid nil checks
184 return ignore.CompileIgnoreLines()
185 }
186 return ignore.CompileIgnoreLines(lines...)
187 })
188}
189
190// ListDirectory lists files and directories in the specified path,
191func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
192 var results []string
193 truncated := false
194 dl := NewDirectoryLister(initialPath)
195
196 conf := fastwalk.Config{
197 Follow: true,
198 // Use forward slashes when running a Windows binary under WSL or MSYS
199 ToSlash: fastwalk.DefaultToSlash(),
200 Sort: fastwalk.SortDirsFirst,
201 }
202
203 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
204 if err != nil {
205 return nil // Skip files we don't have permission to access
206 }
207
208 if dl.shouldIgnore(path, ignorePatterns) {
209 if d.IsDir() {
210 return filepath.SkipDir
211 }
212 return nil
213 }
214
215 if path != initialPath {
216 if d.IsDir() {
217 path = path + string(filepath.Separator)
218 }
219 results = append(results, path)
220 }
221
222 if limit > 0 && len(results) >= limit {
223 truncated = true
224 return filepath.SkipAll
225 }
226
227 return nil
228 })
229 if err != nil && len(results) == 0 {
230 return nil, truncated, err
231 }
232
233 return results, truncated, nil
234}