perf: replace regex-based gitignore with glob-based matching (#2199)

Austin Cherry created

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>

Change summary

go.mod                        |   7 
go.sum                        |  16 +
internal/fsext/fileutil.go    |  31 ++-
internal/fsext/ignore_test.go |   6 
internal/fsext/ls.go          | 276 ++++++++++++++++++++----------------
internal/fsext/ls_test.go     |   5 
6 files changed, 198 insertions(+), 143 deletions(-)

Detailed changes

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

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=

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 {

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) {

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)

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