Skip to content

Commit b9a9a84

Browse files
committed
Respect parent .gitignore files when gitignore option is enabled
Fixes #86
1 parent db9cb72 commit b9a9a84

File tree

10 files changed

+1211
-107
lines changed

10 files changed

+1211
-107
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
foo.js

ignore.js

Lines changed: 169 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ import gitIgnore from 'ignore';
77
import isPathInside from 'is-path-inside';
88
import slash from 'slash';
99
import {toPath} from 'unicorn-magic';
10-
import {isNegativePattern, bindFsMethod} from './utilities.js';
10+
import {
11+
isNegativePattern,
12+
bindFsMethod,
13+
promisifyFsMethod,
14+
findGitRoot,
15+
findGitRootSync,
16+
getParentGitignorePaths,
17+
} from './utilities.js';
1118

1219
const defaultIgnoredDirectories = [
1320
'**/node_modules',
@@ -24,13 +31,105 @@ export const GITIGNORE_FILES_PATTERN = '**/.gitignore';
2431

2532
const getReadFileMethod = fsImplementation =>
2633
bindFsMethod(fsImplementation?.promises, 'readFile')
27-
?? bindFsMethod(fsImplementation, 'readFile')
28-
?? bindFsMethod(fsPromises, 'readFile');
34+
?? bindFsMethod(fsPromises, 'readFile')
35+
?? promisifyFsMethod(fsImplementation, 'readFile');
2936

3037
const getReadFileSyncMethod = fsImplementation =>
3138
bindFsMethod(fsImplementation, 'readFileSync')
3239
?? bindFsMethod(fs, 'readFileSync');
3340

41+
const shouldSkipIgnoreFileError = (error, suppressErrors) => {
42+
if (!error) {
43+
return Boolean(suppressErrors);
44+
}
45+
46+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
47+
return true;
48+
}
49+
50+
return Boolean(suppressErrors);
51+
};
52+
53+
const createIgnoreFileReadError = (filePath, error) => {
54+
if (error instanceof Error) {
55+
error.message = `Failed to read ignore file at ${filePath}: ${error.message}`;
56+
return error;
57+
}
58+
59+
return new Error(`Failed to read ignore file at ${filePath}: ${String(error)}`);
60+
};
61+
62+
const processIgnoreFileCore = (filePath, readMethod, suppressErrors) => {
63+
try {
64+
const content = readMethod(filePath, 'utf8');
65+
return {filePath, content};
66+
} catch (error) {
67+
if (shouldSkipIgnoreFileError(error, suppressErrors)) {
68+
return undefined;
69+
}
70+
71+
throw createIgnoreFileReadError(filePath, error);
72+
}
73+
};
74+
75+
const readIgnoreFilesSafely = async (paths, readFileMethod, suppressErrors) => {
76+
const fileResults = await Promise.all(paths.map(async filePath => {
77+
try {
78+
const content = await readFileMethod(filePath, 'utf8');
79+
return {filePath, content};
80+
} catch (error) {
81+
if (shouldSkipIgnoreFileError(error, suppressErrors)) {
82+
return undefined;
83+
}
84+
85+
throw createIgnoreFileReadError(filePath, error);
86+
}
87+
}));
88+
89+
return fileResults.filter(Boolean);
90+
};
91+
92+
const readIgnoreFilesSafelySync = (paths, readFileSyncMethod, suppressErrors) => paths
93+
.map(filePath => processIgnoreFileCore(filePath, readFileSyncMethod, suppressErrors))
94+
.filter(Boolean);
95+
96+
const dedupePaths = paths => {
97+
const seen = new Set();
98+
return paths.filter(filePath => {
99+
if (seen.has(filePath)) {
100+
return false;
101+
}
102+
103+
seen.add(filePath);
104+
return true;
105+
});
106+
};
107+
108+
const globIgnoreFiles = (globFunction, patterns, normalizedOptions) => globFunction(patterns, {
109+
...normalizedOptions,
110+
...ignoreFilesGlobOptions, // Must be last to ensure absolute/dot flags stick
111+
});
112+
113+
const getParentIgnorePaths = (gitRoot, normalizedOptions) => gitRoot
114+
? getParentGitignorePaths(gitRoot, normalizedOptions.cwd)
115+
: [];
116+
117+
const combineIgnoreFilePaths = (gitRoot, normalizedOptions, childPaths) => dedupePaths([
118+
...getParentIgnorePaths(gitRoot, normalizedOptions),
119+
...childPaths,
120+
]);
121+
122+
const buildIgnoreResult = (files, normalizedOptions, gitRoot) => {
123+
const baseDir = gitRoot || normalizedOptions.cwd;
124+
const patterns = getPatternsFromIgnoreFiles(files, baseDir);
125+
126+
return {
127+
patterns,
128+
predicate: createIgnorePredicate(patterns, normalizedOptions.cwd, baseDir),
129+
usingGitRoot: Boolean(gitRoot && gitRoot !== normalizedOptions.cwd),
130+
};
131+
};
132+
34133
// Apply base path to gitignore patterns based on .gitignore spec 2.22.1
35134
// https://git-scm.com/docs/gitignore#_pattern_format
36135
// See also https://github.com/sindresorhus/globby/issues/146
@@ -107,19 +206,30 @@ const toRelativePath = (fileOrDirectory, cwd) => {
107206
return fileOrDirectory;
108207
};
109208

110-
const getIsIgnoredPredicate = (files, cwd) => {
111-
const patterns = files.flatMap(file => parseIgnoreFile(file, cwd));
209+
const createIgnorePredicate = (patterns, cwd, baseDir) => {
112210
const ignores = gitIgnore().add(patterns);
211+
// Normalize to handle path separator and . / .. components consistently
212+
const resolvedCwd = path.normalize(path.resolve(cwd));
213+
const resolvedBaseDir = path.normalize(path.resolve(baseDir));
113214

114215
return fileOrDirectory => {
115216
fileOrDirectory = toPath(fileOrDirectory);
116-
fileOrDirectory = toRelativePath(fileOrDirectory, cwd);
117-
// If path is outside cwd (undefined), it can't be ignored by patterns in cwd
118-
if (fileOrDirectory === undefined) {
217+
218+
// Never ignore the cwd itself - use normalized comparison
219+
const normalizedPath = path.normalize(path.resolve(fileOrDirectory));
220+
if (normalizedPath === resolvedCwd) {
221+
return false;
222+
}
223+
224+
// Convert to relative path from baseDir (use normalized baseDir)
225+
const relativePath = toRelativePath(fileOrDirectory, resolvedBaseDir);
226+
227+
// If path is outside baseDir (undefined), it can't be ignored by patterns
228+
if (relativePath === undefined) {
119229
return false;
120230
}
121231

122-
return fileOrDirectory ? ignores.ignores(slash(fileOrDirectory)) : false;
232+
return relativePath ? ignores.ignores(slash(relativePath)) : false;
123233
};
124234
};
125235

@@ -148,91 +258,79 @@ const normalizeOptions = (options = {}) => {
148258
};
149259
};
150260

151-
export const isIgnoredByIgnoreFiles = async (patterns, options) => {
261+
const collectIgnoreFileArtifactsAsync = async (patterns, options, includeParentIgnoreFiles) => {
152262
const normalizedOptions = normalizeOptions(options);
153-
154-
const paths = await fastGlob(patterns, {
155-
...normalizedOptions,
156-
...ignoreFilesGlobOptions, // Must be last to ensure absolute and dot are always set
157-
});
158-
263+
const childPaths = await globIgnoreFiles(fastGlob, patterns, normalizedOptions);
264+
const gitRoot = includeParentIgnoreFiles
265+
? await findGitRoot(normalizedOptions.cwd, normalizedOptions.fs)
266+
: undefined;
267+
const allPaths = combineIgnoreFilePaths(gitRoot, normalizedOptions, childPaths);
159268
const readFileMethod = getReadFileMethod(normalizedOptions.fs);
160-
const files = await Promise.all(paths.map(async filePath => ({
161-
filePath,
162-
content: await readFileMethod(filePath, 'utf8'),
163-
})));
269+
const files = await readIgnoreFilesSafely(allPaths, readFileMethod, normalizedOptions.suppressErrors);
164270

165-
return getIsIgnoredPredicate(files, normalizedOptions.cwd);
271+
return {files, normalizedOptions, gitRoot};
166272
};
167273

168-
export const isIgnoredByIgnoreFilesSync = (patterns, options) => {
274+
const collectIgnoreFileArtifactsSync = (patterns, options, includeParentIgnoreFiles) => {
169275
const normalizedOptions = normalizeOptions(options);
276+
const childPaths = globIgnoreFiles(fastGlob.sync, patterns, normalizedOptions);
277+
const gitRoot = includeParentIgnoreFiles
278+
? findGitRootSync(normalizedOptions.cwd, normalizedOptions.fs)
279+
: undefined;
280+
const allPaths = combineIgnoreFilePaths(gitRoot, normalizedOptions, childPaths);
281+
const readFileSyncMethod = getReadFileSyncMethod(normalizedOptions.fs);
282+
const files = readIgnoreFilesSafelySync(allPaths, readFileSyncMethod, normalizedOptions.suppressErrors);
170283

171-
const paths = fastGlob.sync(patterns, {
172-
...normalizedOptions,
173-
...ignoreFilesGlobOptions, // Must be last to ensure absolute and dot are always set
174-
});
284+
return {files, normalizedOptions, gitRoot};
285+
};
175286

176-
const readFileSyncMethod = getReadFileSyncMethod(normalizedOptions.fs);
177-
const files = paths.map(filePath => ({
178-
filePath,
179-
content: readFileSyncMethod(filePath, 'utf8'),
180-
}));
287+
export const isIgnoredByIgnoreFiles = async (patterns, options) => {
288+
const {files, normalizedOptions, gitRoot} = await collectIgnoreFileArtifactsAsync(patterns, options, false);
289+
return buildIgnoreResult(files, normalizedOptions, gitRoot).predicate;
290+
};
181291

182-
return getIsIgnoredPredicate(files, normalizedOptions.cwd);
292+
export const isIgnoredByIgnoreFilesSync = (patterns, options) => {
293+
const {files, normalizedOptions, gitRoot} = collectIgnoreFileArtifactsSync(patterns, options, false);
294+
return buildIgnoreResult(files, normalizedOptions, gitRoot).predicate;
183295
};
184296

185-
const getPatternsFromIgnoreFiles = (files, cwd) => files.flatMap(file => parseIgnoreFile(file, cwd));
297+
const getPatternsFromIgnoreFiles = (files, baseDir) => files.flatMap(file => parseIgnoreFile(file, baseDir));
186298

187299
/**
188300
Read ignore files and return both patterns and predicate.
189301
This avoids reading the same files twice (once for patterns, once for filtering).
190302
191-
@returns {Promise<{patterns: string[], predicate: Function}>}
303+
@param {string[]} patterns - Patterns to find ignore files
304+
@param {Object} options - Options object
305+
@param {boolean} [includeParentIgnoreFiles=false] - Whether to search for parent .gitignore files
306+
@returns {Promise<{patterns: string[], predicate: Function, usingGitRoot: boolean}>}
192307
*/
193-
export const getIgnorePatternsAndPredicate = async (patterns, options) => {
194-
const normalizedOptions = normalizeOptions(options);
195-
196-
const paths = await fastGlob(patterns, {
197-
...normalizedOptions,
198-
...ignoreFilesGlobOptions, // Must be last to ensure absolute and dot are always set
199-
});
200-
201-
const readFileMethod = getReadFileMethod(normalizedOptions.fs);
202-
const files = await Promise.all(paths.map(async filePath => ({
203-
filePath,
204-
content: await readFileMethod(filePath, 'utf8'),
205-
})));
206-
207-
return {
208-
patterns: getPatternsFromIgnoreFiles(files, normalizedOptions.cwd),
209-
predicate: getIsIgnoredPredicate(files, normalizedOptions.cwd),
210-
};
308+
export const getIgnorePatternsAndPredicate = async (patterns, options, includeParentIgnoreFiles = false) => {
309+
const {files, normalizedOptions, gitRoot} = await collectIgnoreFileArtifactsAsync(
310+
patterns,
311+
options,
312+
includeParentIgnoreFiles,
313+
);
314+
315+
return buildIgnoreResult(files, normalizedOptions, gitRoot);
211316
};
212317

213318
/**
214319
Read ignore files and return both patterns and predicate (sync version).
215320
216-
@returns {{patterns: string[], predicate: Function}}
321+
@param {string[]} patterns - Patterns to find ignore files
322+
@param {Object} options - Options object
323+
@param {boolean} [includeParentIgnoreFiles=false] - Whether to search for parent .gitignore files
324+
@returns {{patterns: string[], predicate: Function, usingGitRoot: boolean}}
217325
*/
218-
export const getIgnorePatternsAndPredicateSync = (patterns, options) => {
219-
const normalizedOptions = normalizeOptions(options);
220-
221-
const paths = fastGlob.sync(patterns, {
222-
...normalizedOptions,
223-
...ignoreFilesGlobOptions, // Must be last to ensure absolute and dot are always set
224-
});
225-
226-
const readFileSyncMethod = getReadFileSyncMethod(normalizedOptions.fs);
227-
const files = paths.map(filePath => ({
228-
filePath,
229-
content: readFileSyncMethod(filePath, 'utf8'),
230-
}));
231-
232-
return {
233-
patterns: getPatternsFromIgnoreFiles(files, normalizedOptions.cwd),
234-
predicate: getIsIgnoredPredicate(files, normalizedOptions.cwd),
235-
};
326+
export const getIgnorePatternsAndPredicateSync = (patterns, options, includeParentIgnoreFiles = false) => {
327+
const {files, normalizedOptions, gitRoot} = collectIgnoreFileArtifactsSync(
328+
patterns,
329+
options,
330+
includeParentIgnoreFiles,
331+
);
332+
333+
return buildIgnoreResult(files, normalizedOptions, gitRoot);
236334
};
237335

238336
export const isGitIgnored = options => isIgnoredByIgnoreFiles(GITIGNORE_FILES_PATTERN, options);

index.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ export type Options = {
4242
/**
4343
Respect ignore patterns in `.gitignore` files that apply to the globbed files.
4444
45+
When enabled, globby searches for `.gitignore` files from the current working directory downward, and if a Git repository is detected (by finding a `.git` directory), it also respects `.gitignore` files in parent directories up to the repository root. This matches Git's actual behavior where patterns from parent `.gitignore` files apply to subdirectories.
46+
4547
Gitignore patterns take priority over user patterns, matching Git's behavior. To include gitignored files, set this to `false`.
4648
47-
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.
49+
Performance: Globby reads `.gitignore` files before globbing. When there are no negation patterns (like `!important.log`) and no parent `.gitignore` files are found, 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 or parent `.gitignore` files 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.
4850
4951
@default false
5052
*/

0 commit comments

Comments
 (0)