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	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}