ls.go 5.7 KB

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