@cjser/globby 16.2.1-cjser.2 → 16.2.3-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.
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, {isPathValid} 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,46 +345,226 @@ 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
+ // Normalize ordinary escaped characters for the ignore package, while preserving escapes that it handles as literals. An escaped question mark is widened to a wildcard because the ignore package does not match it literally, and a possible match is safer here than pruning too much.
363
+ const normalizeGitignorePatternForIgnore = value => value.replaceAll(/\\(.)/gu, (match, character) => '*[]\\'.includes(character) ? match : character);
364
+
365
+ // Turn gitignore-literal text into fast-glob-literal text, so characters like `+(` cannot be
366
+ // misread as micromatch syntax.
367
+ const toLiteralPattern = value => fastGlob.escapePath(unescapeGitignorePattern(value));
368
+
369
+ const finalSegment = value => value.replace(/\/+$/u, '').split('/').pop();
370
+
371
+ // A fragment of rule text is not a rule on its own: `#name` would open a comment and `!name` a negation, both of which stop naming anything. Escape the leading character so the fragment keeps naming what it did inside the rule it came from.
372
+ const toStandaloneRule = value => value.replace(/^([#!])/u, String.raw`\$1`);
373
+
374
+ const isInsideCwd = relativePath => relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
375
+
346
376
  /**
347
- Convert ignore patterns to fast-glob compatible format.
348
- Returns empty array if patterns should be handled by predicate only.
377
+ Resolve a pattern anchored at the directory of its ignore file into a cwd-relative one.
349
378
 
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
379
+ @param {string} directory - Directory of the ignore file that declared the rule.
380
+ @param {string} body - The rule body, relative to that directory.
381
+ @param {string} cwd - Directory the glob runs from.
382
+ @returns {string|undefined} The cwd-relative pattern, or undefined when it targets something outside the cwd.
354
383
  */
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
384
+ const anchorToCwd = (directory, body, cwd) => {
385
+ const relativePath = slash(path.relative(cwd, path.join(directory, body)));
386
+ return isInsideCwd(relativePath) ? relativePath : undefined;
387
+ };
388
+
389
+ // Compare names with the `ignore` package instead of guessing from the syntax, since it is the
390
+ // same engine the predicate uses for the real decision.
391
+ const createNameComparer = () => {
392
+ const nameMatchers = new Map();
393
+ const matchesName = (pattern, name) => {
394
+ // The name is rule text, not a path: in gitignore `\#foo` names the file `#foo`. `Ignore#ignores()` only accepts a `path.relative()`d string and throws otherwise, so unescape first and treat whatever it still rejects (`.`, `..`, anything anchored) as a possible match.
395
+ const namePath = unescapeGitignorePattern(name);
396
+ if (!isPathValid(namePath)) {
397
+ return true;
398
+ }
399
+
400
+ const normalizedPattern = normalizeGitignorePatternForIgnore(pattern);
401
+ let nameMatcher = nameMatchers.get(normalizedPattern);
402
+ if (!nameMatcher) {
403
+ nameMatcher = gitIgnore().add([toStandaloneRule(normalizedPattern)]);
404
+ nameMatchers.set(normalizedPattern, nameMatcher);
405
+ }
406
+
407
+ return nameMatcher.ignores(namePath);
408
+ };
409
+
410
+ // 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.
411
+ return (pattern, name) => {
412
+ if (hasGitignoreWildcards(pattern) && hasGitignoreWildcards(name)) {
413
+ return true;
376
414
  }
377
415
 
378
- // `.gitignore` patterns are relative to the repo root, but an anchored pattern like
379
- // `/foo` looks like an absolute path to globby's directory-to-glob expansion, which
380
- // resolves it against the real filesystem. When the checkout lives under a matching
381
- // `/foo/…` path, `/foo` is a real ancestor directory and expands to `/foo/**`, which
382
- // ignores the whole tree. Drop the leading slash so the pattern stays anchored to the
383
- // cwd instead, letting fast-glob still skip the directory during traversal.
384
- result.push(normalizeDirectoryPatternForFastGlob(pattern).replace(/^\//, ''));
416
+ return hasGitignoreWildcards(name) ? matchesName(name, pattern) : matchesName(pattern, name);
417
+ };
418
+ };
419
+
420
+ const getNegationFinalSegments = rules => rules
421
+ .filter(rule => isNegativePattern(rule.pattern))
422
+ .map(rule => finalSegment(rule.pattern.slice(1)))
423
+ .filter(Boolean);
424
+
425
+ /**
426
+ Check whether any negation in the given rules could re-include a path with one of the given names.
427
+
428
+ 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.
429
+
430
+ @param {Array<{pattern: string, directory: string}>} rules - Raw ignore-file lines and the directory of the ignore file that declared them.
431
+ @param {string[]} names - The guard names returned by `buildPrunePatternsAndGuards`.
432
+ @returns {boolean} Whether a negation could name one of them.
433
+ */
434
+ export const negationsCouldRescue = (rules, names) => {
435
+ if (names.length === 0) {
436
+ return false;
385
437
  }
386
438
 
387
- return hasNegations ? [] : result;
439
+ const couldNameTheSamePath = createNameComparer();
440
+ return getNegationFinalSegments(rules).some(negation => names.some(name => couldNameTheSamePath(name, negation)));
388
441
  };
442
+
443
+ // 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.
444
+ const getRulePrune = ({pattern, directory}, {cwd, matcher, hasNegations, canSkipAtAnyDepth, canMatchIgnoreFile, gitignoreOnlySearch}) => {
445
+ if (isNegativePattern(pattern)) {
446
+ return undefined;
447
+ }
448
+
449
+ const isDirectoryPattern = pattern.endsWith('/');
450
+ const clean = pattern.replace(/\/+$/u, '');
451
+ if (!clean) {
452
+ return undefined;
453
+ }
454
+
455
+ // 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.
456
+ const body = clean.startsWith('**/') && !clean.slice(3).includes('/')
457
+ ? clean.slice(3)
458
+ : clean;
459
+
460
+ if (canMatchIgnoreFile(finalSegment(body))) {
461
+ // 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.
462
+ return undefined;
463
+ }
464
+
465
+ const isGlob = hasGitignoreWildcards(body);
466
+ if (isGlob && MICROMATCH_ONLY_SYNTAX.test(body)) {
467
+ return undefined;
468
+ }
469
+
470
+ // The leading slash stops the normalizer from prefixing `**/`; the passed value already encodes the depth, and an extra `**/` would un-anchor an anchored rule.
471
+ const toFastGlob = value =>
472
+ normalizeDirectoryPatternForFastGlob(`/${value}${isDirectoryPattern ? '/' : ''}`).replace(/^\//u, '');
473
+
474
+ // No separator: matches at any depth below the ignore file that declared it.
475
+ if (!body.includes('/') && canSkipAtAnyDepth(body)) {
476
+ const relativeDirectory = slash(path.relative(cwd, directory));
477
+ const prefix = isInsideCwd(relativeDirectory) ? `${fastGlob.escapePath(relativeDirectory)}/` : '';
478
+ return {pattern: toFastGlob(`${prefix}**/${isGlob ? body : toLiteralPattern(body)}`), guardName: body};
479
+ }
480
+
481
+ // Otherwise fall back to the single occurrence beside the ignore file, which names a concrete path that the matcher can verify directly.
482
+ const anchoredBody = body.replace(/^\//u, '');
483
+ const target = anchorToCwd(directory, isGlob ? anchoredBody : unescapeGitignorePattern(anchoredBody), cwd);
484
+ if (target === undefined) {
485
+ return undefined;
486
+ }
487
+
488
+ // The guard name is compared against negations as rule text, so it has to keep the escapes the rule was written with; the target has already lost them.
489
+ const guardName = finalSegment(anchoredBody);
490
+
491
+ if (isGlob) {
492
+ // A glob does not name a concrete path, so the matcher cannot confirm it is ignored.
493
+ return hasNegations
494
+ ? undefined
495
+ : {pattern: toFastGlob(target), guardName};
496
+ }
497
+
498
+ if (!matcher(path.resolve(cwd, target) + path.sep).ignored) {
499
+ return undefined;
500
+ }
501
+
502
+ // 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.
503
+ const needsGuard = !gitignoreOnlySearch || target.includes('/');
504
+ return {
505
+ pattern: toFastGlob(fastGlob.escapePath(target)),
506
+ guardName: needsGuard ? guardName : undefined,
507
+ };
508
+ };
509
+
510
+ /**
511
+ Build the ignore patterns handed to fast-glob so it can skip ignored directories while traversing.
512
+
513
+ The authoritative filter is always the predicate, so these patterns only ever need to be safe:
514
+ skipping something the predicate would have kept loses files, while skipping less than possible
515
+ merely costs time. Two facts from the gitignore spec make aggressive skipping safe anyway:
516
+
517
+ - "It is not possible to re-include a file if a parent directory of that file is excluded."
518
+ So a directory that is still ignored once every negation has been applied can be skipped whole.
519
+ - A pattern with no separator matches at any depth below its own ignore file, and one with a
520
+ separator is anchored to that file's directory. Working from the raw rules - rather than from
521
+ patterns already rebased onto some other directory - keeps that distinction intact, which is
522
+ what lets this work from a subdirectory of the repository too.
523
+
524
+ The returned guard names are the directory names whose skipping relies on the given rules being
525
+ complete. A caller working from a partial rule set (the ignore-file search) must watch for later
526
+ negations that could name one of them; see `negationsCouldRescue`.
527
+
528
+ @param {Array<{pattern: string, directory: string}>} rules - Raw ignore-file lines and the directory of the ignore file that declared them.
529
+ @param {Function} matcher - The authoritative gitignore matcher.
530
+ @param {string} cwd - Directory the glob runs from.
531
+ @param {Object} [options] - Options.
532
+ @param {boolean} [options.gitignoreOnlySearch] - Whether the rule set can only grow through nested `.gitignore` files.
533
+ @param {boolean} [options.searchesForGitignoreFiles] - Whether the search includes `.gitignore` files.
534
+ @returns {{patterns: string[], guardNames: string[]}} Patterns safe to pass to fast-glob, and the names their safety depends on.
535
+ */
536
+ export const buildPrunePatternsAndGuards = (rules, matcher, cwd, {gitignoreOnlySearch = false, searchesForGitignoreFiles = false} = {}) => {
537
+ if (!matcher || !cwd || !rules || rules.length === 0) {
538
+ return {patterns: [], guardNames: []};
539
+ }
540
+
541
+ const negationNames = getNegationFinalSegments(rules);
542
+ const couldNameTheSamePath = createNameComparer();
543
+ const context = {
544
+ cwd,
545
+ matcher,
546
+ hasNegations: negationNames.length > 0,
547
+ canSkipAtAnyDepth: pattern => !negationNames.some(name => couldNameTheSamePath(pattern, name)),
548
+ canMatchIgnoreFile: pattern => searchesForGitignoreFiles && couldNameTheSamePath(pattern, '.gitignore'),
549
+ gitignoreOnlySearch,
550
+ };
551
+
552
+ const patterns = [];
553
+ const guardNames = [];
554
+
555
+ for (const rule of rules) {
556
+ const prune = getRulePrune(rule, context);
557
+ if (!prune) {
558
+ continue;
559
+ }
560
+
561
+ patterns.push(prune.pattern);
562
+ if (prune.guardName !== undefined) {
563
+ guardNames.push(prune.guardName);
564
+ }
565
+ }
566
+
567
+ return {patterns, guardNames};
568
+ };
569
+
570
+ export const convertPatternsForFastGlob = (rules, matcher, cwd) => buildPrunePatternsAndGuards(rules, matcher, cwd).patterns;