Skip to content

Commit c2eb272

Browse files
committed
Respect custom fs implementation from options
Fixes #265 Fixes #236 Fixes #189
1 parent aa694e0 commit c2eb272

File tree

9 files changed

+206
-32
lines changed

9 files changed

+206
-32
lines changed

ignore.js

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import fastGlob from 'fast-glob';
66
import gitIgnore from 'ignore';
77
import slash from 'slash';
88
import {toPath} from 'unicorn-magic';
9-
import {isNegativePattern} from './utilities.js';
9+
import {isNegativePattern, bindFsMethod} from './utilities.js';
1010

1111
const defaultIgnoredDirectories = [
1212
'**/node_modules',
@@ -21,6 +21,15 @@ const ignoreFilesGlobOptions = {
2121

2222
export const GITIGNORE_FILES_PATTERN = '**/.gitignore';
2323

24+
const getReadFileMethod = fsImplementation =>
25+
bindFsMethod(fsImplementation?.promises, 'readFile')
26+
?? bindFsMethod(fsImplementation, 'readFile')
27+
?? bindFsMethod(fsPromises, 'readFile');
28+
29+
const getReadFileSyncMethod = fsImplementation =>
30+
bindFsMethod(fsImplementation, 'readFileSync')
31+
?? bindFsMethod(fs, 'readFileSync');
32+
2433
// Apply base path to gitignore patterns based on .gitignore spec 2.22.1
2534
// https://git-scm.com/docs/gitignore#_pattern_format
2635
// See also https://github.com/sindresorhus/globby/issues/146
@@ -119,7 +128,7 @@ const normalizeOptions = (options = {}) => {
119128
// Adjust deep option for fast-glob: fast-glob's deep counts differently than expected
120129
// User's deep: 0 = root only -> fast-glob needs: 1
121130
// User's deep: 1 = root + 1 level -> fast-glob needs: 2
122-
const deep = typeof options.deep === 'number' ? options.deep + 1 : Number.POSITIVE_INFINITY;
131+
const deep = typeof options.deep === 'number' ? Math.max(0, options.deep) + 1 : Number.POSITIVE_INFINITY;
123132

124133
// Only pass through specific fast-glob options that make sense for finding ignore files
125134
return {
@@ -130,6 +139,7 @@ const normalizeOptions = (options = {}) => {
130139
followSymbolicLinks: options.followSymbolicLinks ?? true,
131140
concurrency: options.concurrency,
132141
throwErrorOnBrokenSymbolicLink: options.throwErrorOnBrokenSymbolicLink ?? false,
142+
fs: options.fs,
133143
};
134144
};
135145

@@ -141,9 +151,10 @@ export const isIgnoredByIgnoreFiles = async (patterns, options) => {
141151
...ignoreFilesGlobOptions, // Must be last to ensure absolute and dot are always set
142152
});
143153

154+
const readFileMethod = getReadFileMethod(normalizedOptions.fs);
144155
const files = await Promise.all(paths.map(async filePath => ({
145156
filePath,
146-
content: await fsPromises.readFile(filePath, 'utf8'),
157+
content: await readFileMethod(filePath, 'utf8'),
147158
})));
148159

149160
return getIsIgnoredPredicate(files, normalizedOptions.cwd);
@@ -157,9 +168,10 @@ export const isIgnoredByIgnoreFilesSync = (patterns, options) => {
157168
...ignoreFilesGlobOptions, // Must be last to ensure absolute and dot are always set
158169
});
159170

171+
const readFileSyncMethod = getReadFileSyncMethod(normalizedOptions.fs);
160172
const files = paths.map(filePath => ({
161173
filePath,
162-
content: fs.readFileSync(filePath, 'utf8'),
174+
content: readFileSyncMethod(filePath, 'utf8'),
163175
}));
164176

165177
return getIsIgnoredPredicate(files, normalizedOptions.cwd);
@@ -181,9 +193,10 @@ export const getIgnorePatternsAndPredicate = async (patterns, options) => {
181193
...ignoreFilesGlobOptions, // Must be last to ensure absolute and dot are always set
182194
});
183195

196+
const readFileMethod = getReadFileMethod(normalizedOptions.fs);
184197
const files = await Promise.all(paths.map(async filePath => ({
185198
filePath,
186-
content: await fsPromises.readFile(filePath, 'utf8'),
199+
content: await readFileMethod(filePath, 'utf8'),
187200
})));
188201

189202
return {
@@ -205,9 +218,10 @@ export const getIgnorePatternsAndPredicateSync = (patterns, options) => {
205218
...ignoreFilesGlobOptions, // Must be last to ensure absolute and dot are always set
206219
});
207220

221+
const readFileSyncMethod = getReadFileSyncMethod(normalizedOptions.fs);
208222
const files = paths.map(filePath => ({
209223
filePath,
210-
content: fs.readFileSync(filePath, 'utf8'),
224+
content: readFileSyncMethod(filePath, 'utf8'),
211225
}));
212226

213227
return {

index.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@ export type GitignoreOptions = {
120120
@default false
121121
*/
122122
readonly throwErrorOnBrokenSymbolicLink?: boolean;
123+
124+
/**
125+
Custom file system implementation (useful for testing or virtual file systems).
126+
127+
@default undefined
128+
*/
129+
readonly fs?: FastGlob.Options['fs'];
123130
};
124131

125132
export type GlobbyFilterFunction = (path: URL | string) => boolean;

index.js

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import nodePath from 'node:path';
44
import {Readable} from 'node:stream';
55
import mergeStreams from '@sindresorhus/merge-streams';
66
import fastGlob from 'fast-glob';
7-
import {isDirectory, isDirectorySync} from 'path-type';
87
import {toPath} from 'unicorn-magic';
98
import {
109
GITIGNORE_FILES_PATTERN,
1110
getIgnorePatternsAndPredicate,
1211
getIgnorePatternsAndPredicateSync,
1312
} from './ignore.js';
1413
import {
14+
bindFsMethod,
1515
isNegativePattern,
1616
normalizeDirectoryPatternForFastGlob,
1717
} from './utilities.js';
@@ -22,6 +22,33 @@ const assertPatternsInput = patterns => {
2222
}
2323
};
2424

25+
const getStatMethod = fsImplementation =>
26+
bindFsMethod(fsImplementation?.promises, 'stat')
27+
?? bindFsMethod(fsImplementation, 'stat')
28+
?? bindFsMethod(fs.promises, 'stat');
29+
30+
const getStatSyncMethod = fsImplementation =>
31+
bindFsMethod(fsImplementation, 'statSync')
32+
?? bindFsMethod(fs, 'statSync');
33+
34+
const isDirectory = async (path, fsImplementation) => {
35+
try {
36+
const stats = await getStatMethod(fsImplementation)(path);
37+
return stats.isDirectory();
38+
} catch {
39+
return false;
40+
}
41+
};
42+
43+
const isDirectorySync = (path, fsImplementation) => {
44+
try {
45+
const stats = getStatSyncMethod(fsImplementation)(path);
46+
return stats.isDirectory();
47+
} catch {
48+
return false;
49+
}
50+
};
51+
2552
const normalizePathForDirectoryGlob = (filePath, cwd) => {
2653
const path = isNegativePattern(filePath) ? filePath.slice(1) : filePath;
2754
return nodePath.isAbsolute(path) ? path : nodePath.join(cwd, path);
@@ -51,6 +78,7 @@ const directoryToGlob = async (directoryPaths, {
5178
cwd = process.cwd(),
5279
files,
5380
extensions,
81+
fs: fsImplementation,
5482
} = {}) => {
5583
const globs = await Promise.all(directoryPaths.map(async directoryPath => {
5684
// Check pattern without negative prefix
@@ -63,7 +91,7 @@ const directoryToGlob = async (directoryPaths, {
6391

6492
// Original logic for checking actual directories
6593
const pathToCheck = normalizePathForDirectoryGlob(directoryPath, cwd);
66-
return (await isDirectory(pathToCheck)) ? getDirectoryGlob({directoryPath, files, extensions}) : directoryPath;
94+
return (await isDirectory(pathToCheck, fsImplementation)) ? getDirectoryGlob({directoryPath, files, extensions}) : directoryPath;
6795
}));
6896

6997
return globs.flat();
@@ -73,6 +101,7 @@ const directoryToGlobSync = (directoryPaths, {
73101
cwd = process.cwd(),
74102
files,
75103
extensions,
104+
fs: fsImplementation,
76105
} = {}) => directoryPaths.flatMap(directoryPath => {
77106
// Check pattern without negative prefix
78107
const checkPattern = isNegativePattern(directoryPath) ? directoryPath.slice(1) : directoryPath;
@@ -84,7 +113,7 @@ const directoryToGlobSync = (directoryPaths, {
84113

85114
// Original logic for checking actual directories
86115
const pathToCheck = normalizePathForDirectoryGlob(directoryPath, cwd);
87-
return isDirectorySync(pathToCheck) ? getDirectoryGlob({directoryPath, files, extensions}) : directoryPath;
116+
return isDirectorySync(pathToCheck, fsImplementation) ? getDirectoryGlob({directoryPath, files, extensions}) : directoryPath;
88117
});
89118

90119
const toPatternsArray = patterns => {
@@ -93,20 +122,19 @@ const toPatternsArray = patterns => {
93122
return patterns;
94123
};
95124

96-
const checkCwdOption = cwd => {
97-
if (!cwd) {
125+
const checkCwdOption = (cwd, fsImplementation = fs) => {
126+
if (!cwd || !fsImplementation.statSync) {
98127
return;
99128
}
100129

101-
let stat;
102130
try {
103-
stat = fs.statSync(cwd);
104-
} catch {
105-
return;
106-
}
107-
108-
if (!stat.isDirectory()) {
109-
throw new Error('The `cwd` option must be a path to a directory');
131+
if (!fsImplementation.statSync(cwd).isDirectory()) {
132+
throw new Error('The `cwd` option must be a path to a directory');
133+
}
134+
} catch (error) {
135+
if (error.message === 'The `cwd` option must be a path to a directory') {
136+
throw error;
137+
}
110138
}
111139
};
112140

@@ -118,7 +146,7 @@ const normalizeOptions = (options = {}) => {
118146
cwd: toPath(options.cwd),
119147
};
120148

121-
checkCwdOption(options.cwd);
149+
checkCwdOption(options.cwd, options.fs);
122150

123151
return options;
124152
};
@@ -284,13 +312,16 @@ const normalizeExpandDirectoriesOption = (options, cwd) => ({
284312
const generateTasks = async (patterns, options) => {
285313
const globTasks = convertNegativePatterns(patterns, options);
286314

287-
const {cwd, expandDirectories} = options;
315+
const {cwd, expandDirectories, fs: fsImplementation} = options;
288316

289317
if (!expandDirectories) {
290318
return globTasks;
291319
}
292320

293-
const directoryToGlobOptions = normalizeExpandDirectoriesOption(expandDirectories, cwd);
321+
const directoryToGlobOptions = {
322+
...normalizeExpandDirectoriesOption(expandDirectories, cwd),
323+
fs: fsImplementation,
324+
};
294325

295326
return Promise.all(globTasks.map(async task => {
296327
let {patterns, options} = task;
@@ -300,7 +331,7 @@ const generateTasks = async (patterns, options) => {
300331
options.ignore,
301332
] = await Promise.all([
302333
directoryToGlob(patterns, directoryToGlobOptions),
303-
directoryToGlob(options.ignore, {cwd}),
334+
directoryToGlob(options.ignore, {cwd, fs: fsImplementation}),
304335
]);
305336

306337
return {patterns, options};
@@ -309,18 +340,21 @@ const generateTasks = async (patterns, options) => {
309340

310341
const generateTasksSync = (patterns, options) => {
311342
const globTasks = convertNegativePatterns(patterns, options);
312-
const {cwd, expandDirectories} = options;
343+
const {cwd, expandDirectories, fs: fsImplementation} = options;
313344

314345
if (!expandDirectories) {
315346
return globTasks;
316347
}
317348

318-
const directoryToGlobSyncOptions = normalizeExpandDirectoriesOption(expandDirectories, cwd);
349+
const directoryToGlobSyncOptions = {
350+
...normalizeExpandDirectoriesOption(expandDirectories, cwd),
351+
fs: fsImplementation,
352+
};
319353

320354
return globTasks.map(task => {
321355
let {patterns, options} = task;
322356
patterns = directoryToGlobSync(patterns, directoryToGlobSyncOptions);
323-
options.ignore = directoryToGlobSync(options.ignore, {cwd});
357+
options.ignore = directoryToGlobSync(options.ignore, {cwd, fs: fsImplementation});
324358
return {patterns, options};
325359
});
326360
};

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@
6666
"@sindresorhus/merge-streams": "^4.0.0",
6767
"fast-glob": "^3.3.3",
6868
"ignore": "^7.0.5",
69-
"path-type": "^6.0.0",
7069
"slash": "^5.1.0",
7170
"unicorn-magic": "^0.3.0"
7271
},

readme.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ This is a more generic form of the `gitignore` option, allowing you to find igno
101101

102102
**Performance tip:** Using a specific path like `'.gitignore'` is much faster than recursive patterns.
103103

104+
##### fs
105+
106+
Type: [`FileSystemAdapter`](https://github.com/mrmlnc/fast-glob#fs)\
107+
Default: `undefined`
108+
109+
Custom file system implementation (useful for testing or virtual file systems).
110+
111+
**Note:** When using `gitignore` or `ignoreFiles`, the custom fs must also provide `readFile`/`readFileSync` methods.
112+
104113
### globbySync(patterns, options?)
105114

106115
Returns `string[]` of matching paths.
@@ -221,6 +230,15 @@ Default: `false`
221230

222231
Throw an error when symbolic link is broken if `true` or safely return `lstat` call if `false`.
223232

233+
##### fs
234+
235+
Type: [`FileSystemAdapter`](https://github.com/mrmlnc/fast-glob#fs)\
236+
Default: `undefined`
237+
238+
Custom file system implementation (useful for testing or virtual file systems).
239+
240+
**Note:** The custom fs must provide `readFile`/`readFileSync` methods for reading `.gitignore` files.
241+
224242
```js
225243
import {isGitIgnored} from 'globby';
226244

tests/globby.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import {normalizeDirectoryPatternForFastGlob} from '../utilities.js';
1515
import {
1616
PROJECT_ROOT,
17+
createContextAwareFs,
1718
getPathValues,
1819
invalidPatterns,
1920
isUnique,
@@ -302,6 +303,12 @@ test('expandDirectories option', async t => {
302303
}), ['tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp']);
303304
});
304305

306+
test('fs option preserves context during directory expansion', async t => {
307+
const fsImplementation = createContextAwareFs();
308+
const result = await runGlobby(t, temporary, {fs: fsImplementation});
309+
t.deepEqual(result, ['tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp']);
310+
});
311+
305312
test('expandDirectories:true and onlyFiles:true option', async t => {
306313
t.deepEqual(await runGlobby(t, temporary, {onlyFiles: true}), ['tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp']);
307314
});

0 commit comments

Comments
 (0)