on main help sign in
perf: replace regex-based gitignore with glob-based matching (#2199)

Replace github.com/sabhiram/go-gitignore (regex-based) with github.com/go-git/go-git/v5/plumbing/format/gitignore (glob-based). Key optimizations: - Two-level caching: per-directory pattern cache + combined matcher cache - O(1) fast-path for common directories (node_modules, .git, __pycache__, etc.) - Pre-build combined matchers to avoid O(depth) pattern walking per file - Proper isDir parameter for directory-specific patterns (e.g., "backup/") Profiling showed 80% CPU in regexp.tryBacktrack from the old library when walking large monorepos (771k files). After this change, gitignore matching drops to ~2% of CPU time. ๐Ÿ’˜ Generated with Crush Assisted-by: AWS Claude Opus 4.5 via Crush <crush@charm.land>

commitd7e225282c3d4b5a4a9d3ecea08240d439fa2d8b authorAustin Cherry <acmacalister@gmail.com> dateThu 12 Feb 2026, 07:06 (8 months ago) parentfaec9a2
6 files changed+198โˆ’143
go.mod+5โˆ’2 go.sum+12โˆ’4 internal/fsext/fileutil.go+20โˆ’11 internal/fsext/ignore_test.go+3โˆ’3 internal/fsext/ls.go+155โˆ’121 internal/fsext/ls_test.go+3โˆ’2
go.mod+5โˆ’2
@@ -38,6 +38,7 @@ require (
3838 github.com/denisbrodbeck/machineid v1.0.1
3939 github.com/disintegration/imaging v1.6.2
4040 github.com/dustin/go-humanize v1.0.1
41+ github.com/go-git/go-git/v5 v5.16.5
4142 github.com/google/uuid v1.6.0
4243 github.com/invopop/jsonschema v0.13.0
4344 github.com/joho/godotenv v1.5.1
@@ -53,7 +54,6 @@ require (
5354 github.com/pressly/goose/v3 v3.26.0
5455 github.com/qjebbs/go-jsons v1.0.0-alpha.4
5556 github.com/rivo/uniseg v0.4.7
56โˆ’ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
5757 github.com/sahilm/fuzzy v0.1.1
5858 github.com/sourcegraph/jsonrpc2 v0.2.1
5959 github.com/spf13/cobra v1.10.2
@@ -110,6 +110,8 @@ require (
110110 github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
111111 github.com/felixge/httpsnoop v1.0.4 // indirect
112112 github.com/fsnotify/fsnotify v1.9.0 // indirect
113+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
114+ github.com/go-git/go-billy/v5 v5.6.2 // indirect
113115 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
114116 github.com/go-logfmt/logfmt v0.6.0 // indirect
115117 github.com/go-logr/logr v1.4.3 // indirect
@@ -127,6 +129,7 @@ require (
127129 github.com/gorilla/websocket v1.5.3 // indirect
128130 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
129131 github.com/inconshreveable/mousetrap v1.1.0 // indirect
132+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
130133 github.com/kaptinlin/go-i18n v0.2.3 // indirect
131134 github.com/kaptinlin/jsonpointer v0.4.9 // indirect
132135 github.com/kaptinlin/jsonschema v0.6.10 // indirect
@@ -149,7 +152,6 @@ require (
149152 github.com/pierrec/lz4/v4 v4.1.22 // indirect
150153 github.com/pmezard/go-difflib v1.0.0 // indirect
151154 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
152โˆ’ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
153155 github.com/sethvargo/go-retry v0.3.0 // indirect
154156 github.com/spf13/pflag v1.0.9 // indirect
155157 github.com/tetratelabs/wazero v1.11.0 // indirect
@@ -185,6 +187,7 @@ require (
185187 google.golang.org/protobuf v1.36.10 // indirect
186188 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
187189 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
190+ gopkg.in/warnings.v0 v0.1.2 // indirect
188191 modernc.org/libc v1.67.6 // indirect
189192 modernc.org/mathutil v1.7.1 // indirect
190193 modernc.org/memory v1.11.0 // indirect
go.sum+12โˆ’4
@@ -164,6 +164,12 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw
164164github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
165165github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
166166github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
167+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
168+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
169+github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
170+github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
171+github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
172+github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
167173github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
168174github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
169175github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -215,6 +221,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
215221github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
216222github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
217223github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
224+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
225+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
218226github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
219227github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
220228github.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
286294github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
287295github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
288296github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
297+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
298+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
289299github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
290300github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
291301github.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
303313github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
304314github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
305315github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
306โˆ’github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
307โˆ’github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
308316github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
309317github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
310318github.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
324332github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
325333github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
326334github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
327โˆ’github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
328335github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
329336github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
330337github.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
502509gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
503510gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
504511gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
512+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
513+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
505514gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
506515gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
507516gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
508โˆ’gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
509517gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
510518gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
511519modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
internal/fsext/fileutil.go+20โˆ’11
@@ -70,10 +70,16 @@ func NewFastGlobWalker(searchPath string) *FastGlobWalker {
7070 }
7171}
7272
73โˆ’// ShouldSkip checks if a path should be skipped based on hierarchical gitignore,
74โˆ’// crushignore, and hidden file rules
73+// ShouldSkip checks if a file path should be skipped based on hierarchical gitignore,
74+// crushignore, and hidden file rules.
7575func (w *FastGlobWalker) ShouldSkip(path string) bool {
76โˆ’ return w.directoryLister.shouldIgnore(path, nil)
76+ return w.directoryLister.shouldIgnore(path, nil, false)
77+}
78+
79+// ShouldSkipDir checks if a directory path should be skipped based on hierarchical
80+// gitignore, crushignore, and hidden file rules.
81+func (w *FastGlobWalker) ShouldSkipDir(path string) bool {
82+ return w.directoryLister.shouldIgnore(path, nil, true)
7783}
7884
7985func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
@@ -93,14 +99,15 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
9399 return nil // Skip files we can't access
94100 }
95101
96โˆ’ if d.IsDir() {
97โˆ’ if walker.ShouldSkip(path) {
102+ isDir := d.IsDir()
103+ if isDir {
104+ if walker.ShouldSkipDir(path) {
98105 return filepath.SkipDir
99106 }
100โˆ’ }
101โˆ’
102โˆ’ if walker.ShouldSkip(path) {
103โˆ’ return nil
107+ } else {
108+ if walker.ShouldSkip(path) {
109+ return nil
110+ }
104111 }
105112
106113 relPath, err := filepath.Rel(searchPath, path)
@@ -145,10 +152,12 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
145152}
146153
147154// ShouldExcludeFile checks if a file should be excluded from processing
148โˆ’// based on common patterns and ignore rules
155+// based on common patterns and ignore rules.
149156func ShouldExcludeFile(rootPath, filePath string) bool {
157+ info, err := os.Stat(filePath)
158+ isDir := err == nil && info.IsDir()
150159 return NewDirectoryLister(rootPath).
151โˆ’ shouldIgnore(filePath, nil)
160+ shouldIgnore(filePath, nil, isDir)
152161}
153162
154163func PrettyPath(path string) string {
internal/fsext/ignore_test.go+3โˆ’3
@@ -21,9 +21,9 @@ func TestCrushIgnore(t *testing.T) {
2121 require.NoError(t, os.WriteFile(".crushignore", []byte("*.log\n"), 0o644))
2222
2323 dl := NewDirectoryLister(tempDir)
24โˆ’ require.True(t, dl.shouldIgnore("test2.log", nil), ".log files should be ignored")
25โˆ’ require.False(t, dl.shouldIgnore("test1.txt", nil), ".txt files should not be ignored")
26โˆ’ require.True(t, dl.shouldIgnore("test3.tmp", nil), ".tmp files should be ignored by common patterns")
24+ require.True(t, dl.shouldIgnore("test2.log", nil, false), ".log files should be ignored")
25+ require.False(t, dl.shouldIgnore("test1.txt", nil, false), ".txt files should not be ignored")
26+ require.True(t, dl.shouldIgnore("test3.tmp", nil, false), ".tmp files should be ignored by common patterns")
2727}
2828
2929func TestShouldExcludeFile(t *testing.T) {
internal/fsext/ls.go+155โˆ’121
@@ -12,29 +12,45 @@ import (
1212 "github.com/charlievieth/fastwalk"
1313 "github.com/charmbracelet/crush/internal/csync"
1414 "github.com/charmbracelet/crush/internal/home"
15โˆ’ ignore "github.com/sabhiram/go-gitignore"
15+ "github.com/go-git/go-git/v5/plumbing/format/gitignore"
1616)
1717
18โˆ’// commonIgnorePatterns contains commonly ignored files and directories
19โˆ’var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
20โˆ’ return ignore.CompileIgnoreLines(
21โˆ’ // Version control
22โˆ’ ".git",
23โˆ’ ".svn",
24โˆ’ ".hg",
25โˆ’ ".bzr",
26โˆ’
27โˆ’ // IDE and editor files
28โˆ’ ".vscode",
29โˆ’ ".idea",
18+// fastIgnoreDirs is a set of directory names that are always ignored.
19+// This provides O(1) lookup for common cases to avoid expensive pattern matching.
20+var fastIgnoreDirs = map[string]bool{
21+ ".git": true,
22+ ".svn": true,
23+ ".hg": true,
24+ ".bzr": true,
25+ ".vscode": true,
26+ ".idea": true,
27+ "node_modules": true,
28+ "__pycache__": true,
29+ ".pytest_cache": true,
30+ ".cache": true,
31+ ".tmp": true,
32+ ".Trash": true,
33+ ".Spotlight-V100": true,
34+ ".fseventsd": true,
35+ ".crush": true,
36+ "OrbStack": true,
37+ ".local": true,
38+ ".share": true,
39+}
40+
41+// commonIgnorePatterns contains commonly ignored files and directories.
42+// Note: Exact directory names that are in fastIgnoreDirs are handled there for O(1) lookup.
43+// This list contains wildcard patterns and file-specific patterns.
44+var commonIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern {
45+ patterns := []string{
46+ // IDE and editor files (wildcards)
3047 "*.swp",
3148 "*.swo",
3249 "*~",
3350 ".DS_Store",
3451 "Thumbs.db",
3552
36โˆ’ // Build artifacts and dependencies
37โˆ’ "node_modules",
53+ // Build artifacts (non-fastIgnoreDirs)
3854 "target",
3955 "build",
4056 "dist",
@@ -47,84 +63,147 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
4763 "*.dll",
4864 "*.exe",
4965
50โˆ’ // Logs and temporary files
66+ // Logs and temporary files (wildcards)
5167 "*.log",
5268 "*.tmp",
5369 "*.temp",
54โˆ’ ".cache",
55โˆ’ ".tmp",
5670
57โˆ’ // Language-specific
58โˆ’ "__pycache__",
71+ // Language-specific (wildcards and non-fastIgnoreDirs)
5972 "*.pyc",
6073 "*.pyo",
61โˆ’ ".pytest_cache",
6274 "vendor",
6375 "Cargo.lock",
6476 "package-lock.json",
6577 "yarn.lock",
6678 "pnpm-lock.yaml",
67โˆ’
68โˆ’ // OS generated files
69โˆ’ ".Trash",
70โˆ’ ".Spotlight-V100",
71โˆ’ ".fseventsd",
72โˆ’
73โˆ’ // Crush
74โˆ’ ".crush",
75โˆ’
76โˆ’ // macOS stuff
77โˆ’ "OrbStack",
78โˆ’ ".local",
79โˆ’ ".share",
80โˆ’ )
79+ }
80+ return parsePatterns(patterns, nil)
8181})
8282
83โˆ’var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
84โˆ’ home := home.Dir()
83+var homeIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern {
84+ homeDir := home.Dir()
8585 var lines []string
8686 for _, name := range []string{
87โˆ’ filepath.Join(home, ".gitignore"),
88โˆ’ filepath.Join(home, ".config", "git", "ignore"),
89โˆ’ filepath.Join(home, ".config", "crush", "ignore"),
87+ filepath.Join(homeDir, ".gitignore"),
88+ filepath.Join(homeDir, ".config", "git", "ignore"),
89+ filepath.Join(homeDir, ".config", "crush", "ignore"),
9090 } {
9191 if bts, err := os.ReadFile(name); err == nil {
9292 lines = append(lines, strings.Split(string(bts), "\n")...)
9393 }
9494 }
95โˆ’ return ignore.CompileIgnoreLines(lines...)
95+ return parsePatterns(lines, nil)
9696})
9797
98+// parsePatterns parses gitignore pattern strings into Pattern objects.
99+// domain is the path components where the patterns are defined (nil for global).
100+func parsePatterns(lines []string, domain []string) []gitignore.Pattern {
101+ var patterns []gitignore.Pattern
102+ for _, line := range lines {
103+ line = strings.TrimSpace(line)
104+ if line == "" || strings.HasPrefix(line, "#") {
105+ continue
106+ }
107+ patterns = append(patterns, gitignore.ParsePattern(line, domain))
108+ }
109+ return patterns
110+}
111+
98112type directoryLister struct {
99โˆ’ ignores *csync.Map[string, ignore.IgnoreParser]
100โˆ’ rootPath string
113+ // dirPatterns caches parsed patterns from .gitignore/.crushignore for each directory.
114+ // This avoids re-reading files when building combined matchers.
115+ dirPatterns *csync.Map[string, []gitignore.Pattern]
116+ // combinedMatchers caches a combined matcher for each directory that includes
117+ // all ancestor patterns. This allows O(1) matching per file.
118+ combinedMatchers *csync.Map[string, gitignore.Matcher]
119+ rootPath string
101120}
102121
103122func NewDirectoryLister(rootPath string) *directoryLister {
104โˆ’ dl := &directoryLister{
105โˆ’ rootPath: rootPath,
106โˆ’ ignores: csync.NewMap[string, ignore.IgnoreParser](),
123+ return &directoryLister{
124+ rootPath: rootPath,
125+ dirPatterns: csync.NewMap[string, []gitignore.Pattern](),
126+ combinedMatchers: csync.NewMap[string, gitignore.Matcher](),
107127 }
108โˆ’ dl.getIgnore(rootPath)
109โˆ’ return dl
110128}
111129
112โˆ’// git checks, in order:
113โˆ’// - ./.gitignore, ../.gitignore, etc, until repo root
114โˆ’// ~/.config/git/ignore
115โˆ’// ~/.gitignore
116โˆ’//
117โˆ’// This will do the following:
118โˆ’// - the given ignorePatterns
119โˆ’// - [commonIgnorePatterns]
120โˆ’// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
121โˆ’// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
122โˆ’// ~/.config/git/ignore
123โˆ’// ~/.gitignore
124โˆ’// ~/.config/crush/ignore
125โˆ’func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
130+// pathToComponents splits a path into its components for gitignore matching.
131+func pathToComponents(path string) []string {
132+ path = filepath.ToSlash(path)
133+ if path == "" || path == "." {
134+ return nil
135+ }
136+ return strings.Split(path, "/")
137+}
138+
139+// getDirPatterns returns the parsed patterns for a specific directory's
140+// .gitignore and .crushignore files. Results are cached.
141+func (dl *directoryLister) getDirPatterns(dir string) []gitignore.Pattern {
142+ return dl.dirPatterns.GetOrSet(dir, func() []gitignore.Pattern {
143+ var allPatterns []gitignore.Pattern
144+
145+ relPath, _ := filepath.Rel(dl.rootPath, dir)
146+ var domain []string
147+ if relPath != "" && relPath != "." {
148+ domain = pathToComponents(relPath)
149+ }
150+
151+ for _, ignoreFile := range []string{".gitignore", ".crushignore"} {
152+ ignPath := filepath.Join(dir, ignoreFile)
153+ if content, err := os.ReadFile(ignPath); err == nil {
154+ lines := strings.Split(string(content), "\n")
155+ allPatterns = append(allPatterns, parsePatterns(lines, domain)...)
156+ }
157+ }
158+ return allPatterns
159+ })
160+}
161+
162+// getCombinedMatcher returns a matcher that combines all gitignore patterns
163+// from the root to the given directory, plus common patterns and home patterns.
164+// Results are cached per directory, and we reuse parent directory matchers.
165+func (dl *directoryLister) getCombinedMatcher(dir string) gitignore.Matcher {
166+ return dl.combinedMatchers.GetOrSet(dir, func() gitignore.Matcher {
167+ var allPatterns []gitignore.Pattern
168+
169+ // Add common patterns first (lowest priority).
170+ allPatterns = append(allPatterns, commonIgnorePatterns()...)
171+
172+ // Add home ignore patterns.
173+ allPatterns = append(allPatterns, homeIgnorePatterns()...)
174+
175+ // Collect patterns from root to this directory.
176+ relDir, _ := filepath.Rel(dl.rootPath, dir)
177+ var pathParts []string
178+ if relDir != "" && relDir != "." {
179+ pathParts = pathToComponents(relDir)
180+ }
181+
182+ // Add patterns from each directory from root to current.
183+ currentPath := dl.rootPath
184+ allPatterns = append(allPatterns, dl.getDirPatterns(currentPath)...)
185+
186+ for _, part := range pathParts {
187+ currentPath = filepath.Join(currentPath, part)
188+ allPatterns = append(allPatterns, dl.getDirPatterns(currentPath)...)
189+ }
190+
191+ return gitignore.NewMatcher(allPatterns)
192+ })
193+}
194+
195+// shouldIgnore checks if a path should be ignored based on gitignore rules.
196+// This uses a combined matcher that includes all ancestor patterns for O(1) matching.
197+func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string, isDir bool) bool {
198+ base := filepath.Base(path)
199+
200+ // Fast path: O(1) lookup for commonly ignored directories.
201+ if isDir && fastIgnoreDirs[base] {
202+ return true
203+ }
204+
205+ // Check explicit ignore patterns.
126206 if len(ignorePatterns) > 0 {
127โˆ’ base := filepath.Base(path)
128207 for _, pattern := range ignorePatterns {
129208 if matched, err := filepath.Match(pattern, base); err == nil && matched {
130209 return true
@@ -132,8 +211,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
132211 }
133212 }
134213
135โˆ’ // Don't apply gitignore rules to the root directory itself
136โˆ’ // In gitignore semantics, patterns don't apply to the repo root
214+ // Don't apply gitignore rules to the root directory itself.
137215 if path == dl.rootPath {
138216 return false
139217 }
@@ -143,69 +221,24 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
143221 relPath = path
144222 }
145223
146โˆ’ if commonIgnorePatterns().MatchesPath(relPath) {
147โˆ’ slog.Debug("Ignoring common pattern", "path", relPath)
148โˆ’ return true
224+ pathComponents := pathToComponents(relPath)
225+ if len(pathComponents) == 0 {
226+ return false
149227 }
150228
229+ // Get the combined matcher for the parent directory.
151230 parentDir := filepath.Dir(path)
152โˆ’ ignoreParser := dl.getIgnore(parentDir)
153โˆ’ if ignoreParser.MatchesPath(relPath) {
154โˆ’ slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir)
155โˆ’ return true
156โˆ’ }
231+ matcher := dl.getCombinedMatcher(parentDir)
157232
158โˆ’ // For directories, also check with trailing slash (gitignore convention)
159โˆ’ if ignoreParser.MatchesPath(relPath + "/") {
160โˆ’ slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
233+ if matcher.Match(pathComponents, isDir) {
234+ slog.Debug("Ignoring path", "path", relPath)
161235 return true
162236 }
163237
164โˆ’ if dl.checkParentIgnores(relPath) {
165โˆ’ return true
166โˆ’ }
167โˆ’
168โˆ’ if homeIgnore().MatchesPath(relPath) {
169โˆ’ slog.Debug("Ignoring home dir pattern", "path", relPath)
170โˆ’ return true
171โˆ’ }
172โˆ’
173โˆ’ return false
174โˆ’}
175โˆ’
176โˆ’func (dl *directoryLister) checkParentIgnores(path string) bool {
177โˆ’ parent := filepath.Dir(filepath.Dir(path))
178โˆ’ for parent != "." && path != "." {
179โˆ’ if dl.getIgnore(parent).MatchesPath(path) {
180โˆ’ slog.Debug("Ignoring parent dir pattern", "path", path, "dir", parent)
181โˆ’ return true
182โˆ’ }
183โˆ’ if parent == dl.rootPath {
184โˆ’ break
185โˆ’ }
186โˆ’ parent = filepath.Dir(parent)
187โˆ’ }
188238 return false
189239}
190240
191โˆ’func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
192โˆ’ return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
193โˆ’ var lines []string
194โˆ’ for _, ign := range []string{".crushignore", ".gitignore"} {
195โˆ’ name := filepath.Join(path, ign)
196โˆ’ if content, err := os.ReadFile(name); err == nil {
197โˆ’ lines = append(lines, strings.Split(string(content), "\n")...)
198โˆ’ }
199โˆ’ }
200โˆ’ if len(lines) == 0 {
201โˆ’ // Return a no-op parser to avoid nil checks
202โˆ’ return ignore.CompileIgnoreLines()
203โˆ’ }
204โˆ’ return ignore.CompileIgnoreLines(lines...)
205โˆ’ })
206โˆ’}
207โˆ’
208โˆ’// ListDirectory lists files and directories in the specified path,
241+// ListDirectory lists files and directories in the specified path.
209242func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
210243 found := csync.NewSlice[string]()
211244 dl := NewDirectoryLister(initialPath)
@@ -224,15 +257,16 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int
224257 return nil // Skip files we don't have permission to access
225258 }
226259
227โˆ’ if dl.shouldIgnore(path, ignorePatterns) {
228โˆ’ if d.IsDir() {
260+ isDir := d.IsDir()
261+ if dl.shouldIgnore(path, ignorePatterns, isDir) {
262+ if isDir {
229263 return filepath.SkipDir
230264 }
231265 return nil
232266 }
233267
234268 if path != initialPath {
235โˆ’ if d.IsDir() {
269+ if isDir {
236270 path = path + string(filepath.Separator)
237271 }
238272 found.Append(path)
internal/fsext/ls_test.go+3โˆ’2
@@ -31,11 +31,12 @@ func TestListDirectory(t *testing.T) {
3131 files, truncated, err := ListDirectory(tmp, nil, -1, -1)
3232 require.NoError(t, err)
3333 require.False(t, truncated)
34โˆ’ require.Len(t, files, 4)
34+ // The .gitignore has ".*" pattern which ignores hidden files anywhere
35+ // (like real git does), so subdir/.another is ignored.
36+ require.Len(t, files, 3)
3537 require.ElementsMatch(t, []string{
3638 "regular.txt",
3739 "subdir",
38โˆ’ "subdir/.another",
3940 "subdir/file.go",
4041 }, relPaths(t, files, tmp))
4142 })