Skip to content

Commit fcb07c3

Browse files
committed
Fix {gitignore: true} performance issue
1 parent 2dee432 commit fcb07c3

File tree

7 files changed

+683
-20
lines changed

7 files changed

+683
-20
lines changed

ignore.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,5 +154,62 @@ export const isIgnoredByIgnoreFilesSync = (patterns, options) => {
154154
return getIsIgnoredPredicate(files, cwd);
155155
};
156156

157+
const getPatternsFromIgnoreFiles = (files, cwd) => files.flatMap(file => parseIgnoreFile(file, cwd));
158+
159+
/**
160+
Read ignore files and return both patterns and predicate.
161+
This avoids reading the same files twice (once for patterns, once for filtering).
162+
163+
@returns {Promise<{patterns: string[], predicate: Function}>}
164+
*/
165+
export const getIgnorePatternsAndPredicate = async (patterns, options) => {
166+
const {cwd, suppressErrors, deep, ignore} = normalizeOptions(options);
167+
168+
const paths = await fastGlob(patterns, {
169+
cwd,
170+
suppressErrors,
171+
deep,
172+
ignore,
173+
...ignoreFilesGlobOptions,
174+
});
175+
176+
const files = await Promise.all(paths.map(async filePath => ({
177+
filePath,
178+
content: await fsPromises.readFile(filePath, 'utf8'),
179+
})));
180+
181+
return {
182+
patterns: getPatternsFromIgnoreFiles(files, cwd),
183+
predicate: getIsIgnoredPredicate(files, cwd),
184+
};
185+
};
186+
187+
/**
188+
Read ignore files and return both patterns and predicate (sync version).
189+
190+
@returns {{patterns: string[], predicate: Function}}
191+
*/
192+
export const getIgnorePatternsAndPredicateSync = (patterns, options) => {
193+
const {cwd, suppressErrors, deep, ignore} = normalizeOptions(options);
194+
195+
const paths = fastGlob.sync(patterns, {
196+
cwd,
197+
suppressErrors,
198+
deep,
199+
ignore,
200+
...ignoreFilesGlobOptions,
201+
});
202+
203+
const files = paths.map(filePath => ({
204+
filePath,
205+
content: fs.readFileSync(filePath, 'utf8'),
206+
}));
207+
208+
return {
209+
patterns: getPatternsFromIgnoreFiles(files, cwd),
210+
predicate: getIsIgnoredPredicate(files, cwd),
211+
};
212+
};
213+
157214
export const isGitIgnored = options => isIgnoredByIgnoreFiles(GITIGNORE_FILES_PATTERN, options);
158215
export const isGitIgnoredSync = options => isIgnoredByIgnoreFilesSync(GITIGNORE_FILES_PATTERN, options);

index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export type Options = {
4242
/**
4343
Respect ignore patterns in `.gitignore` files that apply to the globbed files.
4444
45-
Performance note: This option searches for all `.gitignore` files in the entire directory tree before globbing, which can be slow. For better performance, use `ignoreFiles: '.gitignore'` to only respect the root `.gitignore` file.
45+
Performance: Globby reads `.gitignore` files before globbing. When there are no negation patterns (like `!important.log`), it passes ignore patterns to fast-glob to skip traversing ignored directories entirely, which significantly improves performance for large `node_modules` or build directories. When negation patterns are present, all filtering is done after traversal to ensure correct Git-compatible behavior. For optimal performance, prefer specific `.gitignore` patterns without negations, or use `ignoreFiles: '.gitignore'` to target only the root ignore file.
4646
4747
@default false
4848
*/

index.js

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ import {isDirectory, isDirectorySync} from 'path-type';
88
import {toPath} from 'unicorn-magic';
99
import {
1010
GITIGNORE_FILES_PATTERN,
11-
isIgnoredByIgnoreFiles,
12-
isIgnoredByIgnoreFilesSync,
11+
getIgnorePatternsAndPredicate,
12+
getIgnorePatternsAndPredicateSync,
1313
} from './ignore.js';
14-
import {isNegativePattern} from './utilities.js';
14+
import {
15+
isNegativePattern,
16+
normalizeDirectoryPatternForFastGlob,
17+
} from './utilities.js';
1518

1619
const assertPatternsInput = patterns => {
1720
if (patterns.some(pattern => typeof pattern !== 'string')) {
@@ -134,14 +137,89 @@ const getIgnoreFilesPatterns = options => {
134137
return patterns;
135138
};
136139

137-
const getFilter = async options => {
140+
/**
141+
Apply gitignore patterns to options and return filter predicate.
142+
143+
When negation patterns are present (e.g., '!important.log'), we cannot pass positive patterns to fast-glob because it would filter out files before our predicate can re-include them. In this case, we rely entirely on the predicate for filtering, which handles negations correctly.
144+
145+
When there are no negations, we optimize by passing patterns to fast-glob's ignore option to skip directories during traversal (performance optimization).
146+
147+
All patterns (including negated) are always used in the filter predicate to ensure correct Git-compatible behavior.
148+
149+
@returns {Promise<{options: Object, filter: Function}>}
150+
*/
151+
const applyIgnoreFilesAndGetFilter = async options => {
138152
const ignoreFilesPatterns = getIgnoreFilesPatterns(options);
139-
return createFilterFunction(ignoreFilesPatterns.length > 0 && await isIgnoredByIgnoreFiles(ignoreFilesPatterns, options));
153+
154+
if (ignoreFilesPatterns.length === 0) {
155+
return {
156+
options,
157+
filter: createFilterFunction(false),
158+
};
159+
}
160+
161+
// Read ignore files once and get both patterns and predicate
162+
const {patterns, predicate} = await getIgnorePatternsAndPredicate(ignoreFilesPatterns, options);
163+
164+
// Determine which patterns are safe to pass to fast-glob
165+
// If there are negation patterns, we can't pass file patterns to fast-glob
166+
// because fast-glob doesn't understand negations and would filter out files
167+
// that should be re-included by negation patterns.
168+
// We only pass patterns to fast-glob if there are NO negations.
169+
const hasNegations = patterns.some(pattern => isNegativePattern(pattern));
170+
const patternsForFastGlob = hasNegations
171+
? [] // With negations, let the predicate handle everything
172+
: patterns
173+
.filter(pattern => !isNegativePattern(pattern))
174+
.map(pattern => normalizeDirectoryPatternForFastGlob(pattern));
175+
176+
const modifiedOptions = {
177+
...options,
178+
ignore: [...options.ignore, ...patternsForFastGlob],
179+
};
180+
181+
return {
182+
options: modifiedOptions,
183+
filter: createFilterFunction(predicate),
184+
};
140185
};
141186

142-
const getFilterSync = options => {
187+
/**
188+
Apply gitignore patterns to options and return filter predicate (sync version).
189+
190+
@returns {{options: Object, filter: Function}}
191+
*/
192+
const applyIgnoreFilesAndGetFilterSync = options => {
143193
const ignoreFilesPatterns = getIgnoreFilesPatterns(options);
144-
return createFilterFunction(ignoreFilesPatterns.length > 0 && isIgnoredByIgnoreFilesSync(ignoreFilesPatterns, options));
194+
195+
if (ignoreFilesPatterns.length === 0) {
196+
return {
197+
options,
198+
filter: createFilterFunction(false),
199+
};
200+
}
201+
202+
// Read ignore files once and get both patterns and predicate
203+
const {patterns, predicate} = getIgnorePatternsAndPredicateSync(ignoreFilesPatterns, options);
204+
205+
// Determine which patterns are safe to pass to fast-glob
206+
// (same logic as async version - see comments above)
207+
const hasNegations = patterns.some(pattern => isNegativePattern(pattern));
208+
const patternsForFastGlob = hasNegations
209+
? []
210+
: patterns
211+
.filter(pattern => !isNegativePattern(pattern))
212+
.map(pattern => normalizeDirectoryPatternForFastGlob(pattern));
213+
214+
const modifiedOptions = {
215+
...options,
216+
ignore: [...options.ignore, ...patternsForFastGlob],
217+
};
218+
219+
return {
220+
options: modifiedOptions,
221+
filter: createFilterFunction(predicate),
222+
};
145223
};
146224

147225
const createFilterFunction = isIgnored => {
@@ -248,28 +326,34 @@ const generateTasksSync = (patterns, options) => {
248326
};
249327

250328
export const globby = normalizeArguments(async (patterns, options) => {
251-
const [
252-
tasks,
253-
filter,
254-
] = await Promise.all([
255-
generateTasks(patterns, options),
256-
getFilter(options),
257-
]);
329+
// Apply ignore files and get filter (reads .gitignore files once)
330+
const {options: modifiedOptions, filter} = await applyIgnoreFilesAndGetFilter(options);
331+
332+
// Generate tasks with modified options (includes gitignore patterns in ignore option)
333+
const tasks = await generateTasks(patterns, modifiedOptions);
258334

259335
const results = await Promise.all(tasks.map(task => fastGlob(task.patterns, task.options)));
260336
return unionFastGlobResults(results, filter);
261337
});
262338

263339
export const globbySync = normalizeArgumentsSync((patterns, options) => {
264-
const tasks = generateTasksSync(patterns, options);
265-
const filter = getFilterSync(options);
340+
// Apply ignore files and get filter (reads .gitignore files once)
341+
const {options: modifiedOptions, filter} = applyIgnoreFilesAndGetFilterSync(options);
342+
343+
// Generate tasks with modified options (includes gitignore patterns in ignore option)
344+
const tasks = generateTasksSync(patterns, modifiedOptions);
345+
266346
const results = tasks.map(task => fastGlob.sync(task.patterns, task.options));
267347
return unionFastGlobResults(results, filter);
268348
});
269349

270350
export const globbyStream = normalizeArgumentsSync((patterns, options) => {
271-
const tasks = generateTasksSync(patterns, options);
272-
const filter = getFilterSync(options);
351+
// Apply ignore files and get filter (reads .gitignore files once)
352+
const {options: modifiedOptions, filter} = applyIgnoreFilesAndGetFilterSync(options);
353+
354+
// Generate tasks with modified options (includes gitignore patterns in ignore option)
355+
const tasks = generateTasksSync(patterns, modifiedOptions);
356+
273357
const streams = tasks.map(task => fastGlob.stream(task.patterns, task.options));
274358

275359
if (streams.length === 0) {

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Default: `false`
8686

8787
Respect ignore patterns in `.gitignore` files that apply to the globbed files.
8888

89-
**Performance note:** This option searches for *all* `.gitignore` files in the entire directory tree before globbing, which can be slow. For better performance, use `ignoreFiles: '.gitignore'` to only respect the root `.gitignore` file.
89+
**Performance:** Globby reads `.gitignore` files before globbing. When there are no negation patterns (like `!important.log`), it passes ignore patterns to fast-glob to skip traversing ignored directories entirely, which significantly improves performance for large `node_modules` or build directories. When negation patterns are present, all filtering is done after traversal to ensure correct Git-compatible behavior. For optimal performance, prefer specific `.gitignore` patterns without negations, or use `ignoreFiles: '.gitignore'` to target only the root ignore file.
9090

9191
##### ignoreFiles
9292

0 commit comments

Comments
 (0)