| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- package fsext
- import (
- "errors"
- "log/slog"
- "os"
- "path/filepath"
- "slices"
- "strings"
- "sync"
- "github.com/charlievieth/fastwalk"
- "github.com/charmbracelet/crush/internal/csync"
- "github.com/charmbracelet/crush/internal/home"
- ignore "github.com/sabhiram/go-gitignore"
- )
- // commonIgnorePatterns contains commonly ignored files and directories
- var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
- return ignore.CompileIgnoreLines(
- // Version control
- ".git",
- ".svn",
- ".hg",
- ".bzr",
- // IDE and editor files
- ".vscode",
- ".idea",
- "*.swp",
- "*.swo",
- "*~",
- ".DS_Store",
- "Thumbs.db",
- // Build artifacts and dependencies
- "node_modules",
- "target",
- "build",
- "dist",
- "out",
- "bin",
- "obj",
- "*.o",
- "*.so",
- "*.dylib",
- "*.dll",
- "*.exe",
- // Logs and temporary files
- "*.log",
- "*.tmp",
- "*.temp",
- ".cache",
- ".tmp",
- // Language-specific
- "__pycache__",
- "*.pyc",
- "*.pyo",
- ".pytest_cache",
- "vendor",
- "Cargo.lock",
- "package-lock.json",
- "yarn.lock",
- "pnpm-lock.yaml",
- // OS generated files
- ".Trash",
- ".Spotlight-V100",
- ".fseventsd",
- // Crush
- ".crush",
- // macOS stuff
- "OrbStack",
- ".local",
- ".share",
- )
- })
- var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
- home := home.Dir()
- var lines []string
- for _, name := range []string{
- filepath.Join(home, ".gitignore"),
- filepath.Join(home, ".config", "git", "ignore"),
- filepath.Join(home, ".config", "crush", "ignore"),
- } {
- if bts, err := os.ReadFile(name); err == nil {
- lines = append(lines, strings.Split(string(bts), "\n")...)
- }
- }
- return ignore.CompileIgnoreLines(lines...)
- })
- type directoryLister struct {
- ignores *csync.Map[string, ignore.IgnoreParser]
- rootPath string
- }
- func NewDirectoryLister(rootPath string) *directoryLister {
- dl := &directoryLister{
- rootPath: rootPath,
- ignores: csync.NewMap[string, ignore.IgnoreParser](),
- }
- dl.getIgnore(rootPath)
- return dl
- }
- // git checks, in order:
- // - ./.gitignore, ../.gitignore, etc, until repo root
- // ~/.config/git/ignore
- // ~/.gitignore
- //
- // This will do the following:
- // - the given ignorePatterns
- // - [commonIgnorePatterns]
- // - ./.gitignore, ../.gitignore, etc, until dl.rootPath
- // - ./.crushignore, ../.crushignore, etc, until dl.rootPath
- // ~/.config/git/ignore
- // ~/.gitignore
- // ~/.config/crush/ignore
- func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
- if len(ignorePatterns) > 0 {
- base := filepath.Base(path)
- for _, pattern := range ignorePatterns {
- if matched, err := filepath.Match(pattern, base); err == nil && matched {
- return true
- }
- }
- }
- // Don't apply gitignore rules to the root directory itself
- // In gitignore semantics, patterns don't apply to the repo root
- if path == dl.rootPath {
- return false
- }
- relPath, err := filepath.Rel(dl.rootPath, path)
- if err != nil {
- relPath = path
- }
- if commonIgnorePatterns().MatchesPath(relPath) {
- slog.Debug("ignoring common pattern", "path", relPath)
- return true
- }
- parentDir := filepath.Dir(path)
- ignoreParser := dl.getIgnore(parentDir)
- if ignoreParser.MatchesPath(relPath) {
- slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
- return true
- }
- // For directories, also check with trailing slash (gitignore convention)
- if ignoreParser.MatchesPath(relPath + "/") {
- slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
- return true
- }
- if dl.checkParentIgnores(relPath) {
- return true
- }
- if homeIgnore().MatchesPath(relPath) {
- slog.Debug("ignoring home dir pattern", "path", relPath)
- return true
- }
- return false
- }
- func (dl *directoryLister) checkParentIgnores(path string) bool {
- parent := filepath.Dir(filepath.Dir(path))
- for parent != "." && path != "." {
- if dl.getIgnore(parent).MatchesPath(path) {
- slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
- return true
- }
- if parent == dl.rootPath {
- break
- }
- parent = filepath.Dir(parent)
- }
- return false
- }
- func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
- return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
- var lines []string
- for _, ign := range []string{".crushignore", ".gitignore"} {
- name := filepath.Join(path, ign)
- if content, err := os.ReadFile(name); err == nil {
- lines = append(lines, strings.Split(string(content), "\n")...)
- }
- }
- if len(lines) == 0 {
- // Return a no-op parser to avoid nil checks
- return ignore.CompileIgnoreLines()
- }
- return ignore.CompileIgnoreLines(lines...)
- })
- }
- // ListDirectory lists files and directories in the specified path,
- func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
- found := csync.NewSlice[string]()
- dl := NewDirectoryLister(initialPath)
- slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
- conf := fastwalk.Config{
- Follow: true,
- ToSlash: fastwalk.DefaultToSlash(),
- Sort: fastwalk.SortDirsFirst,
- MaxDepth: depth,
- }
- err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return nil // Skip files we don't have permission to access
- }
- if dl.shouldIgnore(path, ignorePatterns) {
- if d.IsDir() {
- return filepath.SkipDir
- }
- return nil
- }
- if path != initialPath {
- if d.IsDir() {
- path = path + string(filepath.Separator)
- }
- found.Append(path)
- }
- if limit > 0 && found.Len() >= limit {
- return filepath.SkipAll
- }
- return nil
- })
- if err != nil && !errors.Is(err, filepath.SkipAll) {
- return nil, false, err
- }
- matches, truncated := truncate(slices.Collect(found.Seq()), limit)
- return matches, truncated || errors.Is(err, filepath.SkipAll), nil
- }
|