@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/dist-cjser/index.cjs +278 -61
- package/ignore.js +172 -24
- package/index.js +63 -36
- package/package.json +3 -3
- package/readme.md +1 -1
- package/utilities.js +217 -35
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
|
-
|
|
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
|
|
351
|
-
@param {
|
|
352
|
-
@param {
|
|
353
|
-
@returns {string
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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;
|