Skip to content

Commit 8e819b0

Browse files
authored
perf: prune gitignored directories during gap-file discovery (#1100)
1 parent b0e2b5a commit 8e819b0

13 files changed

Lines changed: 1757 additions & 226 deletions

cmd/rslint/cmd.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -911,8 +911,8 @@ func executeLintPipeline(args lintArgs, ctx context.Context, dispatch linter.Esl
911911
//
912912
// Config ignores are passed so that directories which are
913913
// directory-level blocked (e.g. **/tests/**) are pruned during
914-
// the .gitignore scan. This is safe because isDirPathBlocked is
915-
// the same function used by the linter — blocked dirs' files
914+
// the .gitignore scan. This is safe because isDirAbsolutelyBlocked is
915+
// the same predicate used by the linter — blocked dirs' files
916916
// are never linted, so their .gitignore patterns are irrelevant.
917917
//
918918
// Concurrency:

internal/config/config.go

Lines changed: 10 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -336,50 +336,6 @@ func registerAllCoreEslintRules() {
336336
}
337337
}
338338

339-
// isFileIgnored checks if a file is matched by ignore patterns, evaluated sequentially.
340-
// Later patterns override earlier ones; a `!` prefix negates (re-includes) a previously
341-
// ignored file. This aligns with ESLint v10's ignore semantics.
342-
//
343-
// For directory-level blocking (dir/** prevents traversal entirely), use isDirPathBlocked.
344-
func isFileIgnored(filePath string, ignorePatterns []string, cwd string) bool {
345-
if cwd == "" {
346-
return isFileIgnoredSimple(filePath, ignorePatterns)
347-
}
348-
349-
// Normalize the file path relative to cwd
350-
normalizedPath := normalizePath(filePath, cwd)
351-
unixPath := strings.ReplaceAll(normalizedPath, "\\", "/")
352-
353-
// Evaluate patterns sequentially. Later patterns override earlier ones.
354-
// A `!` prefix negates (re-includes) a previously ignored file.
355-
// This aligns with ESLint v10's ignore semantics.
356-
ignored := false
357-
for _, pattern := range ignorePatterns {
358-
negated := false
359-
if strings.HasPrefix(pattern, "!") {
360-
negated = true
361-
pattern = pattern[1:]
362-
}
363-
364-
normalizedPattern := normalizePattern(pattern)
365-
366-
// Match against the relative path only. Do NOT fall back to the
367-
// absolute filePath — patterns with **/ prefix (e.g., **/tmp/**/*)
368-
// would incorrectly match system directory names in the absolute path
369-
// (e.g., /tmp/ on Linux/macOS).
370-
matched := matchGlob(normalizedPattern, normalizedPath)
371-
// Windows path separator fallback.
372-
if !matched && unixPath != normalizedPath {
373-
matched = matchGlob(normalizedPattern, unixPath)
374-
}
375-
376-
if matched {
377-
ignored = !negated
378-
}
379-
}
380-
return ignored
381-
}
382-
383339
// normalizePattern cleans up a glob pattern to match paths produced by normalizePath.
384340
// normalizePath uses tspath.NormalizePath on file paths (strips leading "./", collapses
385341
// "/./", resolves ".."), so patterns must undergo the same transformation.
@@ -389,24 +345,16 @@ func matchGlob(pattern, path string) bool {
389345
return err == nil && m
390346
}
391347

392-
// isFileLevelPattern returns true if the pattern only matches files (not directories).
393-
// File-level patterns end with /**/* or /* (but not /**).
394-
// These do NOT block directory traversal in ESLint v10's isDirectoryIgnored.
395-
func isFileLevelPattern(pattern string) bool {
396-
return strings.HasSuffix(pattern, "/**/*") ||
397-
(strings.HasSuffix(pattern, "/*") && !strings.HasSuffix(pattern, "/**"))
398-
}
399-
400348
func normalizePattern(pattern string) string {
401349
return tspath.NormalizePath(pattern)
402350
}
403351

404352
// isDirBlockedByIgnores checks if the file's directory is blocked by a
405-
// directory-level ignore pattern (e.g., `dir/**`). File-level patterns
406-
// (`dir/**/*`, `dir/*`) and negation patterns are skipped.
407-
// This aligns with ESLint v10: `dir/**` blocks directory traversal entirely,
408-
// and `!` negation cannot undo it.
409-
func isDirBlockedByIgnores(filePath string, ignorePatterns []string, cwd string) bool {
353+
// directory-level ignore pattern (e.g., `dir/**`). File-level patterns and
354+
// negation patterns are excluded (by Kind) in isDirAbsolutelyBlocked. This
355+
// aligns with ESLint v10: `dir/**` blocks directory traversal entirely, and
356+
// `!` negation cannot undo it.
357+
func isDirBlockedByIgnores(filePath string, patterns []IgnorePattern, cwd string) bool {
410358
var dirPath string
411359
if cwd != "" {
412360
dirPath = normalizePath(tspath.GetDirectoryPath(filePath), cwd)
@@ -418,39 +366,7 @@ func isDirBlockedByIgnores(filePath string, ignorePatterns []string, cwd string)
418366
if dirPath == "" || dirPath == "." {
419367
return false
420368
}
421-
return isDirPathBlocked(dirPath, ignorePatterns)
422-
}
423-
424-
// isDirPathBlocked checks if a directory path is blocked by any directory-level ignore
425-
// pattern. Shared between GetConfigForFile and DiscoverGapFiles.
426-
//
427-
// A directory is blocked if a pattern matches the path itself or any parent segment.
428-
// For example, pattern "dir1/**" blocks "dir1", "dir1/sub", and "dir1/sub/deep".
429-
// File-level patterns (ending with /**/* or /*) and negation (!) patterns are skipped —
430-
// directory blocking is absolute and cannot be negated.
431-
func isDirPathBlocked(dirPath string, ignorePatterns []string) bool {
432-
for _, pattern := range ignorePatterns {
433-
if pattern == "" || strings.HasPrefix(pattern, "!") {
434-
continue
435-
}
436-
if isFileLevelPattern(pattern) {
437-
continue
438-
}
439-
440-
normalizedPattern := normalizePattern(pattern)
441-
442-
if matchGlob(normalizedPattern, dirPath) || matchGlob(normalizedPattern, dirPath+"/x") {
443-
return true
444-
}
445-
segments := strings.Split(dirPath, "/")
446-
for i := 1; i < len(segments); i++ {
447-
partial := strings.Join(segments[:i], "/")
448-
if matchGlob(normalizedPattern, partial) || matchGlob(normalizedPattern, partial+"/x") {
449-
return true
450-
}
451-
}
452-
}
453-
return false
369+
return isDirAbsolutelyBlocked(dirPath, patterns)
454370
}
455371

456372
// normalizePath converts file path to be relative to cwd for consistent matching
@@ -461,23 +377,6 @@ func normalizePath(filePath, cwd string) string {
461377
}))
462378
}
463379

464-
// isFileIgnoredSimple provides fallback matching when cwd is unavailable
465-
func isFileIgnoredSimple(filePath string, ignorePatterns []string) bool {
466-
ignored := false
467-
for _, pattern := range ignorePatterns {
468-
negated := false
469-
if strings.HasPrefix(pattern, "!") {
470-
negated = true
471-
pattern = pattern[1:]
472-
}
473-
normalizedPattern := normalizePattern(pattern)
474-
if matched, err := doublestar.Match(normalizedPattern, filePath); err == nil && matched {
475-
ignored = !negated
476-
}
477-
}
478-
return ignored
479-
}
480-
481380
// MergedConfig is the final computed configuration for a single file
482381
type MergedConfig struct {
483382
Rules map[string]*RuleConfig
@@ -546,8 +445,10 @@ func (config RslintConfig) GetConfigForFile(filePath string, cwd string) *Merged
546445
continue
547446
}
548447

549-
// 3. Entry-level ignores
550-
if isFileIgnored(filePath, entry.Ignores, cwd) {
448+
// 3. Entry-level ignores. Parsed per entry; entry.Ignores is usually
449+
// empty (ESLint configs put ignores in a dedicated global-ignore entry),
450+
// so ParseIgnorePatterns returns nil and this is free in the common case.
451+
if isFileIgnored(filePath, ParseIgnorePatterns(entry.Ignores), cwd) {
551452
continue
552453
}
553454

internal/config/config_ignore_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func TestIsFileIgnored_Negation(t *testing.T) {
105105

106106
for _, tt := range tests {
107107
t.Run(tt.name, func(t *testing.T) {
108-
result := isFileIgnored(tt.filePath, tt.patterns, cwd)
108+
result := isFileIgnored(tt.filePath, ParseIgnorePatterns(tt.patterns), cwd)
109109
if result != tt.shouldIgnore {
110110
t.Errorf("isFileIgnored(%q, %v) = %v, expected %v",
111111
tt.filePath, tt.patterns, result, tt.shouldIgnore)
@@ -175,7 +175,7 @@ func TestIsFileIgnored_NegationBeforePositive(t *testing.T) {
175175

176176
// Negation before positive pattern: ! has nothing to negate yet,
177177
// then positive pattern ignores. Result: ignored.
178-
result := isFileIgnored("build/test.js", []string{"!build/test.js", "build/**"}, cwd)
178+
result := isFileIgnored("build/test.js", ParseIgnorePatterns([]string{"!build/test.js", "build/**"}), cwd)
179179
if !result {
180180
t.Error("Expected ignored: negation before positive has no effect, positive wins")
181181
}
@@ -212,7 +212,7 @@ func TestIsFileIgnored_FileExtensionNegation(t *testing.T) {
212212

213213
for _, tt := range tests {
214214
t.Run(tt.name, func(t *testing.T) {
215-
result := isFileIgnored(tt.filePath, tt.patterns, cwd)
215+
result := isFileIgnored(tt.filePath, ParseIgnorePatterns(tt.patterns), cwd)
216216
if result != tt.shouldIgnore {
217217
t.Errorf("isFileIgnored(%q, %v) = %v, expected %v",
218218
tt.filePath, tt.patterns, result, tt.shouldIgnore)
@@ -240,7 +240,7 @@ func TestIsFileIgnored_MultiLevelNegateAndReIgnore(t *testing.T) {
240240

241241
for _, tt := range tests {
242242
t.Run(tt.name, func(t *testing.T) {
243-
result := isFileIgnored(tt.filePath, patterns, cwd)
243+
result := isFileIgnored(tt.filePath, ParseIgnorePatterns(patterns), cwd)
244244
if result != tt.shouldIgnore {
245245
t.Errorf("isFileIgnored(%q) = %v, expected %v", tt.filePath, result, tt.shouldIgnore)
246246
}
@@ -284,7 +284,7 @@ func TestIsFileIgnoredSimple_Negation(t *testing.T) {
284284

285285
for _, tt := range tests {
286286
t.Run(tt.name, func(t *testing.T) {
287-
result := isFileIgnoredSimple(tt.filePath, tt.patterns)
287+
result := isFileIgnoredSimple(tt.filePath, ParseIgnorePatterns(tt.patterns))
288288
if result != tt.shouldIgnore {
289289
t.Errorf("isFileIgnoredSimple(%q, %v) = %v, expected %v",
290290
tt.filePath, tt.patterns, result, tt.shouldIgnore)

internal/config/cwd_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func TestCwdHandling(t *testing.T) {
9797

9898
for _, tt := range tests {
9999
t.Run(tt.name, func(t *testing.T) {
100-
result := isFileIgnored(tt.filePath, tt.patterns, originalCwd)
100+
result := isFileIgnored(tt.filePath, ParseIgnorePatterns(tt.patterns), originalCwd)
101101
if result != tt.shouldIgnore {
102102
t.Errorf("%s: isFileIgnored(%q, %v) = %v, expected %v",
103103
tt.description, tt.filePath, tt.patterns, result, tt.shouldIgnore)

internal/config/file_discovery.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func DiscoverGapFiles(
9292

9393
// 2. Prepend default directory ignores so they are always active
9494
// regardless of user config.
95-
globalIgnores = append(utils.DefaultIgnoreDirGlobs(), globalIgnores...)
95+
globalIgnores = append(ParseIgnorePatterns(utils.DefaultIgnoreDirGlobs()), globalIgnores...)
9696

9797
// Use non-nil empty slice to distinguish "files field present, no gaps"
9898
// from "no files field" (nil).
@@ -139,6 +139,11 @@ func DiscoverGapFiles(
139139
dirIgnore sync.Map // map[string]bool — pattern check cache, write-once per path
140140
)
141141

142+
// Precompute negation reaches once. canPruneDir uses them to prune gitignore
143+
// file-level directories (e.g. target/ → **/target/**/*) without ever
144+
// skipping a directory a `!` pattern could re-include.
145+
neg := buildNegReach(globalIgnores)
146+
142147
// Defer the parallelism limit to GOMAXPROCS (Go's standard knob; aligned
143148
// with container CGroup CPU limits). Lower bound of 2 keeps the walker
144149
// useful on single-core CI runners. singleThreaded overrides to 1 for
@@ -235,7 +240,11 @@ func DiscoverGapFiles(
235240
continue
236241
}
237242
} else {
238-
blocked := isDirPathBlocked(childPath, globalIgnores)
243+
// canPruneDir unifies both directory-prune cases: absolute
244+
// blocks (dir/**, bare names) and negation-aware file-level
245+
// gitignore covers (dir/**/*). Sound — prunes only when every
246+
// descendant file would be ignored by isFileIgnored.
247+
blocked := canPruneDir(childPath, globalIgnores, neg)
239248
dirIgnore.Store(childPath, blocked)
240249
if blocked {
241250
continue

0 commit comments

Comments
 (0)