@cjser/globby 16.2.0-cjser.2 → 16.2.2-cjser.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  ));
27
27
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
28
 
29
- // packages/@cjser/globby.tmp-26-1778145952693/index.js
29
+ // packages/@cjser/globby.tmp-26-1784218928215/index.js
30
30
  var index_exports = {};
31
31
  __export(index_exports, {
32
32
  convertPathToPattern: () => convertPathToPattern,
@@ -50,24 +50,26 @@ var import_sindresorhus_merge_streams = __toESM(require("@cjser/sindresorhus__me
50
50
  var import_fast_glob3 = __toESM(require("fast-glob"), 1);
51
51
  var import_node2 = require("@cjser/unicorn-magic/node");
52
52
 
53
- // packages/@cjser/globby.tmp-26-1778145952693/ignore.js
53
+ // packages/@cjser/globby.tmp-26-1784218928215/ignore.js
54
54
  var import_node_process = __toESM(require("node:process"), 1);
55
55
  var import_node_fs2 = __toESM(require("node:fs"), 1);
56
56
  var import_promises = __toESM(require("node:fs/promises"), 1);
57
57
  var import_node_path2 = __toESM(require("node:path"), 1);
58
58
  var import_node_os = __toESM(require("node:os"), 1);
59
59
  var import_fast_glob2 = __toESM(require("fast-glob"), 1);
60
- var import_ignore = __toESM(require("ignore"), 1);
60
+ var import_ignore2 = __toESM(require("ignore"), 1);
61
61
  var import_is_path_inside2 = __toESM(require("@cjser/is-path-inside"), 1);
62
- var import_slash = __toESM(require("@cjser/slash"), 1);
62
+ var import_slash2 = __toESM(require("@cjser/slash"), 1);
63
63
  var import_node = require("@cjser/unicorn-magic/node");
64
64
 
65
- // packages/@cjser/globby.tmp-26-1778145952693/utilities.js
65
+ // packages/@cjser/globby.tmp-26-1784218928215/utilities.js
66
66
  var import_node_fs = __toESM(require("node:fs"), 1);
67
67
  var import_node_path = __toESM(require("node:path"), 1);
68
68
  var import_node_util = require("node:util");
69
69
  var import_fast_glob = __toESM(require("fast-glob"), 1);
70
+ var import_ignore = __toESM(require("ignore"), 1);
70
71
  var import_is_path_inside = __toESM(require("@cjser/is-path-inside"), 1);
72
+ var import_slash = __toESM(require("@cjser/slash"), 1);
71
73
  var isNegativePattern = (pattern) => pattern[0] === "!";
72
74
  var normalizeAbsolutePatternToRelative = (pattern) => {
73
75
  if (!pattern.startsWith("/")) {
@@ -268,23 +270,113 @@ var getParentGitignorePaths = (gitRoot, cwd) => {
268
270
  const chain = buildPathChain(import_node_path.default.resolve(cwd), import_node_path.default.resolve(gitRoot));
269
271
  return [...chain].reverse().map((directory) => import_node_path.default.join(directory, ".gitignore"));
270
272
  };
271
- var convertPatternsForFastGlob = (patterns, usingGitRoot, normalizeDirectoryPatternForFastGlob2) => {
272
- if (usingGitRoot) {
273
- return [];
273
+ var GITIGNORE_WILDCARDS = /(?<!\\)[*?[]/u;
274
+ var hasGitignoreWildcards = (value) => GITIGNORE_WILDCARDS.test(value);
275
+ var MICROMATCH_ONLY_SYNTAX = /[(){}|\\]/u;
276
+ var unescapeGitignorePattern = (value) => value.replaceAll(/\\(.)/gu, "$1");
277
+ var toLiteralPattern = (value) => import_fast_glob.default.escapePath(unescapeGitignorePattern(value));
278
+ var finalSegment = (value) => value.replace(/\/+$/u, "").split("/").pop();
279
+ var isInsideCwd = (relativePath) => relativePath !== "" && !relativePath.startsWith("..") && !import_node_path.default.isAbsolute(relativePath);
280
+ var anchorToCwd = (directory, body, cwd) => {
281
+ const relativePath = (0, import_slash.default)(import_node_path.default.relative(cwd, import_node_path.default.join(directory, body)));
282
+ return isInsideCwd(relativePath) ? relativePath : void 0;
283
+ };
284
+ var createNameComparer = () => {
285
+ const nameMatchers = /* @__PURE__ */ new Map();
286
+ const matchesName = (pattern, name) => {
287
+ let nameMatcher = nameMatchers.get(pattern);
288
+ if (!nameMatcher) {
289
+ nameMatcher = (0, import_ignore.default)().add([pattern]);
290
+ nameMatchers.set(pattern, nameMatcher);
291
+ }
292
+ return nameMatcher.ignores(name);
293
+ };
294
+ return (pattern, name) => {
295
+ if (hasGitignoreWildcards(pattern) && hasGitignoreWildcards(name)) {
296
+ return true;
297
+ }
298
+ return hasGitignoreWildcards(name) ? matchesName(name, pattern) : matchesName(pattern, name);
299
+ };
300
+ };
301
+ var getNegationFinalSegments = (rules) => rules.filter((rule) => isNegativePattern(rule.pattern)).map((rule) => finalSegment(rule.pattern.slice(1))).filter(Boolean);
302
+ var negationsCouldRescue = (rules, names) => {
303
+ if (names.length === 0) {
304
+ return false;
274
305
  }
275
- const result = [];
276
- let hasNegations = false;
277
- for (const pattern of patterns) {
278
- if (isNegativePattern(pattern)) {
279
- hasNegations = true;
280
- break;
306
+ const couldNameTheSamePath = createNameComparer();
307
+ return getNegationFinalSegments(rules).some((negation) => names.some((name) => couldNameTheSamePath(name, negation)));
308
+ };
309
+ var getRulePrune = ({ pattern, directory }, { cwd, matcher, hasNegations, canSkipAtAnyDepth, canMatchIgnoreFile, gitignoreOnlySearch }) => {
310
+ if (isNegativePattern(pattern)) {
311
+ return void 0;
312
+ }
313
+ const isDirectoryPattern = pattern.endsWith("/");
314
+ const clean = pattern.replace(/\/+$/u, "");
315
+ if (!clean) {
316
+ return void 0;
317
+ }
318
+ const body = clean.startsWith("**/") && !clean.slice(3).includes("/") ? clean.slice(3) : clean;
319
+ if (canMatchIgnoreFile(finalSegment(body))) {
320
+ return void 0;
321
+ }
322
+ const isGlob = hasGitignoreWildcards(body);
323
+ if (isGlob && MICROMATCH_ONLY_SYNTAX.test(body)) {
324
+ return void 0;
325
+ }
326
+ const toFastGlob = (value) => normalizeDirectoryPatternForFastGlob(`/${value}${isDirectoryPattern ? "/" : ""}`).replace(/^\//u, "");
327
+ if (!body.includes("/") && canSkipAtAnyDepth(body)) {
328
+ const relativeDirectory = (0, import_slash.default)(import_node_path.default.relative(cwd, directory));
329
+ const prefix = isInsideCwd(relativeDirectory) ? `${import_fast_glob.default.escapePath(relativeDirectory)}/` : "";
330
+ return { pattern: toFastGlob(`${prefix}**/${isGlob ? body : toLiteralPattern(body)}`), guardName: body };
331
+ }
332
+ const anchoredBody = body.replace(/^\//u, "");
333
+ const target = anchorToCwd(directory, isGlob ? anchoredBody : unescapeGitignorePattern(anchoredBody), cwd);
334
+ if (target === void 0) {
335
+ return void 0;
336
+ }
337
+ if (isGlob) {
338
+ return hasNegations ? void 0 : { pattern: toFastGlob(target), guardName: finalSegment(target) };
339
+ }
340
+ if (!matcher(import_node_path.default.resolve(cwd, target) + import_node_path.default.sep).ignored) {
341
+ return void 0;
342
+ }
343
+ const needsGuard = !gitignoreOnlySearch || target.includes("/");
344
+ return {
345
+ pattern: toFastGlob(import_fast_glob.default.escapePath(target)),
346
+ guardName: needsGuard ? finalSegment(target) : void 0
347
+ };
348
+ };
349
+ var buildPrunePatternsAndGuards = (rules, matcher, cwd, { gitignoreOnlySearch = false, searchesForGitignoreFiles = false } = {}) => {
350
+ if (!matcher || !cwd || !rules || rules.length === 0) {
351
+ return { patterns: [], guardNames: [] };
352
+ }
353
+ const negationNames = getNegationFinalSegments(rules);
354
+ const couldNameTheSamePath = createNameComparer();
355
+ const context = {
356
+ cwd,
357
+ matcher,
358
+ hasNegations: negationNames.length > 0,
359
+ canSkipAtAnyDepth: (pattern) => !negationNames.some((name) => couldNameTheSamePath(pattern, name)),
360
+ canMatchIgnoreFile: (pattern) => searchesForGitignoreFiles && couldNameTheSamePath(pattern, ".gitignore"),
361
+ gitignoreOnlySearch
362
+ };
363
+ const patterns = [];
364
+ const guardNames = [];
365
+ for (const rule of rules) {
366
+ const prune = getRulePrune(rule, context);
367
+ if (!prune) {
368
+ continue;
369
+ }
370
+ patterns.push(prune.pattern);
371
+ if (prune.guardName !== void 0) {
372
+ guardNames.push(prune.guardName);
281
373
  }
282
- result.push(normalizeDirectoryPatternForFastGlob2(pattern));
283
374
  }
284
- return hasNegations ? [] : result;
375
+ return { patterns, guardNames };
285
376
  };
377
+ var convertPatternsForFastGlob = (rules, matcher, cwd) => buildPrunePatternsAndGuards(rules, matcher, cwd).patterns;
286
378
 
287
- // packages/@cjser/globby.tmp-26-1778145952693/ignore.js
379
+ // packages/@cjser/globby.tmp-26-1784218928215/ignore.js
288
380
  var defaultIgnoredDirectories = [
289
381
  "**/node_modules",
290
382
  "**/flow-typed",
@@ -358,17 +450,33 @@ var globIgnoreFiles = (globFunction, patterns, normalizedOptions) => globFunctio
358
450
  ...ignoreFilesGlobOptions
359
451
  // Must be last to ensure absolute/dot flags stick
360
452
  });
361
- var getParentIgnorePaths = (gitRoot, normalizedOptions) => gitRoot ? getParentGitignorePaths(gitRoot, normalizedOptions.cwd) : [];
362
- var combineIgnoreFilePaths = (gitRoot, normalizedOptions, childPaths) => dedupePaths([
363
- ...getParentIgnorePaths(gitRoot, normalizedOptions),
364
- ...childPaths
365
- ]);
453
+ var normalizeIgnoreFileLine = (line) => {
454
+ line = line.replace(/^\uFEFF/u, "");
455
+ let whitespaceStart = line.length;
456
+ while (whitespaceStart > 0 && /\s/u.test(line[whitespaceStart - 1])) {
457
+ whitespaceStart--;
458
+ }
459
+ if (whitespaceStart === line.length) {
460
+ return line;
461
+ }
462
+ let backslashCount = 0;
463
+ for (let index = whitespaceStart - 1; index >= 0 && line[index] === "\\"; index--) {
464
+ backslashCount++;
465
+ }
466
+ return backslashCount % 2 === 1 ? line.slice(0, whitespaceStart) + " " : line.slice(0, whitespaceStart);
467
+ };
468
+ var readIgnoreFileLines = (content) => content.split(/\r?\n/).map((line) => normalizeIgnoreFileLine(line)).filter((line) => line && !line.startsWith("#"));
469
+ var getIgnoreRules = (files) => files.flatMap((file) => {
470
+ const directory = import_node_path2.default.dirname(file.filePath);
471
+ return readIgnoreFileLines(file.content).map((pattern) => ({ pattern, directory }));
472
+ });
366
473
  var buildIgnoreResult = (files, normalizedOptions, gitRoot) => {
367
474
  const baseDir = gitRoot || normalizedOptions.cwd;
368
475
  const patterns = getPatternsFromIgnoreFiles(files, baseDir);
369
476
  const matcher = createIgnoreMatcher(patterns, normalizedOptions.cwd, baseDir);
370
477
  return {
371
478
  patterns,
479
+ rules: getIgnoreRules(files),
372
480
  matcher,
373
481
  predicate: (fileOrDirectory) => matcher(fileOrDirectory).ignored,
374
482
  usingGitRoot: Boolean(gitRoot && gitRoot !== normalizedOptions.cwd)
@@ -393,8 +501,8 @@ var applyBaseToPattern = (pattern, base) => {
393
501
  return isNegative ? "!" + result : result;
394
502
  };
395
503
  var parseIgnoreFile = (file, cwd) => {
396
- const base = (0, import_slash.default)(import_node_path2.default.relative(cwd, import_node_path2.default.dirname(file.filePath)));
397
- return file.content.split(/\r?\n/).filter((line) => line && !line.startsWith("#")).map((pattern) => applyBaseToPattern(pattern, base));
504
+ const base = (0, import_slash2.default)(import_node_path2.default.relative(cwd, import_node_path2.default.dirname(file.filePath)));
505
+ return readIgnoreFileLines(file.content).map((pattern) => applyBaseToPattern(pattern, base));
398
506
  };
399
507
  var toRelativePath = (fileOrDirectory, cwd) => {
400
508
  if (import_node_path2.default.isAbsolute(fileOrDirectory)) {
@@ -414,7 +522,7 @@ var toRelativePath = (fileOrDirectory, cwd) => {
414
522
  };
415
523
  var notIgnored = { ignored: false, unignored: false };
416
524
  var createIgnoreMatcher = (patterns, cwd, baseDir) => {
417
- const ignores = (0, import_ignore.default)().add(patterns);
525
+ const ignores = (0, import_ignore2.default)().add(patterns);
418
526
  const resolvedCwd = import_node_path2.default.normalize(import_node_path2.default.resolve(cwd));
419
527
  const resolvedBaseDir = import_node_path2.default.normalize(import_node_path2.default.resolve(baseDir));
420
528
  return (fileOrDirectory) => {
@@ -434,7 +542,7 @@ var createIgnoreMatcher = (patterns, cwd, baseDir) => {
434
542
  if (hasTrailingSeparator && !relativePath.endsWith(import_node_path2.default.sep)) {
435
543
  relativePath += import_node_path2.default.sep;
436
544
  }
437
- return ignores.test((0, import_slash.default)(relativePath));
545
+ return ignores.test((0, import_slash2.default)(relativePath));
438
546
  };
439
547
  };
440
548
  var normalizeOptions = (options = {}) => {
@@ -560,7 +668,7 @@ var normalizeGitConfigConditionPattern = (pattern, configFilePath) => {
560
668
  if (pattern.endsWith("/")) {
561
669
  pattern += "**";
562
670
  }
563
- return (0, import_slash.default)(pattern);
671
+ return (0, import_slash2.default)(pattern);
564
672
  };
565
673
  var gitConfigGlobToRegex = (pattern, flags) => {
566
674
  let regex = "";
@@ -618,7 +726,7 @@ var matchesIncludeIfCondition = (condition, gitDirectory, configFilePath) => {
618
726
  const pattern = normalizeGitConfigConditionPattern(rawPattern.trim(), configFilePath);
619
727
  const isCaseInsensitive = keyword.toLowerCase() === "gitdir/i";
620
728
  const regularExpression = gitConfigGlobToRegex(pattern, isCaseInsensitive ? "i" : void 0);
621
- const normalizedGitDirectory = (0, import_slash.default)(import_node_path2.default.resolve(gitDirectory));
729
+ const normalizedGitDirectory = (0, import_slash2.default)(import_node_path2.default.resolve(gitDirectory));
622
730
  return regularExpression.test(normalizedGitDirectory);
623
731
  };
624
732
  var shouldIncludeConfigSection = (section, gitDirectory, configFilePath) => {
@@ -830,23 +938,103 @@ var buildGlobalMatcher = (globalIgnoreFile, cwd, rootDirectory = cwd) => {
830
938
  const patterns = parseIgnoreFile(globalIgnoreFile, import_node_path2.default.dirname(globalIgnoreFile.filePath));
831
939
  return createIgnoreMatcher(patterns, cwd, rootDirectory);
832
940
  };
941
+ var getKnownIgnoreFilePaths = (patterns, normalizedOptions, gitRoot) => {
942
+ const searchPatterns = [patterns].flat();
943
+ const isGitignoreSearch = searchPatterns.includes(GITIGNORE_FILES_PATTERN);
944
+ if (!isGitignoreSearch) {
945
+ return [];
946
+ }
947
+ return gitRoot ? getParentGitignorePaths(gitRoot, normalizedOptions.cwd) : [import_node_path2.default.join(normalizedOptions.cwd, ".gitignore")];
948
+ };
949
+ var getKnownIgnoreFileSearchOptions = (patterns, normalizedOptions) => ({
950
+ ...normalizedOptions,
951
+ ignore: [
952
+ ...normalizedOptions.ignore,
953
+ // Keep negative search patterns active when the known candidates are matched independently.
954
+ ...[patterns].flat().filter((pattern) => isNegativePattern(pattern)).map((pattern) => pattern.slice(1))
955
+ ]
956
+ });
957
+ var getKnownIgnoreFilePattern = (filePath, cwd) => {
958
+ const pattern = (0, import_is_path_inside2.default)(filePath, cwd) ? import_node_path2.default.relative(cwd, filePath) : filePath;
959
+ return import_fast_glob2.default.convertPathToPattern(pattern);
960
+ };
961
+ var getMatchingKnownIgnoreFilePaths = (knownPaths, matchingPaths) => {
962
+ const matchingPathSet = new Set(matchingPaths.map((filePath) => import_node_path2.default.resolve(filePath)));
963
+ return knownPaths.filter((filePath) => matchingPathSet.has(import_node_path2.default.resolve(filePath)));
964
+ };
965
+ var globKnownIgnoreFilePaths = (globFunction, knownPaths, patterns, normalizedOptions) => {
966
+ if (knownPaths.length === 0) {
967
+ return [];
968
+ }
969
+ return globIgnoreFiles(
970
+ globFunction,
971
+ knownPaths.map((filePath) => getKnownIgnoreFilePattern(filePath, normalizedOptions.cwd)),
972
+ getKnownIgnoreFileSearchOptions(patterns, normalizedOptions)
973
+ );
974
+ };
975
+ var filterKnownIgnoreFilePathsAsync = async (knownPaths, patterns, normalizedOptions) => {
976
+ const matchingPaths = await globKnownIgnoreFilePaths(import_fast_glob2.default, knownPaths, patterns, normalizedOptions);
977
+ return getMatchingKnownIgnoreFilePaths(knownPaths, matchingPaths);
978
+ };
979
+ var filterKnownIgnoreFilePathsSync = (knownPaths, patterns, normalizedOptions) => {
980
+ const matchingPaths = globKnownIgnoreFilePaths(import_fast_glob2.default.sync, knownPaths, patterns, normalizedOptions);
981
+ return getMatchingKnownIgnoreFilePaths(knownPaths, matchingPaths);
982
+ };
983
+ var getIgnoreFileSearchPrune = (searchPatterns, files, normalizedOptions, gitRoot) => {
984
+ if (files.length === 0) {
985
+ return { patterns: [], guardNames: [] };
986
+ }
987
+ const { cwd } = normalizedOptions;
988
+ const baseDir = gitRoot || cwd;
989
+ const ignorePatterns = getPatternsFromIgnoreFiles(files, baseDir);
990
+ const matcher = createIgnoreMatcher(ignorePatterns, cwd, baseDir);
991
+ const searchPatternsArray = [searchPatterns].flat();
992
+ const gitignoreOnlySearch = searchPatternsArray.every((pattern) => pattern === GITIGNORE_FILES_PATTERN);
993
+ const searchesForGitignoreFiles = searchPatternsArray.includes(GITIGNORE_FILES_PATTERN);
994
+ return buildPrunePatternsAndGuards(getIgnoreRules(files), matcher, cwd, { gitignoreOnlySearch, searchesForGitignoreFiles });
995
+ };
996
+ var withPrunedSearch = (normalizedOptions, prunePatterns) => prunePatterns.length === 0 ? normalizedOptions : { ...normalizedOptions, ignore: [...normalizedOptions.ignore, ...prunePatterns] };
997
+ var getUnreadPaths = (childPaths, knownPaths) => {
998
+ const alreadyRead = new Set(knownPaths.map((filePath) => import_node_path2.default.resolve(filePath)));
999
+ return dedupePaths(childPaths).filter((filePath) => !alreadyRead.has(import_node_path2.default.resolve(filePath)));
1000
+ };
833
1001
  var collectIgnoreFileArtifactsAsync = async (patterns, options, includeParentIgnoreFiles) => {
834
1002
  const normalizedOptions = normalizeOptions(options);
835
- const childPaths = await globIgnoreFiles(import_fast_glob2.default, patterns, normalizedOptions);
836
- const gitRoot = includeParentIgnoreFiles ? await findGitRoot(normalizedOptions.cwd, normalizedOptions.fs) : void 0;
837
- const allPaths = combineIgnoreFilePaths(gitRoot, normalizedOptions, childPaths);
838
1003
  const readFileMethod = getReadFileMethod(normalizedOptions.fs);
839
- const files = await readIgnoreFilesSafely(allPaths, readFileMethod, normalizedOptions.suppressErrors);
840
- return { files, normalizedOptions, gitRoot };
1004
+ const gitRoot = includeParentIgnoreFiles ? await findGitRoot(normalizedOptions.cwd, normalizedOptions.fs) : void 0;
1005
+ const knownPaths = await filterKnownIgnoreFilePathsAsync(
1006
+ getKnownIgnoreFilePaths(patterns, normalizedOptions, gitRoot),
1007
+ patterns,
1008
+ normalizedOptions
1009
+ );
1010
+ const knownFiles = await readIgnoreFilesSafely(knownPaths, readFileMethod, normalizedOptions.suppressErrors);
1011
+ const { patterns: prunePatterns, guardNames } = getIgnoreFileSearchPrune(patterns, knownFiles, normalizedOptions, gitRoot);
1012
+ const childPaths = await globIgnoreFiles(import_fast_glob2.default, patterns, withPrunedSearch(normalizedOptions, prunePatterns));
1013
+ let childFiles = await readIgnoreFilesSafely(getUnreadPaths(childPaths, knownPaths), readFileMethod, normalizedOptions.suppressErrors);
1014
+ if (negationsCouldRescue(getIgnoreRules(childFiles), guardNames)) {
1015
+ const allPaths = await globIgnoreFiles(import_fast_glob2.default, patterns, normalizedOptions);
1016
+ childFiles = await readIgnoreFilesSafely(getUnreadPaths(allPaths, knownPaths), readFileMethod, normalizedOptions.suppressErrors);
1017
+ }
1018
+ return { files: [...knownFiles, ...childFiles], normalizedOptions, gitRoot };
841
1019
  };
842
1020
  var collectIgnoreFileArtifactsSync = (patterns, options, includeParentIgnoreFiles) => {
843
1021
  const normalizedOptions = normalizeOptions(options);
844
- const childPaths = globIgnoreFiles(import_fast_glob2.default.sync, patterns, normalizedOptions);
845
- const gitRoot = includeParentIgnoreFiles ? findGitRootSync(normalizedOptions.cwd, normalizedOptions.fs) : void 0;
846
- const allPaths = combineIgnoreFilePaths(gitRoot, normalizedOptions, childPaths);
847
1022
  const readFileSyncMethod = getReadFileSyncMethod(normalizedOptions.fs);
848
- const files = readIgnoreFilesSafelySync(allPaths, readFileSyncMethod, normalizedOptions.suppressErrors);
849
- return { files, normalizedOptions, gitRoot };
1023
+ const gitRoot = includeParentIgnoreFiles ? findGitRootSync(normalizedOptions.cwd, normalizedOptions.fs) : void 0;
1024
+ const knownPaths = filterKnownIgnoreFilePathsSync(
1025
+ getKnownIgnoreFilePaths(patterns, normalizedOptions, gitRoot),
1026
+ patterns,
1027
+ normalizedOptions
1028
+ );
1029
+ const knownFiles = readIgnoreFilesSafelySync(knownPaths, readFileSyncMethod, normalizedOptions.suppressErrors);
1030
+ const { patterns: prunePatterns, guardNames } = getIgnoreFileSearchPrune(patterns, knownFiles, normalizedOptions, gitRoot);
1031
+ const childPaths = globIgnoreFiles(import_fast_glob2.default.sync, patterns, withPrunedSearch(normalizedOptions, prunePatterns));
1032
+ let childFiles = readIgnoreFilesSafelySync(getUnreadPaths(childPaths, knownPaths), readFileSyncMethod, normalizedOptions.suppressErrors);
1033
+ if (negationsCouldRescue(getIgnoreRules(childFiles), guardNames)) {
1034
+ const allPaths = globIgnoreFiles(import_fast_glob2.default.sync, patterns, normalizedOptions);
1035
+ childFiles = readIgnoreFilesSafelySync(getUnreadPaths(allPaths, knownPaths), readFileSyncMethod, normalizedOptions.suppressErrors);
1036
+ }
1037
+ return { files: [...knownFiles, ...childFiles], normalizedOptions, gitRoot };
850
1038
  };
851
1039
  var isIgnoredByIgnoreFiles = async (patterns, options) => {
852
1040
  const { files, normalizedOptions, gitRoot } = await collectIgnoreFileArtifactsAsync(patterns, options, false);
@@ -876,7 +1064,7 @@ var getIgnorePatternsAndPredicateSync = (patterns, options, includeParentIgnoreF
876
1064
  var isGitIgnored = (options) => isIgnoredByIgnoreFiles(GITIGNORE_FILES_PATTERN, options);
877
1065
  var isGitIgnoredSync = (options) => isIgnoredByIgnoreFilesSync(GITIGNORE_FILES_PATTERN, options);
878
1066
 
879
- // packages/@cjser/globby.tmp-26-1778145952693/index.js
1067
+ // packages/@cjser/globby.tmp-26-1784218928215/index.js
880
1068
  var assertPatternsInput = (patterns) => {
881
1069
  if (patterns.some((pattern) => typeof pattern !== "string")) {
882
1070
  throw new TypeError("Patterns must be a string or an array of strings");
@@ -1026,14 +1214,12 @@ var combinePredicate = (matcher, globalMatcher) => {
1026
1214
  return isPathIgnored(matcher, globalMatcher, file);
1027
1215
  };
1028
1216
  };
1029
- var buildIgnoreFilterResult = (options, cwd, { patterns, matcher, usingGitRoot }, globalMatcher, createFilter) => {
1217
+ var buildIgnoreFilterResult = ({ options, cwd, ignoreResult: { rules, matcher }, globalMatcher, createFilter }) => {
1030
1218
  const finalPredicate = combinePredicate(matcher, globalMatcher);
1031
- const patternsForFastGlob = convertPatternsForFastGlob(patterns, usingGitRoot, normalizeDirectoryPatternForFastGlob);
1219
+ const pruneIgnorePatterns = convertPatternsForFastGlob(rules, matcher, cwd);
1032
1220
  return {
1033
- options: {
1034
- ...options,
1035
- ignore: [...options.ignore, ...patternsForFastGlob]
1036
- },
1221
+ options,
1222
+ pruneIgnorePatterns,
1037
1223
  filter: createFilter(finalPredicate, cwd, options.fs)
1038
1224
  };
1039
1225
  };
@@ -1044,14 +1230,21 @@ var applyIgnoreFilesAndGetFilter = async (options) => {
1044
1230
  if (ignoreFilesPatterns.length === 0 && !globalIgnoreFile) {
1045
1231
  return {
1046
1232
  options,
1233
+ pruneIgnorePatterns: [],
1047
1234
  filter: createFilterFunctionAsync(false, cwd, options.fs)
1048
1235
  };
1049
1236
  }
1050
1237
  const includeParentIgnoreFiles = options.gitignore === true;
1051
- const ignoreResult = ignoreFilesPatterns.length > 0 ? await getIgnorePatternsAndPredicate(ignoreFilesPatterns, options, includeParentIgnoreFiles) : { patterns: [], matcher: false, usingGitRoot: false };
1238
+ const ignoreResult = ignoreFilesPatterns.length > 0 ? await getIgnorePatternsAndPredicate(ignoreFilesPatterns, options, includeParentIgnoreFiles) : { rules: [], matcher: false };
1052
1239
  const globalGitRoot = globalIgnoreFile ? await findGitRoot(cwd, options.fs) : void 0;
1053
1240
  const globalMatcher = globalIgnoreFile ? buildGlobalMatcher(globalIgnoreFile, cwd, globalGitRoot ?? cwd) : void 0;
1054
- return buildIgnoreFilterResult(options, cwd, ignoreResult, globalMatcher, createFilterFunctionAsync);
1241
+ return buildIgnoreFilterResult({
1242
+ options,
1243
+ cwd,
1244
+ ignoreResult,
1245
+ globalMatcher,
1246
+ createFilter: createFilterFunctionAsync
1247
+ });
1055
1248
  };
1056
1249
  var applyIgnoreFilesAndGetFilterSync = (options) => {
1057
1250
  const cwd = options.cwd ?? import_node_process2.default.cwd();
@@ -1060,14 +1253,21 @@ var applyIgnoreFilesAndGetFilterSync = (options) => {
1060
1253
  if (ignoreFilesPatterns.length === 0 && !globalIgnoreFile) {
1061
1254
  return {
1062
1255
  options,
1256
+ pruneIgnorePatterns: [],
1063
1257
  filter: createFilterFunction(false, cwd, options.fs)
1064
1258
  };
1065
1259
  }
1066
1260
  const includeParentIgnoreFiles = options.gitignore === true;
1067
- const ignoreResult = ignoreFilesPatterns.length > 0 ? getIgnorePatternsAndPredicateSync(ignoreFilesPatterns, options, includeParentIgnoreFiles) : { patterns: [], matcher: false, usingGitRoot: false };
1261
+ const ignoreResult = ignoreFilesPatterns.length > 0 ? getIgnorePatternsAndPredicateSync(ignoreFilesPatterns, options, includeParentIgnoreFiles) : { rules: [], matcher: false };
1068
1262
  const globalGitRoot = globalIgnoreFile ? findGitRootSync(cwd, options.fs) : void 0;
1069
1263
  const globalMatcher = globalIgnoreFile ? buildGlobalMatcher(globalIgnoreFile, cwd, globalGitRoot ?? cwd) : void 0;
1070
- return buildIgnoreFilterResult(options, cwd, ignoreResult, globalMatcher, createFilterFunction);
1264
+ return buildIgnoreFilterResult({
1265
+ options,
1266
+ cwd,
1267
+ ignoreResult,
1268
+ globalMatcher,
1269
+ createFilter: createFilterFunction
1270
+ });
1071
1271
  };
1072
1272
  var assertGlobalGitignoreSyncSupport = (options) => {
1073
1273
  if (options.globalGitignore && options.fs && !options.fs.statSync) {
@@ -1250,21 +1450,28 @@ var applyParentDirectoryIgnoreAdjustments = (tasks) => tasks.map((task) => ({
1250
1450
  ignore: adjustIgnorePatternsForParentDirectories(task.patterns, task.options.ignore)
1251
1451
  }
1252
1452
  }));
1453
+ var appendPruneIgnorePatterns = (tasks, pruneIgnorePatterns) => pruneIgnorePatterns.length === 0 ? tasks : tasks.map((task) => ({
1454
+ patterns: task.patterns,
1455
+ options: {
1456
+ ...task.options,
1457
+ ignore: [...task.options.ignore, ...pruneIgnorePatterns]
1458
+ }
1459
+ }));
1253
1460
  var normalizeExpandDirectoriesOption = (options, cwd) => ({
1254
1461
  ...cwd ? { cwd } : {},
1255
1462
  ...Array.isArray(options) ? { files: options } : options
1256
1463
  });
1257
- var generateTasks = async (patterns, options) => {
1464
+ var generateTasks = async (patterns, options, pruneIgnorePatterns = []) => {
1258
1465
  const globTasks = convertNegativePatterns(patterns, options);
1259
1466
  const { cwd, expandDirectories, fs: fsImplementation } = options;
1260
1467
  if (!expandDirectories) {
1261
- return applyParentDirectoryIgnoreAdjustments(globTasks);
1468
+ return appendPruneIgnorePatterns(applyParentDirectoryIgnoreAdjustments(globTasks), pruneIgnorePatterns);
1262
1469
  }
1263
1470
  const directoryToGlobOptions = {
1264
1471
  ...normalizeExpandDirectoriesOption(expandDirectories, cwd),
1265
1472
  fs: fsImplementation
1266
1473
  };
1267
- return Promise.all(globTasks.map(async (task) => {
1474
+ const tasks = await Promise.all(globTasks.map(async (task) => {
1268
1475
  let { patterns: patterns2, options: options2 } = task;
1269
1476
  [
1270
1477
  patterns2,
@@ -1276,36 +1483,38 @@ var generateTasks = async (patterns, options) => {
1276
1483
  options2.ignore = adjustIgnorePatternsForParentDirectories(patterns2, options2.ignore);
1277
1484
  return { patterns: patterns2, options: options2 };
1278
1485
  }));
1486
+ return appendPruneIgnorePatterns(tasks, pruneIgnorePatterns);
1279
1487
  };
1280
- var generateTasksSync = (patterns, options) => {
1488
+ var generateTasksSync = (patterns, options, pruneIgnorePatterns = []) => {
1281
1489
  const globTasks = convertNegativePatterns(patterns, options);
1282
1490
  const { cwd, expandDirectories, fs: fsImplementation } = options;
1283
1491
  if (!expandDirectories) {
1284
- return applyParentDirectoryIgnoreAdjustments(globTasks);
1492
+ return appendPruneIgnorePatterns(applyParentDirectoryIgnoreAdjustments(globTasks), pruneIgnorePatterns);
1285
1493
  }
1286
1494
  const directoryToGlobSyncOptions = {
1287
1495
  ...normalizeExpandDirectoriesOption(expandDirectories, cwd),
1288
1496
  fs: fsImplementation
1289
1497
  };
1290
- return globTasks.map((task) => {
1498
+ const tasks = globTasks.map((task) => {
1291
1499
  let { patterns: patterns2, options: options2 } = task;
1292
1500
  patterns2 = directoryToGlobSync(patterns2, directoryToGlobSyncOptions);
1293
1501
  options2.ignore = directoryToGlobSync(options2.ignore, { cwd, fs: fsImplementation });
1294
1502
  options2.ignore = adjustIgnorePatternsForParentDirectories(patterns2, options2.ignore);
1295
1503
  return { patterns: patterns2, options: options2 };
1296
1504
  });
1505
+ return appendPruneIgnorePatterns(tasks, pruneIgnorePatterns);
1297
1506
  };
1298
1507
  var globby = normalizeArguments(async (patterns, options) => {
1299
1508
  assertGlobalGitignoreAsyncSupport(options);
1300
- const { options: modifiedOptions, filter } = await applyIgnoreFilesAndGetFilter(options);
1301
- const tasks = await generateTasks(patterns, modifiedOptions);
1509
+ const { options: modifiedOptions, pruneIgnorePatterns, filter } = await applyIgnoreFilesAndGetFilter(options);
1510
+ const tasks = await generateTasks(patterns, modifiedOptions, pruneIgnorePatterns);
1302
1511
  const results = await Promise.all(tasks.map((task) => (0, import_fast_glob3.default)(task.patterns, task.options)));
1303
1512
  return unionFastGlobResultsAsync(results, filter);
1304
1513
  });
1305
1514
  var globbySync = normalizeArgumentsSync((patterns, options) => {
1306
1515
  assertGlobalGitignoreSyncSupport(options);
1307
- const { options: modifiedOptions, filter } = applyIgnoreFilesAndGetFilterSync(options);
1308
- const tasks = generateTasksSync(patterns, modifiedOptions);
1516
+ const { options: modifiedOptions, pruneIgnorePatterns, filter } = applyIgnoreFilesAndGetFilterSync(options);
1517
+ const tasks = generateTasksSync(patterns, modifiedOptions, pruneIgnorePatterns);
1309
1518
  const results = tasks.map((task) => import_fast_glob3.default.sync(task.patterns, task.options));
1310
1519
  return unionFastGlobResults(results, filter);
1311
1520
  });
@@ -1313,8 +1522,8 @@ var globbyStream = normalizeArgumentsSync((patterns, options) => {
1313
1522
  assertGlobalGitignoreAsyncSupport(options);
1314
1523
  const seen = /* @__PURE__ */ new Set();
1315
1524
  const stream = import_node_stream.Readable.from((async function* () {
1316
- const { options: modifiedOptions, filter } = await applyIgnoreFilesAndGetFilter(options);
1317
- const tasks = await generateTasks(patterns, modifiedOptions);
1525
+ const { options: modifiedOptions, pruneIgnorePatterns, filter } = await applyIgnoreFilesAndGetFilter(options);
1526
+ const tasks = await generateTasks(patterns, modifiedOptions, pruneIgnorePatterns);
1318
1527
  if (tasks.length === 0) {
1319
1528
  return;
1320
1529
  }
package/ignore.js CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  findGitRoot,
16
16
  findGitRootSync,
17
17
  getParentGitignorePaths,
18
+ buildPrunePatternsAndGuards,
19
+ negationsCouldRescue,
18
20
  } from './utilities.js';
19
21
 
20
22
  const defaultIgnoredDirectories = [
@@ -117,14 +119,43 @@ const globIgnoreFiles = (globFunction, patterns, normalizedOptions) => globFunct
117
119
  ...ignoreFilesGlobOptions, // Must be last to ensure absolute/dot flags stick
118
120
  });
119
121
 
120
- const getParentIgnorePaths = (gitRoot, normalizedOptions) => gitRoot
121
- ? getParentGitignorePaths(gitRoot, normalizedOptions.cwd)
122
- : [];
122
+ // Normalize a raw ignore-file line the way git does: strip a byte order mark and trailing whitespace. "Trailing spaces are ignored unless they are quoted with backslash" - a backslash-escaped trailing space is kept, still escaped, so the line can be re-fed to the `ignore` package without being stripped again.
123
+ const normalizeIgnoreFileLine = line => {
124
+ line = line.replace(/^\uFEFF/u, '');
123
125
 
124
- const combineIgnoreFilePaths = (gitRoot, normalizedOptions, childPaths) => dedupePaths([
125
- ...getParentIgnorePaths(gitRoot, normalizedOptions),
126
- ...childPaths,
127
- ]);
126
+ let whitespaceStart = line.length;
127
+ while (whitespaceStart > 0 && /\s/u.test(line[whitespaceStart - 1])) {
128
+ whitespaceStart--;
129
+ }
130
+
131
+ if (whitespaceStart === line.length) {
132
+ return line;
133
+ }
134
+
135
+ let backslashCount = 0;
136
+ for (let index = whitespaceStart - 1; index >= 0 && line[index] === '\\'; index--) {
137
+ backslashCount++;
138
+ }
139
+
140
+ return backslashCount % 2 === 1
141
+ ? line.slice(0, whitespaceStart) + ' '
142
+ : line.slice(0, whitespaceStart);
143
+ };
144
+
145
+ const readIgnoreFileLines = content => content
146
+ .split(/\r?\n/)
147
+ .map(line => normalizeIgnoreFileLine(line))
148
+ .filter(line => line && !line.startsWith('#'));
149
+
150
+ /**
151
+ Get the lines of every ignore file, as git reads them, each paired with the directory of the file that declared them.
152
+
153
+ Anchoring is only meaningful relative to that directory, and the rebased patterns have already lost it, so keep the originals for anything that has to reason about which paths a rule covers.
154
+ */
155
+ const getIgnoreRules = files => files.flatMap(file => {
156
+ const directory = path.dirname(file.filePath);
157
+ return readIgnoreFileLines(file.content).map(pattern => ({pattern, directory}));
158
+ });
128
159
 
129
160
  const buildIgnoreResult = (files, normalizedOptions, gitRoot) => {
130
161
  const baseDir = gitRoot || normalizedOptions.cwd;
@@ -133,6 +164,7 @@ const buildIgnoreResult = (files, normalizedOptions, gitRoot) => {
133
164
 
134
165
  return {
135
166
  patterns,
167
+ rules: getIgnoreRules(files),
136
168
  matcher,
137
169
  predicate: fileOrDirectory => matcher(fileOrDirectory).ignored,
138
170
  usingGitRoot: Boolean(gitRoot && gitRoot !== normalizedOptions.cwd),
@@ -177,11 +209,7 @@ const applyBaseToPattern = (pattern, base) => {
177
209
 
178
210
  const parseIgnoreFile = (file, cwd) => {
179
211
  const base = slash(path.relative(cwd, path.dirname(file.filePath)));
180
-
181
- return file.content
182
- .split(/\r?\n/)
183
- .filter(line => line && !line.startsWith('#'))
184
- .map(pattern => applyBaseToPattern(pattern, base));
212
+ return readIgnoreFileLines(file.content).map(pattern => applyBaseToPattern(pattern, base));
185
213
  };
186
214
 
187
215
  const toRelativePath = (fileOrDirectory, cwd) => {
@@ -515,6 +543,7 @@ const createExcludesFileValue = (value, declaringFilePath) => ({
515
543
 
516
544
  /**
517
545
  Parse git config content and return the excludesFile value and any include paths to recurse into.
546
+
518
547
  The caller is responsible for reading files and recursing (sync or async).
519
548
  */
520
549
  const parseGitConfigForExcludesFile = (content, normalizedPath, gitDirectory) => {
@@ -789,30 +818,149 @@ export const buildGlobalPredicate = (globalIgnoreFile, cwd, rootDirectory = cwd)
789
818
  return fileOrDirectory => matcher(fileOrDirectory).ignored;
790
819
  };
791
820
 
821
+ /**
822
+ Ignore files at or above the cwd can be located without traversing anything.
823
+
824
+ Reading them before searching for the nested ones lets the search itself skip whole ignored directories. Otherwise the recursive search for ignore files walks even the directories that the same `.gitignore` excludes, so a large ignored directory (a mounted share, a build output) is enumerated on every call.
825
+ */
826
+ const getKnownIgnoreFilePaths = (patterns, normalizedOptions, gitRoot) => {
827
+ const searchPatterns = [patterns].flat();
828
+ const isGitignoreSearch = searchPatterns.includes(GITIGNORE_FILES_PATTERN);
829
+ if (!isGitignoreSearch) {
830
+ return [];
831
+ }
832
+
833
+ return gitRoot
834
+ ? getParentGitignorePaths(gitRoot, normalizedOptions.cwd)
835
+ : [path.join(normalizedOptions.cwd, '.gitignore')];
836
+ };
837
+
838
+ const getKnownIgnoreFileSearchOptions = (patterns, normalizedOptions) => ({
839
+ ...normalizedOptions,
840
+ ignore: [
841
+ ...normalizedOptions.ignore,
842
+ // Keep negative search patterns active when the known candidates are matched independently.
843
+ ...[patterns].flat()
844
+ .filter(pattern => isNegativePattern(pattern))
845
+ .map(pattern => pattern.slice(1)),
846
+ ],
847
+ });
848
+
849
+ const getKnownIgnoreFilePattern = (filePath, cwd) => {
850
+ // Relative candidates keep cwd-relative exclusions such as `.gitignore` meaningful; parent candidates must remain absolute because they are outside cwd.
851
+ const pattern = isPathInside(filePath, cwd) ? path.relative(cwd, filePath) : filePath;
852
+ return fastGlob.convertPathToPattern(pattern);
853
+ };
854
+
855
+ const getMatchingKnownIgnoreFilePaths = (knownPaths, matchingPaths) => {
856
+ // Fast-glob normalizes its results, so compare resolved paths before returning the original paths for reading.
857
+ const matchingPathSet = new Set(matchingPaths.map(filePath => path.resolve(filePath)));
858
+
859
+ return knownPaths.filter(filePath => matchingPathSet.has(path.resolve(filePath)));
860
+ };
861
+
862
+ const globKnownIgnoreFilePaths = (globFunction, knownPaths, patterns, normalizedOptions) => {
863
+ if (knownPaths.length === 0) {
864
+ return [];
865
+ }
866
+
867
+ return globIgnoreFiles(
868
+ globFunction,
869
+ knownPaths.map(filePath => getKnownIgnoreFilePattern(filePath, normalizedOptions.cwd)),
870
+ getKnownIgnoreFileSearchOptions(patterns, normalizedOptions),
871
+ );
872
+ };
873
+
874
+ const filterKnownIgnoreFilePathsAsync = async (knownPaths, patterns, normalizedOptions) => {
875
+ const matchingPaths = await globKnownIgnoreFilePaths(fastGlob, knownPaths, patterns, normalizedOptions);
876
+ return getMatchingKnownIgnoreFilePaths(knownPaths, matchingPaths);
877
+ };
878
+
879
+ const filterKnownIgnoreFilePathsSync = (knownPaths, patterns, normalizedOptions) => {
880
+ const matchingPaths = globKnownIgnoreFilePaths(fastGlob.sync, knownPaths, patterns, normalizedOptions);
881
+ return getMatchingKnownIgnoreFilePaths(knownPaths, matchingPaths);
882
+ };
883
+
884
+ const getIgnoreFileSearchPrune = (searchPatterns, files, normalizedOptions, gitRoot) => {
885
+ if (files.length === 0) {
886
+ return {patterns: [], guardNames: []};
887
+ }
888
+
889
+ const {cwd} = normalizedOptions;
890
+ const baseDir = gitRoot || cwd;
891
+ const ignorePatterns = getPatternsFromIgnoreFiles(files, baseDir);
892
+ const matcher = createIgnoreMatcher(ignorePatterns, cwd, baseDir);
893
+
894
+ // Custom ignore files can live anywhere, including beside the cwd, so only a pure `.gitignore` search may treat the rules at or above the cwd as complete.
895
+ const searchPatternsArray = [searchPatterns].flat();
896
+ const gitignoreOnlySearch = searchPatternsArray.every(pattern => pattern === GITIGNORE_FILES_PATTERN);
897
+ const searchesForGitignoreFiles = searchPatternsArray.includes(GITIGNORE_FILES_PATTERN);
898
+
899
+ return buildPrunePatternsAndGuards(getIgnoreRules(files), matcher, cwd, {gitignoreOnlySearch, searchesForGitignoreFiles});
900
+ };
901
+
902
+ const withPrunedSearch = (normalizedOptions, prunePatterns) => prunePatterns.length === 0
903
+ ? normalizedOptions
904
+ : {...normalizedOptions, ignore: [...normalizedOptions.ignore, ...prunePatterns]};
905
+
906
+ // The known ignore files have already been read; only the newly discovered ones are left.
907
+ const getUnreadPaths = (childPaths, knownPaths) => {
908
+ const alreadyRead = new Set(knownPaths.map(filePath => path.resolve(filePath)));
909
+ return dedupePaths(childPaths).filter(filePath => !alreadyRead.has(path.resolve(filePath)));
910
+ };
911
+
792
912
  const collectIgnoreFileArtifactsAsync = async (patterns, options, includeParentIgnoreFiles) => {
793
913
  const normalizedOptions = normalizeOptions(options);
794
- const childPaths = await globIgnoreFiles(fastGlob, patterns, normalizedOptions);
914
+ const readFileMethod = getReadFileMethod(normalizedOptions.fs);
795
915
  const gitRoot = includeParentIgnoreFiles
796
916
  ? await findGitRoot(normalizedOptions.cwd, normalizedOptions.fs)
797
917
  : undefined;
798
- const allPaths = combineIgnoreFilePaths(gitRoot, normalizedOptions, childPaths);
799
- const readFileMethod = getReadFileMethod(normalizedOptions.fs);
800
- const files = await readIgnoreFilesSafely(allPaths, readFileMethod, normalizedOptions.suppressErrors);
801
918
 
802
- return {files, normalizedOptions, gitRoot};
919
+ const knownPaths = await filterKnownIgnoreFilePathsAsync(
920
+ getKnownIgnoreFilePaths(patterns, normalizedOptions, gitRoot),
921
+ patterns,
922
+ normalizedOptions,
923
+ );
924
+ const knownFiles = await readIgnoreFilesSafely(knownPaths, readFileMethod, normalizedOptions.suppressErrors);
925
+ const {patterns: prunePatterns, guardNames} = getIgnoreFileSearchPrune(patterns, knownFiles, normalizedOptions, gitRoot);
926
+
927
+ const childPaths = await globIgnoreFiles(fastGlob, patterns, withPrunedSearch(normalizedOptions, prunePatterns));
928
+ let childFiles = await readIgnoreFilesSafely(getUnreadPaths(childPaths, knownPaths), readFileMethod, normalizedOptions.suppressErrors);
929
+
930
+ // A negation found by the pruned search can re-include a directory the prune patterns skipped, hiding the ignore files inside it. Repeat the search without pruning when that happens; a single unpruned pass finds everything, so once is enough.
931
+ if (negationsCouldRescue(getIgnoreRules(childFiles), guardNames)) {
932
+ const allPaths = await globIgnoreFiles(fastGlob, patterns, normalizedOptions);
933
+ childFiles = await readIgnoreFilesSafely(getUnreadPaths(allPaths, knownPaths), readFileMethod, normalizedOptions.suppressErrors);
934
+ }
935
+
936
+ return {files: [...knownFiles, ...childFiles], normalizedOptions, gitRoot};
803
937
  };
804
938
 
805
939
  const collectIgnoreFileArtifactsSync = (patterns, options, includeParentIgnoreFiles) => {
806
940
  const normalizedOptions = normalizeOptions(options);
807
- const childPaths = globIgnoreFiles(fastGlob.sync, patterns, normalizedOptions);
941
+ const readFileSyncMethod = getReadFileSyncMethod(normalizedOptions.fs);
808
942
  const gitRoot = includeParentIgnoreFiles
809
943
  ? findGitRootSync(normalizedOptions.cwd, normalizedOptions.fs)
810
944
  : undefined;
811
- const allPaths = combineIgnoreFilePaths(gitRoot, normalizedOptions, childPaths);
812
- const readFileSyncMethod = getReadFileSyncMethod(normalizedOptions.fs);
813
- const files = readIgnoreFilesSafelySync(allPaths, readFileSyncMethod, normalizedOptions.suppressErrors);
814
945
 
815
- return {files, normalizedOptions, gitRoot};
946
+ const knownPaths = filterKnownIgnoreFilePathsSync(
947
+ getKnownIgnoreFilePaths(patterns, normalizedOptions, gitRoot),
948
+ patterns,
949
+ normalizedOptions,
950
+ );
951
+ const knownFiles = readIgnoreFilesSafelySync(knownPaths, readFileSyncMethod, normalizedOptions.suppressErrors);
952
+ const {patterns: prunePatterns, guardNames} = getIgnoreFileSearchPrune(patterns, knownFiles, normalizedOptions, gitRoot);
953
+
954
+ const childPaths = globIgnoreFiles(fastGlob.sync, patterns, withPrunedSearch(normalizedOptions, prunePatterns));
955
+ let childFiles = readIgnoreFilesSafelySync(getUnreadPaths(childPaths, knownPaths), readFileSyncMethod, normalizedOptions.suppressErrors);
956
+
957
+ // See `collectIgnoreFileArtifactsAsync`: a rescuing negation means the pruned search may have missed files.
958
+ if (negationsCouldRescue(getIgnoreRules(childFiles), guardNames)) {
959
+ const allPaths = globIgnoreFiles(fastGlob.sync, patterns, normalizedOptions);
960
+ childFiles = readIgnoreFilesSafelySync(getUnreadPaths(allPaths, knownPaths), readFileSyncMethod, normalizedOptions.suppressErrors);
961
+ }
962
+
963
+ return {files: [...knownFiles, ...childFiles], normalizedOptions, gitRoot};
816
964
  };
817
965
 
818
966
  export const isIgnoredByIgnoreFiles = async (patterns, options) => {
@@ -834,7 +982,7 @@ This avoids reading the same files twice (once for patterns, once for filtering)
834
982
  @param {string[]} patterns - Patterns to find ignore files
835
983
  @param {Object} options - Options object
836
984
  @param {boolean} [includeParentIgnoreFiles=false] - Whether to search for parent .gitignore files
837
- @returns {Promise<{patterns: string[], matcher: Function, predicate: Function, usingGitRoot: boolean}>}
985
+ @returns {Promise<{patterns: string[], rules: Array<{pattern: string, directory: string}>, matcher: Function, predicate: Function, usingGitRoot: boolean}>}
838
986
  */
839
987
  export const getIgnorePatternsAndPredicate = async (patterns, options, includeParentIgnoreFiles = false) => {
840
988
  const {files, normalizedOptions, gitRoot} = await collectIgnoreFileArtifactsAsync(
@@ -852,7 +1000,7 @@ Read ignore files and return both patterns and predicate (sync version).
852
1000
  @param {string[]} patterns - Patterns to find ignore files
853
1001
  @param {Object} options - Options object
854
1002
  @param {boolean} [includeParentIgnoreFiles=false] - Whether to search for parent .gitignore files
855
- @returns {{patterns: string[], matcher: Function, predicate: Function, usingGitRoot: boolean}}
1003
+ @returns {{patterns: string[], rules: Array<{pattern: string, directory: string}>, matcher: Function, predicate: Function, usingGitRoot: boolean}}
856
1004
  */
857
1005
  export const getIgnorePatternsAndPredicateSync = (patterns, options, includeParentIgnoreFiles = false) => {
858
1006
  const {files, normalizedOptions, gitRoot} = collectIgnoreFileArtifactsSync(
package/index.js CHANGED
@@ -19,7 +19,6 @@ import {
19
19
  isNegativePattern,
20
20
  getStaticAbsolutePathPrefix,
21
21
  normalizeNegativePattern,
22
- normalizeDirectoryPatternForFastGlob,
23
22
  adjustIgnorePatternsForParentDirectories,
24
23
  convertPatternsForFastGlob,
25
24
  findGitRoot,
@@ -233,31 +232,28 @@ const combinePredicate = (matcher, globalMatcher) => {
233
232
  };
234
233
  };
235
234
 
236
- const buildIgnoreFilterResult = (options, cwd, {patterns, matcher, usingGitRoot}, globalMatcher, createFilter) => {
235
+ const buildIgnoreFilterResult = ({options, cwd, ignoreResult: {rules, matcher}, globalMatcher, createFilter}) => {
237
236
  const finalPredicate = combinePredicate(matcher, globalMatcher);
238
237
 
239
- // Convert patterns to fast-glob format (may return empty array if predicate should handle everything)
240
- const patternsForFastGlob = convertPatternsForFastGlob(patterns, usingGitRoot, normalizeDirectoryPatternForFastGlob);
238
+ // Patterns fast-glob can use to skip ignored directories while traversing. The predicate below stays authoritative, so this only ever needs to be safe, never exhaustive. They are returned separately from `options.ignore` so they skip directory expansion and the parent-directory adjustment, which would rebase them outside the scope their ignore files govern.
239
+ const pruneIgnorePatterns = convertPatternsForFastGlob(rules, matcher, cwd);
241
240
 
242
241
  return {
243
- options: {
244
- ...options,
245
- ignore: [...options.ignore, ...patternsForFastGlob],
246
- },
242
+ options,
243
+ pruneIgnorePatterns,
247
244
  filter: createFilter(finalPredicate, cwd, options.fs),
248
245
  };
249
246
  };
250
247
 
251
248
  /**
252
- Apply gitignore patterns to options and return filter predicate.
253
-
254
- 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.
249
+ Apply ignore files to options and return the filter predicate.
255
250
 
256
- When there are no negations, we optimize by passing patterns to fast-glob's ignore option to skip directories during traversal (performance optimization).
251
+ The predicate handles every rule, including negations, and is the authoritative filter applied
252
+ to fast-glob's results. Rules that provably cannot be affected by negations are additionally
253
+ translated into fast-glob `ignore` patterns, so whole ignored directories are skipped during
254
+ traversal; see `convertPatternsForFastGlob`.
257
255
 
258
- All patterns (including negated) are always used in the filter predicate to ensure correct Git-compatible behavior.
259
-
260
- @returns {Promise<{options: Object, filter: Function}>}
256
+ @returns {Promise<{options: Object, pruneIgnorePatterns: string[], filter: Function}>}
261
257
  */
262
258
  const applyIgnoreFilesAndGetFilter = async options => {
263
259
  const cwd = options.cwd ?? process.cwd();
@@ -267,6 +263,7 @@ const applyIgnoreFilesAndGetFilter = async options => {
267
263
  if (ignoreFilesPatterns.length === 0 && !globalIgnoreFile) {
268
264
  return {
269
265
  options,
266
+ pruneIgnorePatterns: [],
270
267
  filter: createFilterFunctionAsync(false, cwd, options.fs),
271
268
  };
272
269
  }
@@ -276,18 +273,24 @@ const applyIgnoreFilesAndGetFilter = async options => {
276
273
  const includeParentIgnoreFiles = options.gitignore === true;
277
274
  const ignoreResult = ignoreFilesPatterns.length > 0
278
275
  ? await getIgnorePatternsAndPredicate(ignoreFilesPatterns, options, includeParentIgnoreFiles)
279
- : {patterns: [], matcher: false, usingGitRoot: false};
276
+ : {rules: [], matcher: false};
280
277
 
281
278
  const globalGitRoot = globalIgnoreFile ? await findGitRoot(cwd, options.fs) : undefined;
282
279
  const globalMatcher = globalIgnoreFile ? buildGlobalMatcher(globalIgnoreFile, cwd, globalGitRoot ?? cwd) : undefined;
283
280
 
284
- return buildIgnoreFilterResult(options, cwd, ignoreResult, globalMatcher, createFilterFunctionAsync);
281
+ return buildIgnoreFilterResult({
282
+ options,
283
+ cwd,
284
+ ignoreResult,
285
+ globalMatcher,
286
+ createFilter: createFilterFunctionAsync,
287
+ });
285
288
  };
286
289
 
287
290
  /**
288
- Apply gitignore patterns to options and return filter predicate (sync version).
291
+ Apply ignore files to options and return the filter predicate (sync version).
289
292
 
290
- @returns {{options: Object, filter: Function}}
293
+ @returns {{options: Object, pruneIgnorePatterns: string[], filter: Function}}
291
294
  */
292
295
  const applyIgnoreFilesAndGetFilterSync = options => {
293
296
  const cwd = options.cwd ?? process.cwd();
@@ -297,6 +300,7 @@ const applyIgnoreFilesAndGetFilterSync = options => {
297
300
  if (ignoreFilesPatterns.length === 0 && !globalIgnoreFile) {
298
301
  return {
299
302
  options,
303
+ pruneIgnorePatterns: [],
300
304
  filter: createFilterFunction(false, cwd, options.fs),
301
305
  };
302
306
  }
@@ -306,12 +310,18 @@ const applyIgnoreFilesAndGetFilterSync = options => {
306
310
  const includeParentIgnoreFiles = options.gitignore === true;
307
311
  const ignoreResult = ignoreFilesPatterns.length > 0
308
312
  ? getIgnorePatternsAndPredicateSync(ignoreFilesPatterns, options, includeParentIgnoreFiles)
309
- : {patterns: [], matcher: false, usingGitRoot: false};
313
+ : {rules: [], matcher: false};
310
314
 
311
315
  const globalGitRoot = globalIgnoreFile ? findGitRootSync(cwd, options.fs) : undefined;
312
316
  const globalMatcher = globalIgnoreFile ? buildGlobalMatcher(globalIgnoreFile, cwd, globalGitRoot ?? cwd) : undefined;
313
317
 
314
- return buildIgnoreFilterResult(options, cwd, ignoreResult, globalMatcher, createFilterFunction);
318
+ return buildIgnoreFilterResult({
319
+ options,
320
+ cwd,
321
+ ignoreResult,
322
+ globalMatcher,
323
+ createFilter: createFilterFunction,
324
+ });
315
325
  };
316
326
 
317
327
  const assertGlobalGitignoreSyncSupport = options => {
@@ -545,18 +555,31 @@ const applyParentDirectoryIgnoreAdjustments = tasks => tasks.map(task => ({
545
555
  },
546
556
  }));
547
557
 
558
+ // Prune patterns are appended after directory expansion and the parent-directory adjustment on
559
+ // purpose: they are already glob-shaped, and rebasing them onto a `../` prefix would let them
560
+ // ignore paths outside the scope of the ignore files they came from.
561
+ const appendPruneIgnorePatterns = (tasks, pruneIgnorePatterns) => pruneIgnorePatterns.length === 0
562
+ ? tasks
563
+ : tasks.map(task => ({
564
+ patterns: task.patterns,
565
+ options: {
566
+ ...task.options,
567
+ ignore: [...task.options.ignore, ...pruneIgnorePatterns],
568
+ },
569
+ }));
570
+
548
571
  const normalizeExpandDirectoriesOption = (options, cwd) => ({
549
572
  ...(cwd ? {cwd} : {}),
550
573
  ...(Array.isArray(options) ? {files: options} : options),
551
574
  });
552
575
 
553
- const generateTasks = async (patterns, options) => {
576
+ const generateTasks = async (patterns, options, pruneIgnorePatterns = []) => {
554
577
  const globTasks = convertNegativePatterns(patterns, options);
555
578
 
556
579
  const {cwd, expandDirectories, fs: fsImplementation} = options;
557
580
 
558
581
  if (!expandDirectories) {
559
- return applyParentDirectoryIgnoreAdjustments(globTasks);
582
+ return appendPruneIgnorePatterns(applyParentDirectoryIgnoreAdjustments(globTasks), pruneIgnorePatterns);
560
583
  }
561
584
 
562
585
  const directoryToGlobOptions = {
@@ -564,7 +587,7 @@ const generateTasks = async (patterns, options) => {
564
587
  fs: fsImplementation,
565
588
  };
566
589
 
567
- return Promise.all(globTasks.map(async task => {
590
+ const tasks = await Promise.all(globTasks.map(async task => {
568
591
  let {patterns, options} = task;
569
592
 
570
593
  [
@@ -580,14 +603,16 @@ const generateTasks = async (patterns, options) => {
580
603
 
581
604
  return {patterns, options};
582
605
  }));
606
+
607
+ return appendPruneIgnorePatterns(tasks, pruneIgnorePatterns);
583
608
  };
584
609
 
585
- const generateTasksSync = (patterns, options) => {
610
+ const generateTasksSync = (patterns, options, pruneIgnorePatterns = []) => {
586
611
  const globTasks = convertNegativePatterns(patterns, options);
587
612
  const {cwd, expandDirectories, fs: fsImplementation} = options;
588
613
 
589
614
  if (!expandDirectories) {
590
- return applyParentDirectoryIgnoreAdjustments(globTasks);
615
+ return appendPruneIgnorePatterns(applyParentDirectoryIgnoreAdjustments(globTasks), pruneIgnorePatterns);
591
616
  }
592
617
 
593
618
  const directoryToGlobSyncOptions = {
@@ -595,7 +620,7 @@ const generateTasksSync = (patterns, options) => {
595
620
  fs: fsImplementation,
596
621
  };
597
622
 
598
- return globTasks.map(task => {
623
+ const tasks = globTasks.map(task => {
599
624
  let {patterns, options} = task;
600
625
  patterns = directoryToGlobSync(patterns, directoryToGlobSyncOptions);
601
626
  options.ignore = directoryToGlobSync(options.ignore, {cwd, fs: fsImplementation});
@@ -605,16 +630,18 @@ const generateTasksSync = (patterns, options) => {
605
630
 
606
631
  return {patterns, options};
607
632
  });
633
+
634
+ return appendPruneIgnorePatterns(tasks, pruneIgnorePatterns);
608
635
  };
609
636
 
610
637
  export const globby = normalizeArguments(async (patterns, options) => {
611
638
  assertGlobalGitignoreAsyncSupport(options);
612
639
 
613
640
  // Apply ignore files and get filter (reads .gitignore files once)
614
- const {options: modifiedOptions, filter} = await applyIgnoreFilesAndGetFilter(options);
641
+ const {options: modifiedOptions, pruneIgnorePatterns, filter} = await applyIgnoreFilesAndGetFilter(options);
615
642
 
616
- // Generate tasks with modified options (includes gitignore patterns in ignore option)
617
- const tasks = await generateTasks(patterns, modifiedOptions);
643
+ // Generate tasks, attaching the prune patterns so fast-glob skips ignored directories
644
+ const tasks = await generateTasks(patterns, modifiedOptions, pruneIgnorePatterns);
618
645
 
619
646
  const results = await Promise.all(tasks.map(task => fastGlob(task.patterns, task.options)));
620
647
  return unionFastGlobResultsAsync(results, filter);
@@ -624,10 +651,10 @@ export const globbySync = normalizeArgumentsSync((patterns, options) => {
624
651
  assertGlobalGitignoreSyncSupport(options);
625
652
 
626
653
  // Apply ignore files and get filter (reads .gitignore files once)
627
- const {options: modifiedOptions, filter} = applyIgnoreFilesAndGetFilterSync(options);
654
+ const {options: modifiedOptions, pruneIgnorePatterns, filter} = applyIgnoreFilesAndGetFilterSync(options);
628
655
 
629
- // Generate tasks with modified options (includes gitignore patterns in ignore option)
630
- const tasks = generateTasksSync(patterns, modifiedOptions);
656
+ // Generate tasks, attaching the prune patterns so fast-glob skips ignored directories
657
+ const tasks = generateTasksSync(patterns, modifiedOptions, pruneIgnorePatterns);
631
658
 
632
659
  const results = tasks.map(task => fastGlob.sync(task.patterns, task.options));
633
660
  return unionFastGlobResults(results, filter);
@@ -639,10 +666,10 @@ export const globbyStream = normalizeArgumentsSync((patterns, options) => {
639
666
  const seen = new Set();
640
667
  const stream = Readable.from((async function * () {
641
668
  // Apply ignore files and get filter (reads .gitignore files once)
642
- const {options: modifiedOptions, filter} = await applyIgnoreFilesAndGetFilter(options);
669
+ const {options: modifiedOptions, pruneIgnorePatterns, filter} = await applyIgnoreFilesAndGetFilter(options);
643
670
 
644
- // Generate tasks with modified options (includes gitignore patterns in ignore option)
645
- const tasks = await generateTasks(patterns, modifiedOptions);
671
+ // Generate tasks, attaching the prune patterns so fast-glob skips ignored directories
672
+ const tasks = await generateTasks(patterns, modifiedOptions, pruneIgnorePatterns);
646
673
 
647
674
  if (tasks.length === 0) {
648
675
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjser/globby",
3
- "version": "16.2.0-cjser.2",
3
+ "version": "16.2.2-cjser.2",
4
4
  "description": "User-friendly glob matching",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -99,11 +99,11 @@
99
99
  "types": "./index.d.ts",
100
100
  "main": "./dist-cjser/index.cjs",
101
101
  "cjser": {
102
- "sourceVersion": "16.2.0",
102
+ "sourceVersion": "16.2.2",
103
103
  "cjserVersion": 2,
104
104
  "original": {
105
105
  "name": "globby",
106
- "version": "16.2.0",
106
+ "version": "16.2.2",
107
107
  "exports": {
108
108
  "types": "./index.d.ts",
109
109
  "default": "./index.js"
package/readme.md CHANGED
@@ -93,7 +93,7 @@ When enabled, globby searches for `.gitignore` files from the current working di
93
93
 
94
94
  Gitignore patterns take priority over user patterns, matching Git's behavior. To include gitignored files, set this to `false`.
95
95
 
96
- **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.
96
+ **Performance:** Globby reads `.gitignore` files before globbing and hands fast-glob patterns for the directories it can prove are ignored, so whole ignored directories (like large `node_modules` or build outputs) are skipped during traversal instead of being enumerated and filtered afterwards. This holds even when negation patterns (like `!important.log`) or parent `.gitignore` files are present: only the rules that provably cannot be re-included by a negation are used to skip directories, while the final filtering always matches Git's behavior. To read fewer ignore files, use `ignoreFiles: '.gitignore'` to target only the root ignore file.
97
97
 
98
98
  ##### globalGitignore
99
99
 
package/utilities.js CHANGED
@@ -2,7 +2,9 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import {promisify} from 'node:util';
4
4
  import fastGlob from 'fast-glob';
5
+ import gitIgnore from 'ignore';
5
6
  import isPathInside from '@cjser/is-path-inside';
7
+ import slash from '@cjser/slash';
6
8
 
7
9
  export const isNegativePattern = pattern => pattern[0] === '!';
8
10
 
@@ -343,40 +345,210 @@ export const getParentGitignorePaths = (gitRoot, cwd) => {
343
345
  .map(directory => path.join(directory, '.gitignore'));
344
346
  };
345
347
 
348
+ // The wildcards gitignore itself understands, when not escaped. Other characters micromatch
349
+ // treats as syntax ((){}, extglobs, alternation) are literal in gitignore.
350
+ const GITIGNORE_WILDCARDS = /(?<!\\)[*?[]/u;
351
+
352
+ const hasGitignoreWildcards = value => GITIGNORE_WILDCARDS.test(value);
353
+
354
+ // Characters micromatch reads as syntax where gitignore does not. A glob rule containing them
355
+ // cannot be translated, and backslash escapes inside a glob cannot be safely carried through
356
+ // the path handling below, so such rules are left to the predicate.
357
+ const MICROMATCH_ONLY_SYNTAX = /[(){}|\\]/u;
358
+
359
+ // In gitignore, `\x` means the literal character x.
360
+ const unescapeGitignorePattern = value => value.replaceAll(/\\(.)/gu, '$1');
361
+
362
+ // Turn gitignore-literal text into fast-glob-literal text, so characters like `+(` cannot be
363
+ // misread as micromatch syntax.
364
+ const toLiteralPattern = value => fastGlob.escapePath(unescapeGitignorePattern(value));
365
+
366
+ const finalSegment = value => value.replace(/\/+$/u, '').split('/').pop();
367
+
368
+ const isInsideCwd = relativePath => relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
369
+
346
370
  /**
347
- Convert ignore patterns to fast-glob compatible format.
348
- Returns empty array if patterns should be handled by predicate only.
371
+ Resolve a pattern anchored at the directory of its ignore file into a cwd-relative one.
372
+
373
+ @param {string} directory - Directory of the ignore file that declared the rule.
374
+ @param {string} body - The rule body, relative to that directory.
375
+ @param {string} cwd - Directory the glob runs from.
376
+ @returns {string|undefined} The cwd-relative pattern, or undefined when it targets something outside the cwd.
377
+ */
378
+ const anchorToCwd = (directory, body, cwd) => {
379
+ const relativePath = slash(path.relative(cwd, path.join(directory, body)));
380
+ return isInsideCwd(relativePath) ? relativePath : undefined;
381
+ };
382
+
383
+ // Compare names with the `ignore` package instead of guessing from the syntax, since it is the
384
+ // same engine the predicate uses for the real decision.
385
+ const createNameComparer = () => {
386
+ const nameMatchers = new Map();
387
+ const matchesName = (pattern, name) => {
388
+ let nameMatcher = nameMatchers.get(pattern);
389
+ if (!nameMatcher) {
390
+ nameMatcher = gitIgnore().add([pattern]);
391
+ nameMatchers.set(pattern, nameMatcher);
392
+ }
393
+
394
+ return nameMatcher.ignores(name);
395
+ };
396
+
397
+ // A negation can only re-include the excluded path itself; nothing below it can be re-included once the directory is excluded. Two globs cannot be compared this way, so treat them as a possible match.
398
+ return (pattern, name) => {
399
+ if (hasGitignoreWildcards(pattern) && hasGitignoreWildcards(name)) {
400
+ return true;
401
+ }
402
+
403
+ return hasGitignoreWildcards(name) ? matchesName(name, pattern) : matchesName(pattern, name);
404
+ };
405
+ };
406
+
407
+ const getNegationFinalSegments = rules => rules
408
+ .filter(rule => isNegativePattern(rule.pattern))
409
+ .map(rule => finalSegment(rule.pattern.slice(1)))
410
+ .filter(Boolean);
411
+
412
+ /**
413
+ Check whether any negation in the given rules could re-include a path with one of the given names.
414
+
415
+ Used after the pruned ignore-file search: a negation found by that search can re-include a directory the prune patterns skipped, which means ignore files inside it were never discovered.
416
+
417
+ @param {Array<{pattern: string, directory: string}>} rules - Raw ignore-file lines and the directory of the ignore file that declared them.
418
+ @param {string[]} names - The guard names returned by `buildPrunePatternsAndGuards`.
419
+ @returns {boolean} Whether a negation could name one of them.
420
+ */
421
+ export const negationsCouldRescue = (rules, names) => {
422
+ if (names.length === 0) {
423
+ return false;
424
+ }
425
+
426
+ const couldNameTheSamePath = createNameComparer();
427
+ return getNegationFinalSegments(rules).some(negation => names.some(name => couldNameTheSamePath(name, negation)));
428
+ };
429
+
430
+ // Compute the prune pattern for a single rule, or undefined when the rule cannot be skipped safely. The returned object also carries the guard name (if any) whose skipping relies on the rule set being complete.
431
+ const getRulePrune = ({pattern, directory}, {cwd, matcher, hasNegations, canSkipAtAnyDepth, canMatchIgnoreFile, gitignoreOnlySearch}) => {
432
+ if (isNegativePattern(pattern)) {
433
+ return undefined;
434
+ }
349
435
 
350
- @param {string[]} patterns - Ignore patterns from .gitignore files
351
- @param {boolean} usingGitRoot - Whether patterns are relative to git root
352
- @param {Function} normalizeDirectoryPatternForFastGlob - Function to normalize directory patterns
353
- @returns {string[]} Patterns safe to pass to fast-glob, or empty array
436
+ const isDirectoryPattern = pattern.endsWith('/');
437
+ const clean = pattern.replace(/\/+$/u, '');
438
+ if (!clean) {
439
+ return undefined;
440
+ }
441
+
442
+ // A leading `**/` is gitignore's explicit spelling of "match at any depth"; for a single trailing segment it is identical to the bare name (`**/foo` == `foo`). Drop it so the rule takes the any-depth branch below instead of being treated as an anchored glob.
443
+ const body = clean.startsWith('**/') && !clean.slice(3).includes('/')
444
+ ? clean.slice(3)
445
+ : clean;
446
+
447
+ if (canMatchIgnoreFile(finalSegment(body))) {
448
+ // Contents-only rules such as `foo/*` still allow traversal to `foo/.gitignore`, so the ignore-file search must read it before pruning foo's contents.
449
+ return undefined;
450
+ }
451
+
452
+ const isGlob = hasGitignoreWildcards(body);
453
+ if (isGlob && MICROMATCH_ONLY_SYNTAX.test(body)) {
454
+ return undefined;
455
+ }
456
+
457
+ // The leading slash stops the normalizer from prefixing `**/`; the passed value already encodes the depth, and an extra `**/` would un-anchor an anchored rule.
458
+ const toFastGlob = value =>
459
+ normalizeDirectoryPatternForFastGlob(`/${value}${isDirectoryPattern ? '/' : ''}`).replace(/^\//u, '');
460
+
461
+ // No separator: matches at any depth below the ignore file that declared it.
462
+ if (!body.includes('/') && canSkipAtAnyDepth(body)) {
463
+ const relativeDirectory = slash(path.relative(cwd, directory));
464
+ const prefix = isInsideCwd(relativeDirectory) ? `${fastGlob.escapePath(relativeDirectory)}/` : '';
465
+ return {pattern: toFastGlob(`${prefix}**/${isGlob ? body : toLiteralPattern(body)}`), guardName: body};
466
+ }
467
+
468
+ // Otherwise fall back to the single occurrence beside the ignore file, which names a concrete path that the matcher can verify directly.
469
+ const anchoredBody = body.replace(/^\//u, '');
470
+ const target = anchorToCwd(directory, isGlob ? anchoredBody : unescapeGitignorePattern(anchoredBody), cwd);
471
+ if (target === undefined) {
472
+ return undefined;
473
+ }
474
+
475
+ if (isGlob) {
476
+ // A glob does not name a concrete path, so the matcher cannot confirm it is ignored.
477
+ return hasNegations
478
+ ? undefined
479
+ : {pattern: toFastGlob(target), guardName: finalSegment(target)};
480
+ }
481
+
482
+ if (!matcher(path.resolve(cwd, target) + path.sep).ignored) {
483
+ return undefined;
484
+ }
485
+
486
+ // A direct child of the cwd can only be re-included by a rule at or above the cwd, and in a pure gitignore search those rules are all known already. A deeper target has intermediate directories whose ignore files may not have been read yet.
487
+ const needsGuard = !gitignoreOnlySearch || target.includes('/');
488
+ return {
489
+ pattern: toFastGlob(fastGlob.escapePath(target)),
490
+ guardName: needsGuard ? finalSegment(target) : undefined,
491
+ };
492
+ };
493
+
494
+ /**
495
+ Build the ignore patterns handed to fast-glob so it can skip ignored directories while traversing.
496
+
497
+ The authoritative filter is always the predicate, so these patterns only ever need to be safe:
498
+ skipping something the predicate would have kept loses files, while skipping less than possible
499
+ merely costs time. Two facts from the gitignore spec make aggressive skipping safe anyway:
500
+
501
+ - "It is not possible to re-include a file if a parent directory of that file is excluded."
502
+ So a directory that is still ignored once every negation has been applied can be skipped whole.
503
+ - A pattern with no separator matches at any depth below its own ignore file, and one with a
504
+ separator is anchored to that file's directory. Working from the raw rules - rather than from
505
+ patterns already rebased onto some other directory - keeps that distinction intact, which is
506
+ what lets this work from a subdirectory of the repository too.
507
+
508
+ The returned guard names are the directory names whose skipping relies on the given rules being
509
+ complete. A caller working from a partial rule set (the ignore-file search) must watch for later
510
+ negations that could name one of them; see `negationsCouldRescue`.
511
+
512
+ @param {Array<{pattern: string, directory: string}>} rules - Raw ignore-file lines and the directory of the ignore file that declared them.
513
+ @param {Function} matcher - The authoritative gitignore matcher.
514
+ @param {string} cwd - Directory the glob runs from.
515
+ @param {Object} [options] - Options.
516
+ @param {boolean} [options.gitignoreOnlySearch] - Whether the rule set can only grow through nested `.gitignore` files.
517
+ @param {boolean} [options.searchesForGitignoreFiles] - Whether the search includes `.gitignore` files.
518
+ @returns {{patterns: string[], guardNames: string[]}} Patterns safe to pass to fast-glob, and the names their safety depends on.
354
519
  */
355
- export const convertPatternsForFastGlob = (patterns, usingGitRoot, normalizeDirectoryPatternForFastGlob) => {
356
- // Determine which patterns are safe to pass to fast-glob
357
- // If there are negation patterns, we can't pass file patterns to fast-glob
358
- // because fast-glob doesn't understand negations and would filter out files
359
- // that should be re-included by negation patterns.
360
- // If we're using git root, patterns are relative to git root not cwd,
361
- // so we can't pass them to fast-glob which expects cwd-relative patterns.
362
- // We only pass patterns to fast-glob if there are NO negations AND we're not using git root.
363
-
364
- if (usingGitRoot) {
365
- return []; // Patterns are relative to git root, not cwd
366
- }
367
-
368
- const result = [];
369
- let hasNegations = false;
370
-
371
- // Single pass to check for negations and collect positive patterns
372
- for (const pattern of patterns) {
373
- if (isNegativePattern(pattern)) {
374
- hasNegations = true;
375
- break; // Early exit on first negation
520
+ export const buildPrunePatternsAndGuards = (rules, matcher, cwd, {gitignoreOnlySearch = false, searchesForGitignoreFiles = false} = {}) => {
521
+ if (!matcher || !cwd || !rules || rules.length === 0) {
522
+ return {patterns: [], guardNames: []};
523
+ }
524
+
525
+ const negationNames = getNegationFinalSegments(rules);
526
+ const couldNameTheSamePath = createNameComparer();
527
+ const context = {
528
+ cwd,
529
+ matcher,
530
+ hasNegations: negationNames.length > 0,
531
+ canSkipAtAnyDepth: pattern => !negationNames.some(name => couldNameTheSamePath(pattern, name)),
532
+ canMatchIgnoreFile: pattern => searchesForGitignoreFiles && couldNameTheSamePath(pattern, '.gitignore'),
533
+ gitignoreOnlySearch,
534
+ };
535
+
536
+ const patterns = [];
537
+ const guardNames = [];
538
+
539
+ for (const rule of rules) {
540
+ const prune = getRulePrune(rule, context);
541
+ if (!prune) {
542
+ continue;
376
543
  }
377
544
 
378
- result.push(normalizeDirectoryPatternForFastGlob(pattern));
545
+ patterns.push(prune.pattern);
546
+ if (prune.guardName !== undefined) {
547
+ guardNames.push(prune.guardName);
548
+ }
379
549
  }
380
550
 
381
- return hasNegations ? [] : result;
551
+ return {patterns, guardNames};
382
552
  };
553
+
554
+ export const convertPatternsForFastGlob = (rules, matcher, cwd) => buildPrunePatternsAndGuards(rules, matcher, cwd).patterns;