diff --git a/go.mod b/go.mod index 2d5e7c78db09abe77f7aef404c051e57e24043cd..13493454d8b6a0583b834bc6aa19a5c73be83327 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 + github.com/go-git/go-git/v5 v5.16.5 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 @@ -53,7 +54,6 @@ require ( github.com/pressly/goose/v3 v3.26.0 github.com/qjebbs/go-jsons v1.0.0-alpha.4 github.com/rivo/uniseg v0.4.7 - github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sahilm/fuzzy v0.1.1 github.com/sourcegraph/jsonrpc2 v0.2.1 github.com/spf13/cobra v1.10.2 @@ -110,6 +110,8 @@ require ( github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -127,6 +129,7 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kaptinlin/go-i18n v0.2.3 // indirect github.com/kaptinlin/jsonpointer v0.4.9 // indirect github.com/kaptinlin/jsonschema v0.6.10 // indirect @@ -149,7 +152,6 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect @@ -185,6 +187,7 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 8469b97cf1ce51255d4194799f9231bfaf74fa59..f29030cec4205073c997229175b4fc70d65e1934 100644 --- a/go.sum +++ b/go.sum @@ -164,6 +164,12 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= +github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -215,6 +221,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4= @@ -286,6 +294,8 @@ github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFu github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -303,8 +313,6 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= -github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= @@ -324,7 +332,6 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= @@ -502,10 +509,11 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/internal/fsext/fileutil.go b/internal/fsext/fileutil.go index 182b145a609311d20544d399c1212097c7519dda..c091820935d9c13142b12d3bd79d8c023a42a2fd 100644 --- a/internal/fsext/fileutil.go +++ b/internal/fsext/fileutil.go @@ -70,10 +70,16 @@ func NewFastGlobWalker(searchPath string) *FastGlobWalker { } } -// ShouldSkip checks if a path should be skipped based on hierarchical gitignore, -// crushignore, and hidden file rules +// ShouldSkip checks if a file path should be skipped based on hierarchical gitignore, +// crushignore, and hidden file rules. func (w *FastGlobWalker) ShouldSkip(path string) bool { - return w.directoryLister.shouldIgnore(path, nil) + return w.directoryLister.shouldIgnore(path, nil, false) +} + +// ShouldSkipDir checks if a directory path should be skipped based on hierarchical +// gitignore, crushignore, and hidden file rules. +func (w *FastGlobWalker) ShouldSkipDir(path string) bool { + return w.directoryLister.shouldIgnore(path, nil, true) } func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) { @@ -93,14 +99,15 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, return nil // Skip files we can't access } - if d.IsDir() { - if walker.ShouldSkip(path) { + isDir := d.IsDir() + if isDir { + if walker.ShouldSkipDir(path) { return filepath.SkipDir } - } - - if walker.ShouldSkip(path) { - return nil + } else { + if walker.ShouldSkip(path) { + return nil + } } relPath, err := filepath.Rel(searchPath, path) @@ -145,10 +152,12 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, } // ShouldExcludeFile checks if a file should be excluded from processing -// based on common patterns and ignore rules +// based on common patterns and ignore rules. func ShouldExcludeFile(rootPath, filePath string) bool { + info, err := os.Stat(filePath) + isDir := err == nil && info.IsDir() return NewDirectoryLister(rootPath). - shouldIgnore(filePath, nil) + shouldIgnore(filePath, nil, isDir) } func PrettyPath(path string) string { diff --git a/internal/fsext/ignore_test.go b/internal/fsext/ignore_test.go index a652f3a285fd256840fb3a711fb36e0217a43e28..e5e34e85a4c9bf0b207703aa0cf2f0459e03a427 100644 --- a/internal/fsext/ignore_test.go +++ b/internal/fsext/ignore_test.go @@ -21,9 +21,9 @@ func TestCrushIgnore(t *testing.T) { require.NoError(t, os.WriteFile(".crushignore", []byte("*.log\n"), 0o644)) dl := NewDirectoryLister(tempDir) - require.True(t, dl.shouldIgnore("test2.log", nil), ".log files should be ignored") - require.False(t, dl.shouldIgnore("test1.txt", nil), ".txt files should not be ignored") - require.True(t, dl.shouldIgnore("test3.tmp", nil), ".tmp files should be ignored by common patterns") + require.True(t, dl.shouldIgnore("test2.log", nil, false), ".log files should be ignored") + require.False(t, dl.shouldIgnore("test1.txt", nil, false), ".txt files should not be ignored") + require.True(t, dl.shouldIgnore("test3.tmp", nil, false), ".tmp files should be ignored by common patterns") } func TestShouldExcludeFile(t *testing.T) { diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index b541a4a0fedd78c866fa274fc183fabe4c833edd..afc81bc156205dcc624a3a45f693047bb5be835e 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -12,29 +12,45 @@ import ( "github.com/charlievieth/fastwalk" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/home" - ignore "github.com/sabhiram/go-gitignore" + "github.com/go-git/go-git/v5/plumbing/format/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", +// fastIgnoreDirs is a set of directory names that are always ignored. +// This provides O(1) lookup for common cases to avoid expensive pattern matching. +var fastIgnoreDirs = map[string]bool{ + ".git": true, + ".svn": true, + ".hg": true, + ".bzr": true, + ".vscode": true, + ".idea": true, + "node_modules": true, + "__pycache__": true, + ".pytest_cache": true, + ".cache": true, + ".tmp": true, + ".Trash": true, + ".Spotlight-V100": true, + ".fseventsd": true, + ".crush": true, + "OrbStack": true, + ".local": true, + ".share": true, +} + +// commonIgnorePatterns contains commonly ignored files and directories. +// Note: Exact directory names that are in fastIgnoreDirs are handled there for O(1) lookup. +// This list contains wildcard patterns and file-specific patterns. +var commonIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern { + patterns := []string{ + // IDE and editor files (wildcards) "*.swp", "*.swo", "*~", ".DS_Store", "Thumbs.db", - // Build artifacts and dependencies - "node_modules", + // Build artifacts (non-fastIgnoreDirs) "target", "build", "dist", @@ -47,84 +63,147 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser { "*.dll", "*.exe", - // Logs and temporary files + // Logs and temporary files (wildcards) "*.log", "*.tmp", "*.temp", - ".cache", - ".tmp", - // Language-specific - "__pycache__", + // Language-specific (wildcards and non-fastIgnoreDirs) "*.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", - ) + } + return parsePatterns(patterns, nil) }) -var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser { - home := home.Dir() +var homeIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern { + homeDir := 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"), + filepath.Join(homeDir, ".gitignore"), + filepath.Join(homeDir, ".config", "git", "ignore"), + filepath.Join(homeDir, ".config", "crush", "ignore"), } { if bts, err := os.ReadFile(name); err == nil { lines = append(lines, strings.Split(string(bts), "\n")...) } } - return ignore.CompileIgnoreLines(lines...) + return parsePatterns(lines, nil) }) +// parsePatterns parses gitignore pattern strings into Pattern objects. +// domain is the path components where the patterns are defined (nil for global). +func parsePatterns(lines []string, domain []string) []gitignore.Pattern { + var patterns []gitignore.Pattern + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + patterns = append(patterns, gitignore.ParsePattern(line, domain)) + } + return patterns +} + type directoryLister struct { - ignores *csync.Map[string, ignore.IgnoreParser] - rootPath string + // dirPatterns caches parsed patterns from .gitignore/.crushignore for each directory. + // This avoids re-reading files when building combined matchers. + dirPatterns *csync.Map[string, []gitignore.Pattern] + // combinedMatchers caches a combined matcher for each directory that includes + // all ancestor patterns. This allows O(1) matching per file. + combinedMatchers *csync.Map[string, gitignore.Matcher] + rootPath string } func NewDirectoryLister(rootPath string) *directoryLister { - dl := &directoryLister{ - rootPath: rootPath, - ignores: csync.NewMap[string, ignore.IgnoreParser](), + return &directoryLister{ + rootPath: rootPath, + dirPatterns: csync.NewMap[string, []gitignore.Pattern](), + combinedMatchers: csync.NewMap[string, gitignore.Matcher](), } - 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 { +// pathToComponents splits a path into its components for gitignore matching. +func pathToComponents(path string) []string { + path = filepath.ToSlash(path) + if path == "" || path == "." { + return nil + } + return strings.Split(path, "/") +} + +// getDirPatterns returns the parsed patterns for a specific directory's +// .gitignore and .crushignore files. Results are cached. +func (dl *directoryLister) getDirPatterns(dir string) []gitignore.Pattern { + return dl.dirPatterns.GetOrSet(dir, func() []gitignore.Pattern { + var allPatterns []gitignore.Pattern + + relPath, _ := filepath.Rel(dl.rootPath, dir) + var domain []string + if relPath != "" && relPath != "." { + domain = pathToComponents(relPath) + } + + for _, ignoreFile := range []string{".gitignore", ".crushignore"} { + ignPath := filepath.Join(dir, ignoreFile) + if content, err := os.ReadFile(ignPath); err == nil { + lines := strings.Split(string(content), "\n") + allPatterns = append(allPatterns, parsePatterns(lines, domain)...) + } + } + return allPatterns + }) +} + +// getCombinedMatcher returns a matcher that combines all gitignore patterns +// from the root to the given directory, plus common patterns and home patterns. +// Results are cached per directory, and we reuse parent directory matchers. +func (dl *directoryLister) getCombinedMatcher(dir string) gitignore.Matcher { + return dl.combinedMatchers.GetOrSet(dir, func() gitignore.Matcher { + var allPatterns []gitignore.Pattern + + // Add common patterns first (lowest priority). + allPatterns = append(allPatterns, commonIgnorePatterns()...) + + // Add home ignore patterns. + allPatterns = append(allPatterns, homeIgnorePatterns()...) + + // Collect patterns from root to this directory. + relDir, _ := filepath.Rel(dl.rootPath, dir) + var pathParts []string + if relDir != "" && relDir != "." { + pathParts = pathToComponents(relDir) + } + + // Add patterns from each directory from root to current. + currentPath := dl.rootPath + allPatterns = append(allPatterns, dl.getDirPatterns(currentPath)...) + + for _, part := range pathParts { + currentPath = filepath.Join(currentPath, part) + allPatterns = append(allPatterns, dl.getDirPatterns(currentPath)...) + } + + return gitignore.NewMatcher(allPatterns) + }) +} + +// shouldIgnore checks if a path should be ignored based on gitignore rules. +// This uses a combined matcher that includes all ancestor patterns for O(1) matching. +func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string, isDir bool) bool { + base := filepath.Base(path) + + // Fast path: O(1) lookup for commonly ignored directories. + if isDir && fastIgnoreDirs[base] { + return true + } + + // Check explicit ignore patterns. if len(ignorePatterns) > 0 { - base := filepath.Base(path) for _, pattern := range ignorePatterns { if matched, err := filepath.Match(pattern, base); err == nil && matched { return true @@ -132,8 +211,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo } } - // Don't apply gitignore rules to the root directory itself - // In gitignore semantics, patterns don't apply to the repo root + // Don't apply gitignore rules to the root directory itself. if path == dl.rootPath { return false } @@ -143,69 +221,24 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo relPath = path } - if commonIgnorePatterns().MatchesPath(relPath) { - slog.Debug("Ignoring common pattern", "path", relPath) - return true + pathComponents := pathToComponents(relPath) + if len(pathComponents) == 0 { + return false } + // Get the combined matcher for the parent directory. parentDir := filepath.Dir(path) - ignoreParser := dl.getIgnore(parentDir) - if ignoreParser.MatchesPath(relPath) { - slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir) - return true - } + matcher := dl.getCombinedMatcher(parentDir) - // For directories, also check with trailing slash (gitignore convention) - if ignoreParser.MatchesPath(relPath + "/") { - slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir) + if matcher.Match(pathComponents, isDir) { + slog.Debug("Ignoring path", "path", relPath) 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("Ignoring 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, +// 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) @@ -224,15 +257,16 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int return nil // Skip files we don't have permission to access } - if dl.shouldIgnore(path, ignorePatterns) { - if d.IsDir() { + isDir := d.IsDir() + if dl.shouldIgnore(path, ignorePatterns, isDir) { + if isDir { return filepath.SkipDir } return nil } if path != initialPath { - if d.IsDir() { + if isDir { path = path + string(filepath.Separator) } found.Append(path) diff --git a/internal/fsext/ls_test.go b/internal/fsext/ls_test.go index 7bdad17fc46955d49fa08f7488d6efe8239294cb..1bb862754ea38c378fa75709f9dd4b30dd8803f8 100644 --- a/internal/fsext/ls_test.go +++ b/internal/fsext/ls_test.go @@ -31,11 +31,12 @@ func TestListDirectory(t *testing.T) { files, truncated, err := ListDirectory(tmp, nil, -1, -1) require.NoError(t, err) require.False(t, truncated) - require.Len(t, files, 4) + // The .gitignore has ".*" pattern which ignores hidden files anywhere + // (like real git does), so subdir/.another is ignored. + require.Len(t, files, 3) require.ElementsMatch(t, []string{ "regular.txt", "subdir", - "subdir/.another", "subdir/file.go", }, relPaths(t, files, tmp)) })