feat: use go tool

This commit is contained in:
2026-07-03 19:24:01 -04:00
parent 39548b4332
commit 4d0cab1fe0
1542 changed files with 252399 additions and 14 deletions
+868
View File
@@ -0,0 +1,868 @@
import process from 'node:process';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import fastGlob from 'fast-glob';
import gitIgnore from 'ignore';
import isPathInside from 'is-path-inside';
import slash from 'slash';
import {toPath} from 'unicorn-magic/node';
import {
isNegativePattern,
bindFsMethod,
promisifyFsMethod,
findGitRoot,
findGitRootSync,
getParentGitignorePaths,
} from './utilities.js';
const defaultIgnoredDirectories = [
'**/node_modules',
'**/flow-typed',
'**/coverage',
'**/.git',
];
const ignoreFilesGlobOptions = {
absolute: true,
dot: true,
};
export const GITIGNORE_FILES_PATTERN = '**/.gitignore';
// Maximum depth for [include] chains to prevent stack overflow (git uses 10)
const MAX_INCLUDE_DEPTH = 10;
const getReadFileMethod = fsImplementation =>
bindFsMethod(fsImplementation?.promises, 'readFile')
?? bindFsMethod(fsPromises, 'readFile')
?? promisifyFsMethod(fsImplementation, 'readFile');
const getReadFileSyncMethod = fsImplementation =>
bindFsMethod(fsImplementation, 'readFileSync')
?? bindFsMethod(fs, 'readFileSync');
const shouldSkipIgnoreFileError = (error, suppressErrors) => {
if (!error) {
return Boolean(suppressErrors);
}
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
return true;
}
return Boolean(suppressErrors);
};
const createReadError = (kind, filePath, error) => {
const prefix = `Failed to read ${kind} at ${filePath}`;
if (error instanceof Error) {
return new Error(`${prefix}: ${error.message}`, {cause: error});
}
return new Error(`${prefix}: ${String(error)}`);
};
const createIgnoreFileReadError = (filePath, error) => createReadError('ignore file', filePath, error);
const createGitConfigReadError = (filePath, error) => createReadError('git config', filePath, error);
const processIgnoreFileCore = (filePath, readMethod, suppressErrors) => {
try {
const content = readMethod(filePath, 'utf8');
return {filePath, content};
} catch (error) {
if (shouldSkipIgnoreFileError(error, suppressErrors)) {
return undefined;
}
throw createIgnoreFileReadError(filePath, error);
}
};
const readIgnoreFilesSafely = async (paths, readFileMethod, suppressErrors) => {
const fileResults = await Promise.all(paths.map(async filePath => {
try {
const content = await readFileMethod(filePath, 'utf8');
return {filePath, content};
} catch (error) {
if (shouldSkipIgnoreFileError(error, suppressErrors)) {
return undefined;
}
throw createIgnoreFileReadError(filePath, error);
}
}));
return fileResults.filter(Boolean);
};
const readIgnoreFilesSafelySync = (paths, readFileSyncMethod, suppressErrors) => paths
.map(filePath => processIgnoreFileCore(filePath, readFileSyncMethod, suppressErrors))
.filter(Boolean);
const dedupePaths = paths => {
const seen = new Set();
return paths.filter(filePath => {
if (seen.has(filePath)) {
return false;
}
seen.add(filePath);
return true;
});
};
const globIgnoreFiles = (globFunction, patterns, normalizedOptions) => globFunction(patterns, {
...normalizedOptions,
...ignoreFilesGlobOptions, // Must be last to ensure absolute/dot flags stick
});
const getParentIgnorePaths = (gitRoot, normalizedOptions) => gitRoot
? getParentGitignorePaths(gitRoot, normalizedOptions.cwd)
: [];
const combineIgnoreFilePaths = (gitRoot, normalizedOptions, childPaths) => dedupePaths([
...getParentIgnorePaths(gitRoot, normalizedOptions),
...childPaths,
]);
const buildIgnoreResult = (files, normalizedOptions, gitRoot) => {
const baseDir = gitRoot || normalizedOptions.cwd;
const patterns = getPatternsFromIgnoreFiles(files, baseDir);
const matcher = createIgnoreMatcher(patterns, normalizedOptions.cwd, baseDir);
return {
patterns,
matcher,
predicate: fileOrDirectory => matcher(fileOrDirectory).ignored,
usingGitRoot: Boolean(gitRoot && gitRoot !== normalizedOptions.cwd),
};
};
// Apply base path to gitignore patterns based on .gitignore spec 2.22.1
// https://git-scm.com/docs/gitignore#_pattern_format
// See also https://github.com/sindresorhus/globby/issues/146
const applyBaseToPattern = (pattern, base) => {
if (!base) {
return pattern;
}
const isNegative = isNegativePattern(pattern);
const cleanPattern = isNegative ? pattern.slice(1) : pattern;
// Check if pattern has non-trailing slashes
const slashIndex = cleanPattern.indexOf('/');
const hasNonTrailingSlash = slashIndex !== -1 && slashIndex !== cleanPattern.length - 1;
let result;
if (!hasNonTrailingSlash) {
// "If there is no separator at the beginning or middle of the pattern,
// then the pattern may also match at any level below the .gitignore level."
// So patterns like '*.log' or 'temp' or 'build/' (trailing slash) match recursively.
result = path.posix.join(base, '**', cleanPattern);
} else if (cleanPattern.startsWith('/')) {
// "If there is a separator at the beginning [...] of the pattern,
// then the pattern is relative to the directory level of the particular .gitignore file itself."
// Leading slash anchors the pattern to the .gitignore's directory.
result = path.posix.join(base, cleanPattern.slice(1));
} else {
// "If there is a separator [...] middle [...] of the pattern,
// then the pattern is relative to the directory level of the particular .gitignore file itself."
// Patterns like 'src/foo' are relative to the .gitignore's directory.
result = path.posix.join(base, cleanPattern);
}
return isNegative ? '!' + result : result;
};
const parseIgnoreFile = (file, cwd) => {
const base = slash(path.relative(cwd, path.dirname(file.filePath)));
return file.content
.split(/\r?\n/)
.filter(line => line && !line.startsWith('#'))
.map(pattern => applyBaseToPattern(pattern, base));
};
const toRelativePath = (fileOrDirectory, cwd) => {
if (path.isAbsolute(fileOrDirectory)) {
// When paths are equal, path.relative returns empty string which is valid
// isPathInside returns false for equal paths, so check this case first
const relativePath = path.relative(cwd, fileOrDirectory);
if (relativePath && !isPathInside(fileOrDirectory, cwd)) {
// Path is outside cwd - it cannot be ignored by patterns in cwd
// Return undefined to indicate this path is outside scope
return undefined;
}
return relativePath;
}
// Normalize relative paths:
// - Git treats './foo' as 'foo' when checking against patterns
// - Patterns starting with './' in .gitignore are invalid and don't match anything
// - The ignore library expects normalized paths without './' prefix
if (fileOrDirectory.startsWith('./')) {
return fileOrDirectory.slice(2);
}
// Paths with ../ point outside cwd and cannot match patterns from this directory
// Return undefined to indicate this path is outside scope
if (fileOrDirectory.startsWith('../')) {
return undefined;
}
return fileOrDirectory;
};
const notIgnored = {ignored: false, unignored: false};
const createIgnoreMatcher = (patterns, cwd, baseDir) => {
const ignores = gitIgnore().add(patterns);
// Normalize to handle path separator and . / .. components consistently
const resolvedCwd = path.normalize(path.resolve(cwd));
const resolvedBaseDir = path.normalize(path.resolve(baseDir));
return fileOrDirectory => {
fileOrDirectory = toPath(fileOrDirectory);
const hasTrailingSeparator = /[/\\]$/.test(fileOrDirectory);
// Never ignore the cwd itself - use normalized comparison
const normalizedPath = path.normalize(path.resolve(fileOrDirectory));
if (normalizedPath === resolvedCwd) {
return notIgnored;
}
// Convert to relative path from baseDir (use normalized baseDir)
let relativePath = toRelativePath(fileOrDirectory, resolvedBaseDir);
// If path is outside baseDir (undefined), it can't be ignored by patterns
if (relativePath === undefined) {
return notIgnored;
}
if (!relativePath) {
return notIgnored;
}
if (hasTrailingSeparator && !relativePath.endsWith(path.sep)) {
relativePath += path.sep;
}
return ignores.test(slash(relativePath));
};
};
const normalizeOptions = (options = {}) => {
const ignoreOption = options.ignore
? (Array.isArray(options.ignore) ? options.ignore : [options.ignore])
: [];
const cwd = toPath(options.cwd) ?? process.cwd();
// Adjust deep option for fast-glob: fast-glob's deep counts differently than expected
// User's deep: 0 = root only -> fast-glob needs: 1
// User's deep: 1 = root + 1 level -> fast-glob needs: 2
const deep = typeof options.deep === 'number' ? Math.max(0, options.deep) + 1 : Number.POSITIVE_INFINITY;
// Only pass through specific fast-glob options that make sense for finding ignore files
return {
cwd,
suppressErrors: options.suppressErrors ?? false,
deep,
ignore: [...ignoreOption, ...defaultIgnoredDirectories],
followSymbolicLinks: options.followSymbolicLinks ?? true,
concurrency: options.concurrency,
throwErrorOnBrokenSymbolicLink: options.throwErrorOnBrokenSymbolicLink ?? false,
fs: options.fs,
};
};
const unescapeGitQuotedValue = value => value.replaceAll(/\\(["\\abfnrtv])/g, (_match, escapedCharacter) => {
switch (escapedCharacter) {
case 'a': {
return '\u0007';
}
case 'b': {
return '\b';
}
case 'f': {
return '\f';
}
case 'n': {
return '\n';
}
case 'r': {
return '\r';
}
case 't': {
return '\t';
}
case 'v': {
return '\v';
}
default: {
return escapedCharacter;
}
}
});
const parseGitConfigValue = value => {
const trimmedValue = value.trim();
const quotedMatch = trimmedValue.match(/^"((?:[^"\\]|\\.)*)"\s*(?:[#;].*)?$/);
if (quotedMatch) {
return unescapeGitQuotedValue(quotedMatch[1]);
}
return trimmedValue.replace(/\s[#;].*$/, '').trim();
};
const resolveConfigPath = (filePath, configPath) => {
if (configPath.startsWith('~/')) {
const homeDirectory = os.homedir();
const resolved = path.join(homeDirectory, configPath.slice(2));
// Ensure the resolved path is within the home directory to prevent traversal via ~/..
if (!isPathInside(resolved, homeDirectory)) {
// Invalid path, return a path that won't exist
return path.join(homeDirectory, '.globby-invalid-path-traversal');
}
return resolved;
}
if (path.isAbsolute(configPath)) {
return configPath;
}
return path.resolve(path.dirname(filePath), configPath);
};
const parseGitConfigSection = line => {
if (!line.startsWith('[')) {
return undefined;
}
let inQuotes = false;
let isEscaped = false;
for (let index = 1; index < line.length; index++) {
const character = line[index];
if (isEscaped) {
isEscaped = false;
continue;
}
if (character === '\\') {
isEscaped = true;
continue;
}
if (character === '"') {
inQuotes = !inQuotes;
continue;
}
if (character === ']' && !inQuotes) {
const remainder = line.slice(index + 1).trimStart();
if (remainder && !remainder.startsWith('#') && !remainder.startsWith(';')) {
return undefined;
}
return line.slice(1, index).trim();
}
}
return undefined;
};
const parseGitConfigEntry = line => {
const match = line.match(/^([A-Za-z\d-.]+)\s*=\s*(.*)$/);
if (!match) {
return undefined;
}
return {
key: match[1].toLowerCase(),
value: parseGitConfigValue(match[2]),
};
};
const parseIncludeIfCondition = section => {
if (!section) {
return undefined;
}
const match = section.match(/^includeif\s+"([^"]+)"$/i);
return match ? match[1] : undefined;
};
const normalizeGitConfigConditionPattern = (pattern, configFilePath) => {
if (pattern.startsWith('~/')) {
pattern = path.join(os.homedir(), pattern.slice(2));
} else if (pattern.startsWith('./')) {
pattern = path.resolve(path.dirname(configFilePath), pattern.slice(2));
} else if (!path.isAbsolute(pattern)) {
pattern = `**/${pattern}`;
}
if (pattern.endsWith('/')) {
pattern += '**';
}
return slash(pattern);
};
const gitConfigGlobToRegex = (pattern, flags) => {
let regex = '';
for (let index = 0; index < pattern.length; index++) {
const character = pattern[index];
const nextCharacter = pattern[index + 1];
const nextNextCharacter = pattern[index + 2];
if (character === '*' && nextCharacter === '*' && nextNextCharacter === '/') {
regex += '(?:.*/)?';
index += 2;
continue;
}
if (character === '*' && nextCharacter === '*') {
regex += '.*';
index += 1;
continue;
}
if (character === '*') {
regex += '[^/]*';
continue;
}
if (character === '?') {
regex += '[^/]';
continue;
}
if (character === '[') {
const closingBracketIndex = pattern.indexOf(']', index + 1);
if (closingBracketIndex !== -1) {
const bracketContent = pattern.slice(index + 1, closingBracketIndex);
if (bracketContent) {
const negatedBracketContent = bracketContent[0] === '!' ? `^${bracketContent.slice(1)}` : bracketContent;
regex += `[${negatedBracketContent}]`;
index = closingBracketIndex;
continue;
}
}
}
regex += /[|\\{}()[\]^$+?.]/.test(character) ? `\\${character}` : character;
}
try {
return new RegExp(`^${regex}$`, flags);
} catch {
// If regex construction fails (e.g., invalid bracket expression), return a non-matching pattern
return /(?!)/;
}
};
const matchesIncludeIfCondition = (condition, gitDirectory, configFilePath) => {
if (!gitDirectory) {
return false;
}
const match = condition.match(/^(gitdir|gitdir\/i):(.*)$/i);
if (!match) {
return false;
}
const [, keyword, rawPattern] = match;
const pattern = normalizeGitConfigConditionPattern(rawPattern.trim(), configFilePath);
const isCaseInsensitive = keyword.toLowerCase() === 'gitdir/i';
const regularExpression = gitConfigGlobToRegex(pattern, isCaseInsensitive ? 'i' : undefined);
const normalizedGitDirectory = slash(path.resolve(gitDirectory));
return regularExpression.test(normalizedGitDirectory);
};
const shouldIncludeConfigSection = (section, gitDirectory, configFilePath) => {
if (section?.toLowerCase() === 'include') {
return true;
}
// `globalGitignore` intentionally keeps `includeIf` support narrow.
// Only `gitdir:` and `gitdir/i:` conditions are treated as active here.
// Other Git predicates such as `onbranch:` are outside this feature's
// supported boundary and are documented as unsupported.
const condition = parseIncludeIfCondition(section);
return condition ? matchesIncludeIfCondition(condition, gitDirectory, configFilePath) : false;
};
const createExcludesFileValue = (value, declaringFilePath) => ({
value,
declaringFilePath,
});
/**
Parse git config content and return the excludesFile value and any include paths to recurse into.
The caller is responsible for reading files and recursing (sync or async).
*/
const parseGitConfigForExcludesFile = (content, normalizedPath, gitDirectory) => {
let currentSection;
let excludesFile;
const includePaths = [];
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) {
continue;
}
if (trimmed.startsWith('[')) {
currentSection = parseGitConfigSection(trimmed);
continue;
}
const entry = parseGitConfigEntry(trimmed);
if (!entry) {
continue;
}
if (currentSection?.toLowerCase() === 'core' && entry.key === 'excludesfile') {
excludesFile = createExcludesFileValue(entry.value, normalizedPath);
continue;
}
if (shouldIncludeConfigSection(currentSection, gitDirectory, normalizedPath) && entry.key === 'path' && entry.value) {
includePaths.push(resolveConfigPath(normalizedPath, entry.value));
}
}
return {excludesFile, includePaths};
};
const readGitConfigFile = (normalizedPath, readMethod, suppressErrors) => {
try {
return readMethod(normalizedPath, 'utf8');
} catch (error) {
if (shouldSkipIgnoreFileError(error, suppressErrors)) {
return undefined;
}
throw createGitConfigReadError(normalizedPath, error);
}
};
const getExcludesFileFromGitConfigSync = (filePath, readFileSync, gitDirectory, options = {}) => {
const {suppressErrors, includeStack = new Set(), depth = 0} = options;
const normalizedPath = path.resolve(filePath);
if (includeStack.has(normalizedPath)) {
return undefined;
}
if (depth >= MAX_INCLUDE_DEPTH) {
return undefined;
}
includeStack.add(normalizedPath);
const content = readGitConfigFile(normalizedPath, readFileSync, suppressErrors);
if (content === undefined) {
includeStack.delete(normalizedPath);
return undefined;
}
let {excludesFile, includePaths} = parseGitConfigForExcludesFile(content, normalizedPath, gitDirectory);
for (const includePath of includePaths) {
const includedExcludesFile = getExcludesFileFromGitConfigSync(includePath, readFileSync, gitDirectory, {suppressErrors, includeStack, depth: depth + 1});
if (includedExcludesFile !== undefined) {
excludesFile = includedExcludesFile;
}
}
includeStack.delete(normalizedPath);
return excludesFile;
};
const getExcludesFileFromGitConfigAsync = async (filePath, readFile, gitDirectory, options = {}) => {
const {suppressErrors, includeStack = new Set(), depth = 0} = options;
const normalizedPath = path.resolve(filePath);
if (includeStack.has(normalizedPath)) {
return undefined;
}
if (depth >= MAX_INCLUDE_DEPTH) {
return undefined;
}
includeStack.add(normalizedPath);
let content;
try {
content = await readFile(normalizedPath, 'utf8');
} catch (error) {
includeStack.delete(normalizedPath);
if (shouldSkipIgnoreFileError(error, suppressErrors)) {
return undefined;
}
throw createGitConfigReadError(normalizedPath, error);
}
let {excludesFile, includePaths} = parseGitConfigForExcludesFile(content, normalizedPath, gitDirectory);
for (const includePath of includePaths) {
// eslint-disable-next-line no-await-in-loop
const includedExcludesFile = await getExcludesFileFromGitConfigAsync(includePath, readFile, gitDirectory, {suppressErrors, includeStack, depth: depth + 1});
if (includedExcludesFile !== undefined) {
excludesFile = includedExcludesFile;
}
}
includeStack.delete(normalizedPath);
return excludesFile;
};
const resolveGitDirectoryFromFile = (gitFilePath, content) => {
const match = content.match(/^gitdir:\s*(.+?)\s*$/i);
if (!match) {
return gitFilePath;
}
return path.resolve(path.dirname(gitFilePath), match[1]);
};
const getGitDirectorySync = (gitRoot, readFileSync) => {
if (!gitRoot) {
return undefined;
}
const gitFilePath = path.join(gitRoot, '.git');
try {
return resolveGitDirectoryFromFile(gitFilePath, readFileSync(gitFilePath, 'utf8'));
} catch {
return gitFilePath;
}
};
const getGitDirectoryAsync = async (gitRoot, readFile) => {
if (!gitRoot) {
return undefined;
}
const gitFilePath = path.join(gitRoot, '.git');
try {
return resolveGitDirectoryFromFile(gitFilePath, await readFile(gitFilePath, 'utf8'));
} catch {
return gitFilePath;
}
};
const getXdgConfigHome = () => process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
const getGitConfigPaths = () => {
// `globalGitignore` intentionally reads only user-level Git config.
// It does not try to emulate every Git config scope such as repository
// `.git/config` or system config. This keeps the feature boundary small
// and predictable while still covering the common user-level excludes file.
//
// `GIT_CONFIG_GLOBAL` replaces the user-level config entirely.
if ('GIT_CONFIG_GLOBAL' in process.env) {
const value = process.env.GIT_CONFIG_GLOBAL;
return value ? [value] : [];
}
return [
path.join(getXdgConfigHome(), 'git', 'config'),
path.join(os.homedir(), '.gitconfig'),
];
};
const getDefaultGlobalGitignorePath = () => path.join(getXdgConfigHome(), 'git', 'ignore');
const resolveExcludesFilePath = excludesFileConfig => {
// An explicit empty value disables the global gitignore entirely.
if (excludesFileConfig?.value === '') {
return undefined;
}
// When no core.excludesFile was configured, fall back to Git's default
// user-level ignore file. This matches Git's behavior: the default path
// applies even when GIT_CONFIG_GLOBAL="" suppresses config file reading.
if (excludesFileConfig === undefined) {
return getDefaultGlobalGitignorePath();
}
// Relative core.excludesfile values are resolved from the config file that
// declared them. Do not resolve them from the repository root.
return resolveConfigPath(excludesFileConfig.declaringFilePath, excludesFileConfig.value);
};
const readGlobalGitignoreContent = (filePath, readMethod, suppressErrors) => {
try {
const content = readMethod(filePath, 'utf8');
return {filePath, content};
} catch (error) {
if (shouldSkipIgnoreFileError(error, suppressErrors)) {
return undefined;
}
throw createIgnoreFileReadError(filePath, error);
}
};
export const getGlobalGitignoreFile = (options = {}) => {
const cwd = toPath(options.cwd) ?? process.cwd();
const readFileSync = getReadFileSyncMethod(options.fs);
const gitRoot = findGitRootSync(cwd, options.fs);
const gitDirectory = getGitDirectorySync(gitRoot, readFileSync);
let excludesFileConfig;
for (const gitConfigPath of getGitConfigPaths()) {
const value = getExcludesFileFromGitConfigSync(gitConfigPath, readFileSync, gitDirectory, {suppressErrors: options.suppressErrors});
if (value !== undefined) {
excludesFileConfig = value;
}
}
const filePath = resolveExcludesFilePath(excludesFileConfig);
return filePath === undefined ? undefined : readGlobalGitignoreContent(filePath, readFileSync, options.suppressErrors);
};
export const getGlobalGitignoreFileAsync = async (options = {}) => {
const cwd = toPath(options.cwd) ?? process.cwd();
const readFile = getReadFileMethod(options.fs);
const gitRoot = await findGitRoot(cwd, options.fs);
const gitDirectory = await getGitDirectoryAsync(gitRoot, readFile);
const excludesFileValues = await Promise.all(getGitConfigPaths().map(gitConfigPath => getExcludesFileFromGitConfigAsync(
gitConfigPath,
readFile,
gitDirectory,
{suppressErrors: options.suppressErrors},
)));
const excludesFileConfig = excludesFileValues.findLast(value => value !== undefined);
const filePath = resolveExcludesFilePath(excludesFileConfig);
if (filePath === undefined) {
return undefined;
}
try {
const content = await readFile(filePath, 'utf8');
return {filePath, content};
} catch (error) {
if (shouldSkipIgnoreFileError(error, options.suppressErrors)) {
return undefined;
}
throw createIgnoreFileReadError(filePath, error);
}
};
export const buildGlobalMatcher = (globalIgnoreFile, cwd, rootDirectory = cwd) => {
// Passing the file's own directory as cwd gives base='', so patterns stay
// unchanged and are interpreted relative to the project root (cwd). This
// matches Git's behavior: patterns without slashes match at any depth,
// patterns starting with / are anchored to the project root.
const patterns = parseIgnoreFile(globalIgnoreFile, path.dirname(globalIgnoreFile.filePath));
return createIgnoreMatcher(patterns, cwd, rootDirectory);
};
export const buildGlobalPredicate = (globalIgnoreFile, cwd, rootDirectory = cwd) => {
const matcher = buildGlobalMatcher(globalIgnoreFile, cwd, rootDirectory);
return fileOrDirectory => matcher(fileOrDirectory).ignored;
};
const collectIgnoreFileArtifactsAsync = async (patterns, options, includeParentIgnoreFiles) => {
const normalizedOptions = normalizeOptions(options);
const childPaths = await globIgnoreFiles(fastGlob, patterns, normalizedOptions);
const gitRoot = includeParentIgnoreFiles
? await findGitRoot(normalizedOptions.cwd, normalizedOptions.fs)
: undefined;
const allPaths = combineIgnoreFilePaths(gitRoot, normalizedOptions, childPaths);
const readFileMethod = getReadFileMethod(normalizedOptions.fs);
const files = await readIgnoreFilesSafely(allPaths, readFileMethod, normalizedOptions.suppressErrors);
return {files, normalizedOptions, gitRoot};
};
const collectIgnoreFileArtifactsSync = (patterns, options, includeParentIgnoreFiles) => {
const normalizedOptions = normalizeOptions(options);
const childPaths = globIgnoreFiles(fastGlob.sync, patterns, normalizedOptions);
const gitRoot = includeParentIgnoreFiles
? findGitRootSync(normalizedOptions.cwd, normalizedOptions.fs)
: undefined;
const allPaths = combineIgnoreFilePaths(gitRoot, normalizedOptions, childPaths);
const readFileSyncMethod = getReadFileSyncMethod(normalizedOptions.fs);
const files = readIgnoreFilesSafelySync(allPaths, readFileSyncMethod, normalizedOptions.suppressErrors);
return {files, normalizedOptions, gitRoot};
};
export const isIgnoredByIgnoreFiles = async (patterns, options) => {
const {files, normalizedOptions, gitRoot} = await collectIgnoreFileArtifactsAsync(patterns, options, false);
return buildIgnoreResult(files, normalizedOptions, gitRoot).predicate;
};
export const isIgnoredByIgnoreFilesSync = (patterns, options) => {
const {files, normalizedOptions, gitRoot} = collectIgnoreFileArtifactsSync(patterns, options, false);
return buildIgnoreResult(files, normalizedOptions, gitRoot).predicate;
};
const getPatternsFromIgnoreFiles = (files, baseDir) => files.flatMap(file => parseIgnoreFile(file, baseDir));
/**
Read ignore files and return both patterns and predicate.
This avoids reading the same files twice (once for patterns, once for filtering).
@param {string[]} patterns - Patterns to find ignore files
@param {Object} options - Options object
@param {boolean} [includeParentIgnoreFiles=false] - Whether to search for parent .gitignore files
@returns {Promise<{patterns: string[], matcher: Function, predicate: Function, usingGitRoot: boolean}>}
*/
export const getIgnorePatternsAndPredicate = async (patterns, options, includeParentIgnoreFiles = false) => {
const {files, normalizedOptions, gitRoot} = await collectIgnoreFileArtifactsAsync(
patterns,
options,
includeParentIgnoreFiles,
);
return buildIgnoreResult(files, normalizedOptions, gitRoot);
};
/**
Read ignore files and return both patterns and predicate (sync version).
@param {string[]} patterns - Patterns to find ignore files
@param {Object} options - Options object
@param {boolean} [includeParentIgnoreFiles=false] - Whether to search for parent .gitignore files
@returns {{patterns: string[], matcher: Function, predicate: Function, usingGitRoot: boolean}}
*/
export const getIgnorePatternsAndPredicateSync = (patterns, options, includeParentIgnoreFiles = false) => {
const {files, normalizedOptions, gitRoot} = collectIgnoreFileArtifactsSync(
patterns,
options,
includeParentIgnoreFiles,
);
return buildIgnoreResult(files, normalizedOptions, gitRoot);
};
export const isGitIgnored = options => isIgnoredByIgnoreFiles(GITIGNORE_FILES_PATTERN, options);
export const isGitIgnoredSync = options => isIgnoredByIgnoreFilesSync(GITIGNORE_FILES_PATTERN, options);
+413
View File
@@ -0,0 +1,413 @@
import type FastGlob from 'fast-glob';
export type GlobEntry = FastGlob.Entry;
export type GlobTask = {
readonly patterns: string[];
readonly options: Options;
};
export type ExpandDirectoriesOption =
| boolean
| readonly string[]
| {files?: readonly string[]; extensions?: readonly string[]};
type FastGlobOptionsWithoutCwd = Omit<FastGlob.Options, 'cwd'>;
export type Options = {
/**
If set to `true`, `globby` will automatically glob directories for you. If you define an `Array` it will only glob files that matches the patterns inside the `Array`. You can also define an `Object` with `files` and `extensions` like in the example below.
Note that if you set this option to `false`, you won't get back matched directories unless you set `onlyFiles: false`.
@default true
@example
```
import {globby} from 'globby';
const paths = await globby('images', {
expandDirectories: {
files: ['cat', 'unicorn', '*.jpg'],
extensions: ['png']
}
});
console.log(paths);
//=> ['cat.png', 'unicorn.png', 'cow.jpg', 'rainbow.jpg']
```
*/
readonly expandDirectories?: ExpandDirectoriesOption;
/**
Respect ignore patterns in `.gitignore` files that apply to the globbed files.
When enabled, globby searches for `.gitignore` files from the current working directory downward, and if a Git repository is detected (by finding a `.git` directory), it also respects `.gitignore` files in parent directories up to the repository root. This matches Git's actual behavior where patterns from parent `.gitignore` files apply to subdirectories.
Gitignore patterns take priority over user patterns, matching Git's behavior. To include gitignored files, set this to `false`.
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.
@default false
*/
readonly gitignore?: boolean;
/**
Glob patterns to look for ignore files, which are then used to ignore globbed files.
This is a more generic form of the `gitignore` option, allowing you to find ignore files with a [compatible syntax](http://git-scm.com/docs/gitignore). For instance, this works with Babel's `.babelignore`, Prettier's `.prettierignore`, or ESLint's `.eslintignore` files.
Performance tip: Using a specific path like `'.gitignore'` is much faster than recursive patterns.
@default undefined
*/
readonly ignoreFiles?: string | readonly string[];
/**
When only negation patterns are provided (e.g., `['!*.json']`), automatically prepend a catch-all pattern (`**\/*`) to match all files before applying negations.
Set to `false` to return an empty array when only negation patterns are provided. This can be useful when patterns are user-controlled, to avoid unexpectedly matching all files.
@default true
@example
```
import {globby} from 'globby';
// Default behavior: matches all files except .json
await globby(['!*.json']);
//=> ['file.txt', 'image.png', ...]
// Disable expansion: returns empty array
await globby(['!*.json'], {expandNegationOnlyPatterns: false});
//=> []
```
*/
readonly expandNegationOnlyPatterns?: boolean;
/**
The current working directory in which to search.
@default process.cwd()
*/
readonly cwd?: URL | string;
} & FastGlobOptionsWithoutCwd;
export type GlobbyOptions = Options & {
/**
Respect ignore patterns in the global gitignore file configured via `git config core.excludesfile`.
Values from `[include]` and `gitdir` or `gitdir/i` `[includeIf]` sections inside those user-level config files are also respected.
Patterns in the global gitignore are treated as root-level patterns, matching Git's own behavior.
This option only reads the user-level Git config (`GIT_CONFIG_GLOBAL`, `$XDG_CONFIG_HOME/git/config`, and `~/.gitconfig`). When `core.excludesfile` is unset, it falls back to Git's default user-level ignore file at `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore`. Repository `.git/config` and system config are intentionally not consulted. Other `includeIf` predicates such as `onbranch:` are intentionally not supported.
When used with a custom `fs`, `globby()` and `globbyStream()` also require `fs.promises.stat` or `fs.stat`, and `globbySync()` requires `statSync`.
@default false
*/
readonly globalGitignore?: boolean;
};
export type GitignoreOptions = {
/**
The current working directory in which to search.
@default process.cwd()
*/
readonly cwd?: URL | string;
/**
Suppress errors when encountering directories or files without read permissions.
By default, fast-glob only suppresses `ENOENT` errors. Set to `true` to suppress any error.
@default false
*/
readonly suppressErrors?: boolean;
/**
Specifies the maximum depth of ignore file search relative to the start directory.
@default Infinity
*/
readonly deep?: number;
/**
Glob patterns to exclude from ignore file search.
@default []
*/
readonly ignore?: string | readonly string[];
/**
Indicates whether to traverse descendants of symbolic link directories.
@default true
*/
readonly followSymbolicLinks?: boolean;
/**
Specifies the maximum number of concurrent requests from a reader to read directories.
@default os.cpus().length
*/
readonly concurrency?: number;
/**
Throw an error when symbolic link is broken if `true` or safely return `lstat` call if `false`.
@default false
*/
readonly throwErrorOnBrokenSymbolicLink?: boolean;
/**
Custom file system implementation (useful for testing or virtual file systems).
The custom fs must also provide `readFile`/`readFileSync` methods.
@default undefined
*/
readonly fs?: FastGlob.Options['fs'];
};
export type GlobbyFilterFunction = (path: URL | string) => boolean;
type AsyncIterableReadable<Value> = Omit<NodeJS.ReadableStream, typeof Symbol.asyncIterator> & {
[Symbol.asyncIterator](): NodeJS.AsyncIterator<Value>;
};
/**
A readable stream that yields string paths from glob patterns.
*/
export type GlobbyStream = AsyncIterableReadable<string>;
/**
A readable stream that yields `GlobEntry` objects from glob patterns when `objectMode` is enabled.
*/
export type GlobbyEntryStream = AsyncIterableReadable<GlobEntry>;
/**
Find files and directories using glob patterns.
Note that glob patterns can only contain forward-slashes, not backward-slashes, so if you want to construct a glob pattern from path components, you need to use `path.posix.join()` instead of `path.join()`.
Windows: Patterns with backslashes will silently fail. Use `path.posix.join()` or `convertPathToPattern()`.
@param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns). Supports negation patterns to exclude files. When using only negation patterns (like `['!*.json']`), globby implicitly prepends a catch-all pattern to match all files before applying negations.
@param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package.
@returns The matching paths.
@example
```
import {globby} from 'globby';
const paths = await globby(['*', '!cake']);
console.log(paths);
//=> ['unicorn', 'rainbow']
```
@example
```
import {globby} from 'globby';
// Negation-only patterns match all files except the negated ones
const paths = await globby(['!*.json', '!*.xml'], {cwd: 'config'});
console.log(paths);
//=> ['config.js', 'settings.yaml']
```
*/
export function globby(
patterns: string | readonly string[],
options: GlobbyOptions & ({objectMode: true} | {stats: true})
): Promise<GlobEntry[]>;
export function globby(
patterns: string | readonly string[],
options?: GlobbyOptions
): Promise<string[]>;
/**
Find files and directories using glob patterns.
Note that glob patterns can only contain forward-slashes, not backward-slashes, so if you want to construct a glob pattern from path components, you need to use `path.posix.join()` instead of `path.join()`.
@param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns).
@param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package.
@returns The matching paths.
*/
export function globbySync(
patterns: string | readonly string[],
options: GlobbyOptions & ({objectMode: true} | {stats: true})
): GlobEntry[];
export function globbySync(
patterns: string | readonly string[],
options?: GlobbyOptions
): string[];
/**
Find files and directories using glob patterns.
Note that glob patterns can only contain forward-slashes, not backward-slashes, so if you want to construct a glob pattern from path components, you need to use `path.posix.join()` instead of `path.join()`.
@param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns).
@param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package.
@returns The stream of matching paths.
@example
```
import {globbyStream} from 'globby';
for await (const path of globbyStream('*.tmp')) {
console.log(path);
}
```
*/
export function globbyStream(
patterns: string | readonly string[],
options: GlobbyOptions & ({objectMode: true} | {stats: true})
): GlobbyEntryStream;
export function globbyStream(
patterns: string | readonly string[],
options?: GlobbyOptions
): GlobbyStream;
/**
Note that you should avoid running the same tasks multiple times as they contain a file system cache. Instead, run this method each time to ensure file system changes are taken into consideration.
@param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns).
@param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package.
@returns An object in the format `{pattern: string, options: object}`, which can be passed as arguments to [`fast-glob`](https://github.com/mrmlnc/fast-glob). This is useful for other globbing-related packages.
*/
export function generateGlobTasks(
patterns: string | readonly string[],
options?: Options
): Promise<GlobTask[]>;
/**
@see generateGlobTasks
@returns An object in the format `{pattern: string, options: object}`, which can be passed as arguments to [`fast-glob`](https://github.com/mrmlnc/fast-glob). This is useful for other globbing-related packages.
*/
export function generateGlobTasksSync(
patterns: string | readonly string[],
options?: Options
): GlobTask[];
/**
Note that the options affect the results.
This function is backed by [`fast-glob`](https://github.com/mrmlnc/fast-glob#isdynamicpatternpattern-options).
@param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns).
@param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3).
@returns Whether there are any special glob characters in the `patterns`.
*/
export function isDynamicPattern(
patterns: string | readonly string[],
options?: FastGlobOptionsWithoutCwd & {
/**
The current working directory in which to search.
@default process.cwd()
*/
readonly cwd?: URL | string;
}
): boolean;
/**
`.gitignore` files matched by the ignore config are not used for the resulting filter function.
@returns A filter function indicating whether a given path is ignored via a `.gitignore` file.
@example
```
import {isGitIgnored} from 'globby';
const isIgnored = await isGitIgnored();
console.log(isIgnored('some/file'));
```
*/
export function isGitIgnored(options?: GitignoreOptions): Promise<GlobbyFilterFunction>;
/**
@see isGitIgnored
@returns A filter function indicating whether a given path is ignored via a `.gitignore` file.
*/
export function isGitIgnoredSync(options?: GitignoreOptions): GlobbyFilterFunction;
/**
Converts a path to a pattern by escaping special glob characters like `()`, `[]`, `{}`. On Windows, also converts backslashes to forward slashes.
Use this when your literal paths contain characters with special meaning in globs.
@param source - A file system path to convert to a safe glob pattern.
@returns The path with special glob characters escaped.
@example
```
import {globby, convertPathToPattern} from 'globby';
// ❌ Fails - parentheses are glob syntax
await globby('C:/Program Files (x86)/*.txt');
//=> []
// ✅ Works
const base = convertPathToPattern('C:/Program Files (x86)');
await globby(`${base}/*.txt`);
//=> ['C:/Program Files (x86)/file.txt']
```
*/
export function convertPathToPattern(source: string): FastGlob.Pattern;
/**
Check if a path is ignored by the ignore files.
@param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns).
@param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package.
@returns A filter function indicating whether a given path is ignored via the ignore files.
This is a more generic form of the `isGitIgnored` function, allowing you to find ignore files with a [compatible syntax](http://git-scm.com/docs/gitignore). For instance, this works with Babel's `.babelignore`, Prettier's `.prettierignore`, or ESLint's `.eslintignore` files.
@example
```
import {isIgnoredByIgnoreFiles} from 'globby';
const isIgnored = await isIgnoredByIgnoreFiles('**\/.gitignore');
console.log(isIgnored('some/file'));
```
*/
export function isIgnoredByIgnoreFiles(
patterns: string | readonly string[],
options?: Options
): Promise<GlobbyFilterFunction>;
/**
Check if a path is ignored by the ignore files.
@param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns).
@param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package.
@returns A filter function indicating whether a given path is ignored via the ignore files.
This is a more generic form of the `isGitIgnored` function, allowing you to find ignore files with a [compatible syntax](http://git-scm.com/docs/gitignore). For instance, this works with Babel's `.babelignore`, Prettier's `.prettierignore`, or ESLint's `.eslintignore` files.
@see {@link isIgnoredByIgnoreFiles}
@example
```
import {isIgnoredByIgnoreFilesSync} from 'globby';
const isIgnored = isIgnoredByIgnoreFilesSync('**\/.gitignore');
console.log(isIgnored('some/file'));
```
*/
export function isIgnoredByIgnoreFilesSync(
patterns: string | readonly string[],
options?: Options
): GlobbyFilterFunction;
+680
View File
@@ -0,0 +1,680 @@
import process from 'node:process';
import fs from 'node:fs';
import nodePath from 'node:path';
import {Readable} from 'node:stream';
import mergeStreams from '@sindresorhus/merge-streams';
import fastGlob from 'fast-glob';
import {toPath} from 'unicorn-magic/node';
import {
GITIGNORE_FILES_PATTERN,
getIgnorePatternsAndPredicate,
getIgnorePatternsAndPredicateSync,
getGlobalGitignoreFile,
getGlobalGitignoreFileAsync,
buildGlobalMatcher,
} from './ignore.js';
import {
bindFsMethod,
promisifyFsMethod,
isNegativePattern,
getStaticAbsolutePathPrefix,
normalizeNegativePattern,
normalizeDirectoryPatternForFastGlob,
adjustIgnorePatternsForParentDirectories,
convertPatternsForFastGlob,
findGitRoot,
findGitRootSync,
} from './utilities.js';
const assertPatternsInput = patterns => {
if (patterns.some(pattern => typeof pattern !== 'string')) {
throw new TypeError('Patterns must be a string or an array of strings');
}
};
const getStatMethod = fsImplementation => {
if (fsImplementation) {
return bindFsMethod(fsImplementation.promises, 'stat')
?? promisifyFsMethod(fsImplementation, 'stat');
}
return bindFsMethod(fs.promises, 'stat');
};
const getStatSyncMethod = fsImplementation =>
bindFsMethod(fsImplementation, 'statSync')
?? bindFsMethod(fs, 'statSync');
const isDirectory = async (path, fsImplementation) => {
try {
const stats = await getStatMethod(fsImplementation)(path);
return stats.isDirectory();
} catch {
return false;
}
};
const isDirectorySync = (path, fsImplementation) => {
try {
const stats = getStatSyncMethod(fsImplementation)(path);
return stats.isDirectory();
} catch {
return false;
}
};
const normalizePathForDirectoryGlob = (filePath, cwd) => {
const path = isNegativePattern(filePath) ? filePath.slice(1) : filePath;
return nodePath.isAbsolute(path) ? path : nodePath.join(cwd, path);
};
const shouldExpandGlobstarDirectory = pattern => {
const match = pattern?.match(/\*\*\/([^/]+)$/);
if (!match) {
return false;
}
const dirname = match[1];
const hasWildcards = /[*?[\]{}]/.test(dirname);
const hasExtension = nodePath.extname(dirname) && !dirname.startsWith('.');
return !hasWildcards && !hasExtension;
};
const getDirectoryGlob = ({directoryPath, files, extensions}) => {
const extensionGlob = extensions?.length > 0 ? `.${extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]}` : '';
return files
? files.map(file => nodePath.posix.join(directoryPath, `**/${nodePath.extname(file) ? file : `${file}${extensionGlob}`}`))
: [nodePath.posix.join(directoryPath, `**${extensionGlob ? `/*${extensionGlob}` : ''}`)];
};
const directoryToGlob = async (directoryPaths, {
cwd = process.cwd(),
files,
extensions,
fs: fsImplementation,
} = {}) => {
const globs = await Promise.all(directoryPaths.map(async directoryPath => {
// Check pattern without negative prefix
const checkPattern = isNegativePattern(directoryPath) ? directoryPath.slice(1) : directoryPath;
// Expand globstar directory patterns like **/dirname to **/dirname/**
if (shouldExpandGlobstarDirectory(checkPattern)) {
return getDirectoryGlob({directoryPath, files, extensions});
}
// Original logic for checking actual directories
const pathToCheck = normalizePathForDirectoryGlob(directoryPath, cwd);
return (await isDirectory(pathToCheck, fsImplementation)) ? getDirectoryGlob({directoryPath, files, extensions}) : directoryPath;
}));
return globs.flat();
};
const directoryToGlobSync = (directoryPaths, {
cwd = process.cwd(),
files,
extensions,
fs: fsImplementation,
} = {}) => directoryPaths.flatMap(directoryPath => {
// Check pattern without negative prefix
const checkPattern = isNegativePattern(directoryPath) ? directoryPath.slice(1) : directoryPath;
// Expand globstar directory patterns like **/dirname to **/dirname/**
if (shouldExpandGlobstarDirectory(checkPattern)) {
return getDirectoryGlob({directoryPath, files, extensions});
}
// Original logic for checking actual directories
const pathToCheck = normalizePathForDirectoryGlob(directoryPath, cwd);
return isDirectorySync(pathToCheck, fsImplementation) ? getDirectoryGlob({directoryPath, files, extensions}) : directoryPath;
});
const toPatternsArray = patterns => {
patterns = [...new Set([patterns].flat())];
assertPatternsInput(patterns);
return patterns;
};
const checkCwdOption = (cwd, fsImplementation = fs) => {
if (!cwd || !fsImplementation.statSync) {
return;
}
let stats;
try {
stats = fsImplementation.statSync(cwd);
} catch {
// If stat fails (e.g., path doesn't exist), let fast-glob handle it
return;
}
if (!stats.isDirectory()) {
throw new Error(`The \`cwd\` option must be a path to a directory, got: ${cwd}`);
}
};
const normalizeOptions = (options = {}) => {
// Normalize ignore to an array (fast-glob accepts string but we need array internally)
const ignore = options.ignore
? (Array.isArray(options.ignore) ? options.ignore : [options.ignore])
: [];
options = {
...options,
ignore,
expandDirectories: options.expandDirectories ?? true,
cwd: toPath(options.cwd),
};
checkCwdOption(options.cwd, options.fs);
return options;
};
const normalizeArguments = function_ => async (patterns, options) => function_(toPatternsArray(patterns), normalizeOptions(options));
const normalizeArgumentsSync = function_ => (patterns, options) => function_(toPatternsArray(patterns), normalizeOptions(options));
const getIgnoreFilesPatterns = options => {
const {ignoreFiles, gitignore} = options;
const patterns = ignoreFiles ? toPatternsArray(ignoreFiles) : [];
if (gitignore) {
patterns.push(GITIGNORE_FILES_PATTERN);
}
return patterns;
};
const isPathIgnored = (matcher, globalMatcher, path) => {
const globalResult = globalMatcher ? globalMatcher(path) : undefined;
const result = matcher ? matcher(path) : undefined;
if (result?.unignored) {
return false;
}
return Boolean(result?.ignored || globalResult?.ignored);
};
const hasIgnoredAncestorDirectory = (matcher, globalMatcher, file) => {
let currentPath = file;
while (true) {
const parentDirectory = nodePath.dirname(currentPath);
if (parentDirectory === currentPath) {
return false;
}
if (isPathIgnored(matcher, globalMatcher, `${parentDirectory}${nodePath.sep}`)) {
return true;
}
currentPath = parentDirectory;
}
};
const combinePredicate = (matcher, globalMatcher) => {
if (!matcher && !globalMatcher) {
return false;
}
return file => {
const result = matcher ? matcher(file) : undefined;
// A local negation (e.g. `!file`) re-includes the file, unless
// a parent directory is ignored by either matcher.
if (result?.unignored) {
const globalResult = globalMatcher ? globalMatcher(file) : undefined;
return globalResult?.ignored && hasIgnoredAncestorDirectory(matcher, globalMatcher, file);
}
return isPathIgnored(matcher, globalMatcher, file);
};
};
const buildIgnoreFilterResult = (options, cwd, {patterns, matcher, usingGitRoot}, globalMatcher, createFilter) => {
const finalPredicate = combinePredicate(matcher, globalMatcher);
// Convert patterns to fast-glob format (may return empty array if predicate should handle everything)
const patternsForFastGlob = convertPatternsForFastGlob(patterns, usingGitRoot, normalizeDirectoryPatternForFastGlob);
return {
options: {
...options,
ignore: [...options.ignore, ...patternsForFastGlob],
},
filter: createFilter(finalPredicate, cwd, options.fs),
};
};
/**
Apply gitignore patterns to options and return filter predicate.
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.
When there are no negations, we optimize by passing patterns to fast-glob's ignore option to skip directories during traversal (performance optimization).
All patterns (including negated) are always used in the filter predicate to ensure correct Git-compatible behavior.
@returns {Promise<{options: Object, filter: Function}>}
*/
const applyIgnoreFilesAndGetFilter = async options => {
const cwd = options.cwd ?? process.cwd();
const ignoreFilesPatterns = getIgnoreFilesPatterns(options);
const globalIgnoreFile = options.globalGitignore ? await getGlobalGitignoreFileAsync(options) : undefined;
if (ignoreFilesPatterns.length === 0 && !globalIgnoreFile) {
return {
options,
filter: createFilterFunctionAsync(false, cwd, options.fs),
};
}
// Read ignore files once and get both patterns and predicate
// Enable parent .gitignore search when using gitignore option
const includeParentIgnoreFiles = options.gitignore === true;
const ignoreResult = ignoreFilesPatterns.length > 0
? await getIgnorePatternsAndPredicate(ignoreFilesPatterns, options, includeParentIgnoreFiles)
: {patterns: [], matcher: false, usingGitRoot: false};
const globalGitRoot = globalIgnoreFile ? await findGitRoot(cwd, options.fs) : undefined;
const globalMatcher = globalIgnoreFile ? buildGlobalMatcher(globalIgnoreFile, cwd, globalGitRoot ?? cwd) : undefined;
return buildIgnoreFilterResult(options, cwd, ignoreResult, globalMatcher, createFilterFunctionAsync);
};
/**
Apply gitignore patterns to options and return filter predicate (sync version).
@returns {{options: Object, filter: Function}}
*/
const applyIgnoreFilesAndGetFilterSync = options => {
const cwd = options.cwd ?? process.cwd();
const ignoreFilesPatterns = getIgnoreFilesPatterns(options);
const globalIgnoreFile = options.globalGitignore ? getGlobalGitignoreFile(options) : undefined;
if (ignoreFilesPatterns.length === 0 && !globalIgnoreFile) {
return {
options,
filter: createFilterFunction(false, cwd, options.fs),
};
}
// Read ignore files once and get both patterns and predicate
// Enable parent .gitignore search when using gitignore option
const includeParentIgnoreFiles = options.gitignore === true;
const ignoreResult = ignoreFilesPatterns.length > 0
? getIgnorePatternsAndPredicateSync(ignoreFilesPatterns, options, includeParentIgnoreFiles)
: {patterns: [], matcher: false, usingGitRoot: false};
const globalGitRoot = globalIgnoreFile ? findGitRootSync(cwd, options.fs) : undefined;
const globalMatcher = globalIgnoreFile ? buildGlobalMatcher(globalIgnoreFile, cwd, globalGitRoot ?? cwd) : undefined;
return buildIgnoreFilterResult(options, cwd, ignoreResult, globalMatcher, createFilterFunction);
};
const assertGlobalGitignoreSyncSupport = options => {
if (options.globalGitignore && options.fs && !options.fs.statSync) {
throw new Error('The `globalGitignore` option in `globbySync()` requires `fs.statSync` when a custom `fs` is provided.');
}
};
const globalGitignoreAsyncStatErrorMessage = 'The `globalGitignore` option in `globby()` and `globbyStream()` requires `fs.promises.stat` or `fs.stat` when a custom `fs` is provided.';
const assertGlobalGitignoreAsyncSupport = options => {
if (!options.globalGitignore || !options.fs) {
return;
}
if (!options.fs.promises?.stat && !options.fs.stat) {
throw new Error(globalGitignoreAsyncStatErrorMessage);
}
};
const createPathResolver = cwd => {
const basePath = cwd || process.cwd();
const pathCache = new Map();
return pathKey => {
let absolutePath = pathCache.get(pathKey);
if (absolutePath === undefined) {
if (pathCache.size > 10_000) {
pathCache.clear();
}
absolutePath = nodePath.isAbsolute(pathKey) ? pathKey : nodePath.resolve(basePath, pathKey);
pathCache.set(pathKey, absolutePath);
}
return absolutePath;
};
};
const createAsyncDirectoryCheck = fsMethod => {
const directoryCache = new Map();
return async absolutePath => {
let isDirectory = directoryCache.get(absolutePath);
if (isDirectory !== undefined) {
return isDirectory;
}
try {
const stats = await fsMethod?.(absolutePath);
isDirectory = Boolean(stats?.isDirectory());
} catch {
isDirectory = false;
}
if (directoryCache.size > 10_000) {
directoryCache.clear();
}
directoryCache.set(absolutePath, isDirectory);
return isDirectory;
};
};
const createDirectoryCheck = fsMethod => {
const directoryCache = new Map();
return absolutePath => {
let isDirectory = directoryCache.get(absolutePath);
if (isDirectory !== undefined) {
return isDirectory;
}
try {
isDirectory = Boolean(fsMethod?.(absolutePath)?.isDirectory());
} catch {
isDirectory = false;
}
if (directoryCache.size > 10_000) {
directoryCache.clear();
}
directoryCache.set(absolutePath, isDirectory);
return isDirectory;
};
};
const createFilterFunctionAsync = (isIgnored, cwd, fsImplementation) => {
const resolveAbsolutePath = createPathResolver(cwd);
const isDirectoryEntry = createAsyncDirectoryCheck(getStatMethod(fsImplementation));
return async fastGlobResult => {
if (!isIgnored) {
return true;
}
const absolutePath = resolveAbsolutePath(nodePath.normalize(fastGlobResult.path ?? fastGlobResult));
if (isIgnored(absolutePath)) {
return false;
}
return !(await isDirectoryEntry(absolutePath) && isIgnored(`${absolutePath}${nodePath.sep}`));
};
};
const createFilterFunction = (isIgnored, cwd, fsImplementation) => {
const seen = new Set();
const resolveAbsolutePath = createPathResolver(cwd);
const isDirectoryEntry = createDirectoryCheck(getStatSyncMethod(fsImplementation));
return fastGlobResult => {
const pathKey = nodePath.normalize(fastGlobResult.path ?? fastGlobResult);
if (seen.has(pathKey)) {
return false;
}
if (isIgnored) {
const absolutePath = resolveAbsolutePath(pathKey);
if (isIgnored(absolutePath)) {
return false;
}
if (isDirectoryEntry(absolutePath) && isIgnored(`${absolutePath}${nodePath.sep}`)) {
return false;
}
}
seen.add(pathKey);
return true;
};
};
const unionFastGlobResults = (results, filter) => results.flat().filter(fastGlobResult => filter(fastGlobResult));
const unionFastGlobResultsAsync = async (results, filter) => {
results = results.flat();
const matches = await Promise.all(results.map(fastGlobResult => filter(fastGlobResult)));
const seen = new Set();
return results.filter((fastGlobResult, index) => {
if (!matches[index]) {
return false;
}
const pathKey = nodePath.normalize(fastGlobResult.path ?? fastGlobResult);
if (seen.has(pathKey)) {
return false;
}
seen.add(pathKey);
return true;
});
};
const convertNegativePatterns = (patterns, options) => {
// If all patterns are negative and expandNegationOnlyPatterns is enabled (default),
// prepend a positive catch-all pattern to make negation-only patterns work intuitively
// (e.g., '!*.json' matches all files except JSON)
if (patterns.length > 0 && patterns.every(pattern => isNegativePattern(pattern))) {
if (options.expandNegationOnlyPatterns === false) {
return [];
}
patterns = ['**/*', ...patterns];
}
const positiveAbsolutePathPrefixes = [];
let hasRelativePositivePattern = false;
const normalizedPatterns = [];
for (const pattern of patterns) {
if (isNegativePattern(pattern)) {
normalizedPatterns.push(`!${normalizeNegativePattern(pattern.slice(1), positiveAbsolutePathPrefixes, hasRelativePositivePattern)}`);
continue;
}
normalizedPatterns.push(pattern);
const staticAbsolutePathPrefix = getStaticAbsolutePathPrefix(pattern);
if (staticAbsolutePathPrefix === undefined) {
hasRelativePositivePattern = true;
continue;
}
positiveAbsolutePathPrefixes.push(staticAbsolutePathPrefix);
}
patterns = normalizedPatterns;
const tasks = [];
while (patterns.length > 0) {
const index = patterns.findIndex(pattern => isNegativePattern(pattern));
if (index === -1) {
tasks.push({patterns, options});
break;
}
const ignorePattern = patterns[index].slice(1);
for (const task of tasks) {
task.options.ignore.push(ignorePattern);
}
if (index !== 0) {
tasks.push({
patterns: patterns.slice(0, index),
options: {
...options,
ignore: [
...options.ignore,
ignorePattern,
],
},
});
}
patterns = patterns.slice(index + 1);
}
return tasks;
};
const applyParentDirectoryIgnoreAdjustments = tasks => tasks.map(task => ({
patterns: task.patterns,
options: {
...task.options,
ignore: adjustIgnorePatternsForParentDirectories(task.patterns, task.options.ignore),
},
}));
const normalizeExpandDirectoriesOption = (options, cwd) => ({
...(cwd ? {cwd} : {}),
...(Array.isArray(options) ? {files: options} : options),
});
const generateTasks = async (patterns, options) => {
const globTasks = convertNegativePatterns(patterns, options);
const {cwd, expandDirectories, fs: fsImplementation} = options;
if (!expandDirectories) {
return applyParentDirectoryIgnoreAdjustments(globTasks);
}
const directoryToGlobOptions = {
...normalizeExpandDirectoriesOption(expandDirectories, cwd),
fs: fsImplementation,
};
return Promise.all(globTasks.map(async task => {
let {patterns, options} = task;
[
patterns,
options.ignore,
] = await Promise.all([
directoryToGlob(patterns, directoryToGlobOptions),
directoryToGlob(options.ignore, {cwd, fs: fsImplementation}),
]);
// Adjust ignore patterns for parent directory references
options.ignore = adjustIgnorePatternsForParentDirectories(patterns, options.ignore);
return {patterns, options};
}));
};
const generateTasksSync = (patterns, options) => {
const globTasks = convertNegativePatterns(patterns, options);
const {cwd, expandDirectories, fs: fsImplementation} = options;
if (!expandDirectories) {
return applyParentDirectoryIgnoreAdjustments(globTasks);
}
const directoryToGlobSyncOptions = {
...normalizeExpandDirectoriesOption(expandDirectories, cwd),
fs: fsImplementation,
};
return globTasks.map(task => {
let {patterns, options} = task;
patterns = directoryToGlobSync(patterns, directoryToGlobSyncOptions);
options.ignore = directoryToGlobSync(options.ignore, {cwd, fs: fsImplementation});
// Adjust ignore patterns for parent directory references
options.ignore = adjustIgnorePatternsForParentDirectories(patterns, options.ignore);
return {patterns, options};
});
};
export const globby = normalizeArguments(async (patterns, options) => {
assertGlobalGitignoreAsyncSupport(options);
// Apply ignore files and get filter (reads .gitignore files once)
const {options: modifiedOptions, filter} = await applyIgnoreFilesAndGetFilter(options);
// Generate tasks with modified options (includes gitignore patterns in ignore option)
const tasks = await generateTasks(patterns, modifiedOptions);
const results = await Promise.all(tasks.map(task => fastGlob(task.patterns, task.options)));
return unionFastGlobResultsAsync(results, filter);
});
export const globbySync = normalizeArgumentsSync((patterns, options) => {
assertGlobalGitignoreSyncSupport(options);
// Apply ignore files and get filter (reads .gitignore files once)
const {options: modifiedOptions, filter} = applyIgnoreFilesAndGetFilterSync(options);
// Generate tasks with modified options (includes gitignore patterns in ignore option)
const tasks = generateTasksSync(patterns, modifiedOptions);
const results = tasks.map(task => fastGlob.sync(task.patterns, task.options));
return unionFastGlobResults(results, filter);
});
export const globbyStream = normalizeArgumentsSync((patterns, options) => {
assertGlobalGitignoreAsyncSupport(options);
const seen = new Set();
const stream = Readable.from((async function * () {
// Apply ignore files and get filter (reads .gitignore files once)
const {options: modifiedOptions, filter} = await applyIgnoreFilesAndGetFilter(options);
// Generate tasks with modified options (includes gitignore patterns in ignore option)
const tasks = await generateTasks(patterns, modifiedOptions);
if (tasks.length === 0) {
return;
}
const streams = tasks.map(task => fastGlob.stream(task.patterns, task.options));
for await (const fastGlobResult of mergeStreams(streams)) {
const pathKey = nodePath.normalize(fastGlobResult.path ?? fastGlobResult);
if (!seen.has(pathKey) && await filter(fastGlobResult)) {
seen.add(pathKey);
yield fastGlobResult;
}
}
})());
// Returning a web stream will require revisiting once Readable.toWeb integration is viable.
// return Readable.toWeb(stream);
return stream;
});
export const isDynamicPattern = normalizeArgumentsSync((patterns, options) => patterns.some(pattern => fastGlob.isDynamicPattern(pattern, options)));
export const generateGlobTasks = normalizeArguments(generateTasks);
export const generateGlobTasksSync = normalizeArgumentsSync(generateTasksSync);
export {
isGitIgnored,
isGitIgnoredSync,
isIgnoredByIgnoreFiles,
isIgnoredByIgnoreFilesSync,
} from './ignore.js';
export const {convertPathToPattern} = fastGlob;
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+94
View File
@@ -0,0 +1,94 @@
{
"name": "globby",
"version": "16.2.0",
"description": "User-friendly glob matching",
"license": "MIT",
"repository": "sindresorhus/globby",
"funding": "https://github.com/sponsors/sindresorhus",
"author": {
"email": "sindresorhus@gmail.com",
"name": "Sindre Sorhus",
"url": "https://sindresorhus.com"
},
"type": "module",
"exports": {
"types": "./index.d.ts",
"default": "./index.js"
},
"sideEffects": false,
"engines": {
"node": ">=20"
},
"scripts": {
"bench": "npm update @globby/main-branch glob-stream fast-glob && node bench.js",
"test": "xo && ava && tsd"
},
"files": [
"index.js",
"index.d.ts",
"ignore.js",
"utilities.js"
],
"keywords": [
"all",
"array",
"directories",
"expand",
"files",
"filesystem",
"filter",
"find",
"fnmatch",
"folders",
"fs",
"glob",
"globbing",
"globs",
"gulpfriendly",
"match",
"matcher",
"minimatch",
"multi",
"multiple",
"paths",
"pattern",
"patterns",
"traverse",
"util",
"utility",
"wildcard",
"wildcards",
"promise",
"gitignore",
"git"
],
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
"fast-glob": "^3.3.3",
"ignore": "^7.0.5",
"is-path-inside": "^4.0.0",
"slash": "^5.1.0",
"unicorn-magic": "^0.4.0"
},
"devDependencies": {
"@globby/main-branch": "sindresorhus/globby#main",
"@types/node": "^24.5.2",
"ava": "^6.4.1",
"benchmark": "2.1.4",
"glob-stream": "^8.0.3",
"tempy": "^3.1.0",
"tsd": "^0.33.0",
"xo": "^1.2.2"
},
"xo": {
"ignores": [
"fixtures"
]
},
"ava": {
"files": [
"!tests/utilities.js"
],
"workerThreads": false
}
}
+401
View File
@@ -0,0 +1,401 @@
# globby
> User-friendly glob matching
Based on [`fast-glob`](https://github.com/mrmlnc/fast-glob) but adds a bunch of useful features.
## Features
- Promise API
- Multiple patterns
- Negated patterns: `['foo*', '!foobar']`
- Negation-only patterns: `['!foobar']` → matches all files except `foobar`
- Expands directories: `foo``foo/**/*`
- Supports `.gitignore` and similar ignore config files
- Supports `URL` as `cwd`
## Install
```sh
npm install globby
```
## Usage
```
├── unicorn
├── cake
└── rainbow
```
```js
import {globby} from 'globby';
const paths = await globby(['*', '!cake']);
console.log(paths);
//=> ['unicorn', 'rainbow']
```
## API
Note that glob patterns can only contain forward-slashes, not backward-slashes, so if you want to construct a glob pattern from path components, you need to use `path.posix.join()` instead of `path.join()`.
**Windows:** Patterns with backslashes will silently fail. Use `path.posix.join()` or [`convertPathToPattern()`](#convertpathtopatternpath).
### globby(patterns, options?)
Returns a `Promise<string[]>` of matching paths.
#### patterns
Type: `string | string[]`
See supported `minimatch` [patterns](https://github.com/isaacs/minimatch#usage).
#### options
Type: `object`
See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones below.
##### expandDirectories
Type: `boolean | string[] | object`\
Default: `true`
If set to `true`, `globby` will automatically glob directories for you. If you define an `Array` it will only glob files that matches the patterns inside the `Array`. You can also define an `object` with `files` and `extensions` like below:
```js
import {globby} from 'globby';
const paths = await globby('images', {
expandDirectories: {
files: ['cat', 'unicorn', '*.jpg'],
extensions: ['png']
}
});
console.log(paths);
//=> ['cat.png', 'unicorn.png', 'cow.jpg', 'rainbow.jpg']
```
Note that if you set this option to `false`, you won't get back matched directories unless you set `onlyFiles: false`.
##### gitignore
Type: `boolean`\
Default: `false`
Respect ignore patterns in `.gitignore` files that apply to the globbed files.
When enabled, globby searches for `.gitignore` files from the current working directory downward, and if a Git repository is detected (by finding a `.git` directory), it also respects `.gitignore` files in parent directories up to the repository root. This matches Git's actual behavior where patterns from parent `.gitignore` files apply to subdirectories.
Gitignore patterns take priority over user patterns, matching Git's behavior. To include gitignored files, set this to `false`.
**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.
##### globalGitignore
Type: `boolean`\
Default: `false`
Respect ignore patterns in the global gitignore file configured via `git config core.excludesfile`.
Values from `[include]` and `gitdir` or `gitdir/i` `[includeIf]` sections inside those user-level config files are also respected.
Patterns in the global gitignore are treated as root-level patterns, matching Git's own behavior.
This option only reads the user-level Git config (`GIT_CONFIG_GLOBAL`, `$XDG_CONFIG_HOME/git/config`, and `~/.gitconfig`). When `core.excludesfile` is unset, it falls back to Git's default user-level ignore file at `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore`. Repository `.git/config` and system config are intentionally not consulted. Other `includeIf` predicates such as `onbranch:` are intentionally not supported.
##### ignoreFiles
Type: `string | string[]`\
Default: `undefined`
Glob patterns to look for ignore files, which are then used to ignore globbed files.
This is a more generic form of the `gitignore` option, allowing you to find ignore files with a [compatible syntax](http://git-scm.com/docs/gitignore). For instance, this works with Babel's `.babelignore`, Prettier's `.prettierignore`, or ESLint's `.eslintignore` files.
**Performance tip:** Using a specific path like `'.gitignore'` is much faster than recursive patterns.
##### expandNegationOnlyPatterns
Type: `boolean`\
Default: `true`
When only negation patterns are provided (e.g., `['!*.json']`), automatically prepend a catch-all pattern (`**/*`) to match all files before applying negations.
Set to `false` to return an empty array when only negation patterns are provided. This can be useful when patterns are user-controlled, to avoid unexpectedly matching all files.
```js
import {globby} from 'globby';
// Default behavior: matches all files except .json
await globby(['!*.json']);
//=> ['file.txt', 'image.png', ...]
// Disable expansion: returns empty array
await globby(['!*.json'], {expandNegationOnlyPatterns: false});
//=> []
```
##### fs
Type: [`FileSystemAdapter`](https://github.com/mrmlnc/fast-glob#fs)\
Default: `undefined`
Custom file system implementation (useful for testing or virtual file systems).
**Note:** When using `gitignore`, `ignoreFiles`, or `globalGitignore`, the custom fs must also provide `readFile`/`readFileSync` methods. With `globalGitignore`, `globby()` and `globbyStream()` also require `fs.promises.stat` or `fs.stat`, and `globbySync()` requires `fs.statSync`.
### globbySync(patterns, options?)
Returns `string[]` of matching paths.
### globbyStream(patterns, options?)
Returns a [`stream.Readable`](https://nodejs.org/api/stream.html#stream_readable_streams) of matching paths.
For example, loop over glob matches in a [`for await...of` loop](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of) like this:
```js
import {globbyStream} from 'globby';
for await (const path of globbyStream('*.tmp')) {
console.log(path);
}
```
### convertPathToPattern(path)
Converts a path to a pattern by escaping special glob characters like `()`, `[]`, `{}`. On Windows, also converts backslashes to forward slashes.
Use this when your literal paths contain characters with special meaning in globs.
```js
import {globby, convertPathToPattern} from 'globby';
// ❌ Fails - parentheses are glob syntax
await globby('C:/Program Files (x86)/*.txt');
//=> []
// ✅ Works
const base = convertPathToPattern('C:/Program Files (x86)');
await globby(`${base}/*.txt`);
//=> ['C:/Program Files (x86)/file.txt']
```
[Learn more.](https://github.com/mrmlnc/fast-glob#convertpathtopatternpath)
### generateGlobTasks(patterns, options?)
Returns an `Promise<object[]>` in the format `{patterns: string[], options: Object}`, which can be passed as arguments to [`fast-glob`](https://github.com/mrmlnc/fast-glob). This is useful for other globbing-related packages.
Note that you should avoid running the same tasks multiple times as they contain a file system cache. Instead, run this method each time to ensure file system changes are taken into consideration.
### generateGlobTasksSync(patterns, options?)
Returns an `object[]` in the format `{patterns: string[], options: Object}`, which can be passed as arguments to [`fast-glob`](https://github.com/mrmlnc/fast-glob). This is useful for other globbing-related packages.
Takes the same arguments as `generateGlobTasks`.
### isDynamicPattern(patterns, options?)
Returns a `boolean` of whether there are any special glob characters in the `patterns`.
Note that the options affect the results.
This function is backed by [`fast-glob`](https://github.com/mrmlnc/fast-glob#isdynamicpatternpattern-options).
### isGitIgnored(options?)
Returns a `Promise<(path: URL | string) => boolean>` indicating whether a given path is ignored via a `.gitignore` file.
#### options
Type: `object`
##### cwd
Type: `URL | string`\
Default: `process.cwd()`
The current working directory in which to search.
##### suppressErrors
Type: `boolean`\
Default: `false`
Suppress errors when encountering directories or files without read permissions.
##### deep
Type: `number`\
Default: `Infinity`
Maximum depth to search for `.gitignore` files.
- `0` - Only search in the start directory
- `1` - Search in the start directory and one level of subdirectories
- `2` - Search in the start directory and two levels of subdirectories
##### ignore
Type: `string | string[]`\
Default: `[]`
Glob patterns to exclude from `.gitignore` file search.
##### followSymbolicLinks
Type: `boolean`\
Default: `true`
Indicates whether to traverse descendants of symbolic link directories.
##### concurrency
Type: `number`\
Default: `os.cpus().length`
Specifies the maximum number of concurrent requests from a reader to read directories.
##### throwErrorOnBrokenSymbolicLink
Type: `boolean`\
Default: `false`
Throw an error when symbolic link is broken if `true` or safely return `lstat` call if `false`.
##### fs
Type: [`FileSystemAdapter`](https://github.com/mrmlnc/fast-glob#fs)\
Default: `undefined`
Custom file system implementation (useful for testing or virtual file systems).
**Note:** The custom fs must provide `readFile`/`readFileSync` methods for reading `.gitignore` files.
```js
import {isGitIgnored} from 'globby';
const isIgnored = await isGitIgnored();
console.log(isIgnored('some/file'));
```
```js
// Suppress errors when encountering unreadable directories
const isIgnored = await isGitIgnored({suppressErrors: true});
```
```js
// Limit search depth and exclude certain directories
const isIgnored = await isGitIgnored({
deep: 2,
ignore: ['**/node_modules/**', '**/dist/**']
});
```
### isGitIgnoredSync(options?)
Returns a `(path: URL | string) => boolean` indicating whether a given path is ignored via a `.gitignore` file.
See [`isGitIgnored`](#isgitignoredoptions) for options.
### isIgnoredByIgnoreFiles(patterns, options?)
Returns a `Promise<(path: URL | string) => boolean>` indicating whether a given path is ignored via the ignore files.
This is a more generic form of the `isGitIgnored` function, allowing you to find ignore files with a [compatible syntax](http://git-scm.com/docs/gitignore). For instance, this works with Babel's `.babelignore`, Prettier's `.prettierignore`, or ESLint's `.eslintignore` files.
#### patterns
Type: `string | string[]`
Glob patterns to look for ignore files.
#### options
Type: `object`
See [`isGitIgnored` options](#isgitignoredoptions) for all available options.
```js
import {isIgnoredByIgnoreFiles} from 'globby';
const isIgnored = await isIgnoredByIgnoreFiles("**/.gitignore");
console.log(isIgnored('some/file'));
```
```js
// Suppress errors when encountering unreadable directories
const isIgnored = await isIgnoredByIgnoreFiles("**/.eslintignore", {suppressErrors: true});
```
```js
// Limit search depth and concurrency
const isIgnored = await isIgnoredByIgnoreFiles("**/.prettierignore", {
deep: 3,
concurrency: 4
});
```
### isIgnoredByIgnoreFilesSync(patterns, options?)
Returns a `(path: URL | string) => boolean` indicating whether a given path is ignored via the ignore files.
This is a more generic form of the `isGitIgnoredSync` function, allowing you to find ignore files with a [compatible syntax](http://git-scm.com/docs/gitignore). For instance, this works with Babel's `.babelignore`, Prettier's `.prettierignore`, or ESLint's `.eslintignore` files.
See [`isIgnoredByIgnoreFiles`](#isignoredbyignorefilespatterns-options) for patterns and options.
```js
import {isIgnoredByIgnoreFilesSync} from 'globby';
const isIgnored = isIgnoredByIgnoreFilesSync("**/.gitignore");
console.log(isIgnored('some/file'));
```
## Globbing patterns
Just a quick overview.
- `*` matches any number of characters, but not `/`
- `?` matches a single character, but not `/`
- `**` matches any number of characters, including `/`, as long as it's the only thing in a path part
- `{}` allows for a comma-separated list of "or" expressions
- `!` at the beginning of a pattern will negate the match
### Negation patterns
Globby supports negation patterns to exclude files. There are two ways to use them:
**With positive patterns:**
```js
await globby(['src/**/*.js', '!src/**/*.test.js']);
// Matches all .js files except test files
```
**Negation-only patterns:**
```js
await globby(['!*.json', '!*.xml'], {cwd: 'config'});
// Matches all files in config/ except .json and .xml files
```
When using only negation patterns, globby implicitly prepends `**/*` to match all files, then applies the negations. This means `['!*.json', '!*.xml']` is equivalent to `['**/*', '!*.json', '!*.xml']`.
**Note:** The prepended `**/*` pattern respects the `dot` option. By default, dotfiles (files starting with `.`) are not matched unless you set `dot: true`.
[Various patterns and expected matches.](https://github.com/sindresorhus/multimatch/blob/main/test/test.js)
## Related
- [multimatch](https://github.com/sindresorhus/multimatch) - Match against a list instead of the filesystem
- [matcher](https://github.com/sindresorhus/matcher) - Simple wildcard matching
- [del](https://github.com/sindresorhus/del) - Delete files and directories
- [make-dir](https://github.com/sindresorhus/make-dir) - Make a directory and its parents if needed
+382
View File
@@ -0,0 +1,382 @@
import fs from 'node:fs';
import path from 'node:path';
import {promisify} from 'node:util';
import fastGlob from 'fast-glob';
import isPathInside from 'is-path-inside';
export const isNegativePattern = pattern => pattern[0] === '!';
/**
Normalize a root-anchored pattern to be relative.
On Unix, patterns starting with `/` are interpreted as absolute paths from the filesystem root. This causes inconsistent behavior across platforms since Windows uses different path roots (like `C:\`).
This function strips leading `/` only for root-anchored glob patterns (e.g., `/**`, `/*.txt`, `/foo`), not for real absolute filesystem paths (e.g., `/Users/foo/bar`, `/home/user/project`).
The heuristic: if the pattern has multiple path segments and the first segment contains no glob characters, it's treated as a real absolute path and left unchanged.
@param {string} pattern - The pattern to normalize.
*/
export const normalizeAbsolutePatternToRelative = pattern => {
if (!pattern.startsWith('/')) {
return pattern;
}
const inner = pattern.slice(1);
const firstSlashIndex = inner.indexOf('/');
const firstSegment = firstSlashIndex > 0 ? inner.slice(0, firstSlashIndex) : inner;
// Preserve real absolute paths (multi-segment, non-glob first component like /Users/foo/bar)
if (firstSlashIndex > 0 && !fastGlob.isDynamicPattern(firstSegment)) {
return pattern;
}
// Strip leading / from root-anchored globs (/**, /*.txt, /foo, /{src,dist}/**)
return inner;
};
const absolutePrefixesMatch = (positivePrefix, negativePrefix) => negativePrefix === positivePrefix;
/**
Get the leading static prefix from an absolute pattern.
@param {string} pattern - The pattern to inspect.
@returns {string|undefined} Static absolute prefix, for example `/tmp/project`.
*/
export const getStaticAbsolutePathPrefix = pattern => {
if (!path.isAbsolute(pattern)) {
return undefined;
}
const staticSegments = [];
for (const segment of pattern.split('/')) {
if (!segment) {
continue;
}
if (fastGlob.isDynamicPattern(segment)) {
break;
}
staticSegments.push(segment);
}
return staticSegments.length === 0 ? undefined : `/${staticSegments.join('/')}`;
};
/**
Normalize a negative pattern while preserving true absolute paths when needed.
@param {string} pattern - A negative pattern without the leading `!`.
@param {string[]} [positiveAbsolutePathPrefixes] - Static prefixes from previous positive absolute patterns.
@param {boolean} [hasRelativePositivePattern] - Whether a relative positive pattern has been seen before this negation.
@returns {string} Normalized pattern.
*/
export const normalizeNegativePattern = (pattern, positiveAbsolutePathPrefixes = [], hasRelativePositivePattern = false) => {
// Non-absolute patterns pass through unchanged.
if (!pattern.startsWith('/')) {
return pattern;
}
const normalizedPattern = normalizeAbsolutePatternToRelative(pattern);
// Dynamic root-anchored patterns (e.g. `/{src,dist}/**`) are always normalized to relative.
if (normalizedPattern !== pattern) {
return normalizedPattern;
}
// In mixed relative/absolute pattern sets, keep root-anchored literals cwd-relative.
if (hasRelativePositivePattern) {
return pattern.slice(1);
}
// Literal absolute patterns are treated as cwd-relative unless they clearly target
// the same absolute filesystem area as a positive absolute pattern seen so far.
const negativeAbsolutePathPrefix = getStaticAbsolutePathPrefix(pattern);
const preserveAsAbsolutePattern = negativeAbsolutePathPrefix !== undefined
&& positiveAbsolutePathPrefixes.some(positiveAbsolutePathPrefix => absolutePrefixesMatch(positiveAbsolutePathPrefix, negativeAbsolutePathPrefix));
return preserveAsAbsolutePattern ? pattern : pattern.slice(1);
};
export const bindFsMethod = (object, methodName) => {
const method = object?.[methodName];
return typeof method === 'function' ? method.bind(object) : undefined;
};
// Only used as a fallback for legacy fs implementations
export const promisifyFsMethod = (object, methodName) => {
const method = object?.[methodName];
if (typeof method !== 'function') {
return undefined;
}
return promisify(method.bind(object));
};
export const normalizeDirectoryPatternForFastGlob = pattern => {
if (!pattern.endsWith('/')) {
return pattern;
}
const trimmedPattern = pattern.replace(/\/+$/u, '');
if (!trimmedPattern) {
return '/**';
}
// Special case for '**/' to avoid producing '**/**/**'
if (trimmedPattern === '**') {
return '**/**';
}
const hasLeadingSlash = trimmedPattern.startsWith('/');
const patternBody = hasLeadingSlash ? trimmedPattern.slice(1) : trimmedPattern;
const hasInnerSlash = patternBody.includes('/');
const needsRecursivePrefix = !hasLeadingSlash && !hasInnerSlash && !trimmedPattern.startsWith('**/');
const recursivePrefix = needsRecursivePrefix ? '**/' : '';
return `${recursivePrefix}${trimmedPattern}/**`;
};
/**
Extract the parent directory prefix from a pattern (e.g., '../' or '../../').
Note: Patterns should have trailing slash after '..' (e.g., '../foo' not '..foo'). The directoryToGlob function ensures this in the normal pipeline.
@param {string} pattern - The pattern to analyze.
@returns {string} The parent directory prefix, or empty string if none.
*/
export const getParentDirectoryPrefix = pattern => {
const normalizedPattern = isNegativePattern(pattern) ? pattern.slice(1) : pattern;
const match = normalizedPattern.match(/^(\.\.\/)+/);
return match ? match[0] : '';
};
/**
Adjust ignore patterns to match the relative base of the main patterns.
When patterns reference parent directories, ignore patterns starting with globstars need to be adjusted to match from the same base directory. This ensures intuitive behavior where ignore patterns work correctly with parent directory patterns.
This is analogous to how node-glob normalizes path prefixes (see node-glob issue #309) and how Rust ignore crate strips path prefixes before matching.
@param {string[]} patterns - The main glob patterns.
@param {string[]} ignorePatterns - The ignore patterns to adjust.
@returns {string[]} Adjusted ignore patterns.
*/
export const adjustIgnorePatternsForParentDirectories = (patterns, ignorePatterns) => {
// Early exit for empty arrays
if (patterns.length === 0 || ignorePatterns.length === 0) {
return ignorePatterns;
}
// Get parent directory prefixes for all patterns (empty string if no prefix)
const parentPrefixes = patterns.map(pattern => getParentDirectoryPrefix(pattern));
// Check if all patterns have the same parent prefix
const firstPrefix = parentPrefixes[0];
if (!firstPrefix) {
return ignorePatterns; // No parent directories in any pattern
}
const allSamePrefix = parentPrefixes.every(prefix => prefix === firstPrefix);
if (!allSamePrefix) {
return ignorePatterns; // Mixed bases - don't adjust
}
// Adjust ignore patterns that start with **/
return ignorePatterns.map(pattern => {
// Only adjust patterns starting with **/ that don't already have a parent reference
if (pattern.startsWith('**/') && !pattern.startsWith('../')) {
return firstPrefix + pattern;
}
return pattern;
});
};
/**
Find the git root directory by searching upward for a .git directory.
@param {string} cwd - The directory to start searching from.
@param {Object} [fsImplementation] - Optional fs implementation.
@returns {string|undefined} The git root directory path, or undefined if not found.
*/
const getAsyncStatMethod = fsImplementation =>
bindFsMethod(fsImplementation?.promises, 'stat')
?? bindFsMethod(fs.promises, 'stat');
const getStatSyncMethod = fsImplementation => {
if (fsImplementation) {
return bindFsMethod(fsImplementation, 'statSync');
}
return bindFsMethod(fs, 'statSync');
};
const pathHasGitDirectory = stats => Boolean(stats?.isDirectory?.() || stats?.isFile?.());
const buildPathChain = (startPath, rootPath) => {
const chain = [];
let currentPath = startPath;
chain.push(currentPath);
while (currentPath !== rootPath) {
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
break;
}
currentPath = parentPath;
chain.push(currentPath);
}
return chain;
};
const findGitRootInChain = async (paths, statMethod) => {
for (const directory of paths) {
const gitPath = path.join(directory, '.git');
try {
const stats = await statMethod(gitPath); // eslint-disable-line no-await-in-loop
if (pathHasGitDirectory(stats)) {
return directory;
}
} catch {
// Ignore errors and continue searching
}
}
return undefined;
};
const findGitRootSyncUncached = (cwd, fsImplementation) => {
const statSyncMethod = getStatSyncMethod(fsImplementation);
if (!statSyncMethod) {
return undefined;
}
const currentPath = path.resolve(cwd);
const {root} = path.parse(currentPath);
const chain = buildPathChain(currentPath, root);
for (const directory of chain) {
const gitPath = path.join(directory, '.git');
try {
const stats = statSyncMethod(gitPath);
if (pathHasGitDirectory(stats)) {
return directory;
}
} catch {
// Ignore errors and continue searching
}
}
return undefined;
};
export const findGitRootSync = (cwd, fsImplementation) => {
if (typeof cwd !== 'string') {
throw new TypeError('cwd must be a string');
}
return findGitRootSyncUncached(cwd, fsImplementation);
};
const findGitRootAsyncUncached = async (cwd, fsImplementation) => {
const statMethod = getAsyncStatMethod(fsImplementation);
if (!statMethod) {
return findGitRootSync(cwd, fsImplementation);
}
const currentPath = path.resolve(cwd);
const {root} = path.parse(currentPath);
const chain = buildPathChain(currentPath, root);
return findGitRootInChain(chain, statMethod);
};
export const findGitRoot = async (cwd, fsImplementation) => {
if (typeof cwd !== 'string') {
throw new TypeError('cwd must be a string');
}
return findGitRootAsyncUncached(cwd, fsImplementation);
};
/**
Get paths to all .gitignore files from git root to cwd (inclusive).
@param {string} gitRoot - The git root directory.
@param {string} cwd - The current working directory.
@returns {string[]} Array of .gitignore file paths to search for.
*/
const isWithinGitRoot = (gitRoot, cwd) => {
const resolvedGitRoot = path.resolve(gitRoot);
const resolvedCwd = path.resolve(cwd);
return resolvedCwd === resolvedGitRoot || isPathInside(resolvedCwd, resolvedGitRoot);
};
export const getParentGitignorePaths = (gitRoot, cwd) => {
if (gitRoot && typeof gitRoot !== 'string') {
throw new TypeError('gitRoot must be a string or undefined');
}
if (typeof cwd !== 'string') {
throw new TypeError('cwd must be a string');
}
// If no gitRoot provided, return empty array
if (!gitRoot) {
return [];
}
if (!isWithinGitRoot(gitRoot, cwd)) {
return [];
}
const chain = buildPathChain(path.resolve(cwd), path.resolve(gitRoot));
return [...chain]
.reverse()
.map(directory => path.join(directory, '.gitignore'));
};
/**
Convert ignore patterns to fast-glob compatible format.
Returns empty array if patterns should be handled by predicate only.
@param {string[]} patterns - Ignore patterns from .gitignore files
@param {boolean} usingGitRoot - Whether patterns are relative to git root
@param {Function} normalizeDirectoryPatternForFastGlob - Function to normalize directory patterns
@returns {string[]} Patterns safe to pass to fast-glob, or empty array
*/
export const convertPatternsForFastGlob = (patterns, usingGitRoot, normalizeDirectoryPatternForFastGlob) => {
// Determine which patterns are safe to pass to fast-glob
// If there are negation patterns, we can't pass file patterns to fast-glob
// because fast-glob doesn't understand negations and would filter out files
// that should be re-included by negation patterns.
// If we're using git root, patterns are relative to git root not cwd,
// so we can't pass them to fast-glob which expects cwd-relative patterns.
// We only pass patterns to fast-glob if there are NO negations AND we're not using git root.
if (usingGitRoot) {
return []; // Patterns are relative to git root, not cwd
}
const result = [];
let hasNegations = false;
// Single pass to check for negations and collect positive patterns
for (const pattern of patterns) {
if (isNegativePattern(pattern)) {
hasNegations = true;
break; // Early exit on first negation
}
result.push(normalizeDirectoryPatternForFastGlob(pattern));
}
return hasNegations ? [] : result;
};