ls.go

  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}