├── .npmrc ├── .gitattributes ├── fixtures ├── gitignore-nested │ ├── .gitignore │ ├── .gitkeep │ └── subdir │ │ ├── deep │ │ └── nested │ │ │ └── .gitkeep │ │ └── .gitignore ├── multiple-negation │ ├── !unicorn.js │ ├── !!unicorn.js │ └── .gitignore ├── npmignore-nested │ ├── .gitkeep │ └── subdir │ │ ├── deep │ │ └── .gitkeep │ │ └── .npmignore ├── bad-permissions │ └── noread │ │ └── unreadable.js ├── gitignore-negation-nested │ ├── a1.txt │ ├── a2.txt │ ├── y │ │ ├── a1.txt │ │ ├── a2.txt │ │ ├── z │ │ │ ├── .gitkeep │ │ │ ├── a1.txt │ │ │ └── a2.txt │ │ └── .gitignore │ └── .gitignore ├── gitignore-dotslash │ ├── bar.js │ ├── foo.js │ └── .gitignore ├── gitignore │ ├── .gitignore │ └── bar.js ├── negative │ ├── .gitignore │ └── foo.js ├── unreadable-gitignore │ └── .gitignore └── ignore-files │ ├── .eslintignore │ └── .prettierignore ├── .gitignore ├── .github ├── security.md └── workflows │ └── main.yml ├── .editorconfig ├── license ├── package.json ├── bench.js ├── tests ├── utilities.js ├── convert-path-to-pattern.js ├── parent-directory-patterns.js ├── gitignore-comprehensive.js ├── generate-glob-tasks.js ├── ignore.js └── globby.js ├── index.test-d.ts ├── utilities.js ├── ignore.js ├── readme.md ├── index.d.ts └── index.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /fixtures/gitignore-nested/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/gitignore-nested/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/multiple-negation/!unicorn.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/npmignore-nested/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/multiple-negation/!!unicorn.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/bad-permissions/noread/unreadable.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/gitignore-negation-nested/a1.txt: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /fixtures/gitignore-negation-nested/a2.txt: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /fixtures/gitignore-negation-nested/y/a1.txt: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /fixtures/gitignore-negation-nested/y/a2.txt: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /fixtures/gitignore-negation-nested/y/z/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/npmignore-nested/subdir/deep/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/gitignore-dotslash/bar.js: -------------------------------------------------------------------------------- 1 | // Test file 2 | -------------------------------------------------------------------------------- /fixtures/gitignore-dotslash/foo.js: -------------------------------------------------------------------------------- 1 | // Test file 2 | -------------------------------------------------------------------------------- /fixtures/gitignore-negation-nested/.gitignore: -------------------------------------------------------------------------------- 1 | a* 2 | -------------------------------------------------------------------------------- /fixtures/gitignore-negation-nested/y/z/a1.txt: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /fixtures/gitignore-negation-nested/y/z/a2.txt: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /fixtures/gitignore-nested/subdir/deep/nested/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/gitignore/.gitignore: -------------------------------------------------------------------------------- 1 | foo.js 2 | !bar.js 3 | -------------------------------------------------------------------------------- /fixtures/negative/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !foo.js 3 | -------------------------------------------------------------------------------- /fixtures/unreadable-gitignore/.gitignore: -------------------------------------------------------------------------------- 1 | foo.js 2 | -------------------------------------------------------------------------------- /fixtures/negative/foo.js: -------------------------------------------------------------------------------- 1 | console.log('no semicolon'); 2 | -------------------------------------------------------------------------------- /fixtures/gitignore-negation-nested/y/.gitignore: -------------------------------------------------------------------------------- 1 | !a2.txt 2 | -------------------------------------------------------------------------------- /fixtures/ignore-files/.eslintignore: -------------------------------------------------------------------------------- 1 | ignored-by-eslint.js 2 | -------------------------------------------------------------------------------- /fixtures/npmignore-nested/subdir/.npmignore: -------------------------------------------------------------------------------- 1 | a.js 2 | *.log 3 | -------------------------------------------------------------------------------- /fixtures/ignore-files/.prettierignore: -------------------------------------------------------------------------------- 1 | ignored-by-prettier.js 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | bench 4 | *.tmp 5 | tmp 6 | -------------------------------------------------------------------------------- /fixtures/gitignore-dotslash/.gitignore: -------------------------------------------------------------------------------- 1 | ./foo.js 2 | ../bar.js 3 | baz.js 4 | -------------------------------------------------------------------------------- /fixtures/multiple-negation/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !!unicorn.js 3 | !!!unicorn.js 4 | -------------------------------------------------------------------------------- /fixtures/gitignore-nested/subdir/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | specific.txt 3 | deep/*.tmp 4 | temp/ -------------------------------------------------------------------------------- /fixtures/gitignore/bar.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import fn from '..'; 3 | 4 | test(t => { 5 | t.is(fn('foo'), fn('foobar')); 6 | }); 7 | -------------------------------------------------------------------------------- /.github/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} on ${{ matrix.os }} 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 24 14 | - 20 15 | os: 16 | - ubuntu-latest 17 | - macos-latest 18 | # - windows-latest 19 | steps: 20 | - uses: actions/checkout@v5 21 | - uses: actions/setup-node@v5 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm install 25 | - run: npm test 26 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "globby", 3 | "version": "16.1.0", 4 | "description": "User-friendly glob matching", 5 | "license": "MIT", 6 | "repository": "sindresorhus/globby", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "email": "sindresorhus@gmail.com", 10 | "name": "Sindre Sorhus", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./index.d.ts", 16 | "default": "./index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=20" 21 | }, 22 | "scripts": { 23 | "bench": "npm update @globby/main-branch glob-stream fast-glob && node bench.js", 24 | "test": "xo && ava && tsd" 25 | }, 26 | "files": [ 27 | "index.js", 28 | "index.d.ts", 29 | "ignore.js", 30 | "utilities.js" 31 | ], 32 | "keywords": [ 33 | "all", 34 | "array", 35 | "directories", 36 | "expand", 37 | "files", 38 | "filesystem", 39 | "filter", 40 | "find", 41 | "fnmatch", 42 | "folders", 43 | "fs", 44 | "glob", 45 | "globbing", 46 | "globs", 47 | "gulpfriendly", 48 | "match", 49 | "matcher", 50 | "minimatch", 51 | "multi", 52 | "multiple", 53 | "paths", 54 | "pattern", 55 | "patterns", 56 | "traverse", 57 | "util", 58 | "utility", 59 | "wildcard", 60 | "wildcards", 61 | "promise", 62 | "gitignore", 63 | "git" 64 | ], 65 | "dependencies": { 66 | "@sindresorhus/merge-streams": "^4.0.0", 67 | "fast-glob": "^3.3.3", 68 | "ignore": "^7.0.5", 69 | "is-path-inside": "^4.0.0", 70 | "slash": "^5.1.0", 71 | "unicorn-magic": "^0.4.0" 72 | }, 73 | "devDependencies": { 74 | "@globby/main-branch": "sindresorhus/globby#main", 75 | "@types/node": "^24.5.2", 76 | "ava": "^6.4.1", 77 | "benchmark": "2.1.4", 78 | "glob-stream": "^8.0.3", 79 | "tempy": "^3.1.0", 80 | "tsd": "^0.33.0", 81 | "xo": "^1.2.2" 82 | }, 83 | "xo": { 84 | "ignores": [ 85 | "fixtures" 86 | ] 87 | }, 88 | "ava": { 89 | "files": [ 90 | "!tests/utilities.js" 91 | ], 92 | "workerThreads": false 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /bench.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | import {fileURLToPath} from 'node:url'; 5 | import Benchmark from 'benchmark'; 6 | import * as globbyMainBranch from '@globby/main-branch'; 7 | import gs from 'glob-stream'; 8 | import fastGlob from 'fast-glob'; 9 | import {globby, globbySync, globbyStream} from './index.js'; 10 | 11 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 12 | const BENCH_DIR = 'bench'; 13 | 14 | const runners = [ 15 | { 16 | name: 'globby async (working directory)', 17 | run: globby, 18 | }, 19 | { 20 | name: 'globby async (upstream/main)', 21 | run: globbyMainBranch.globby, 22 | }, 23 | { 24 | name: 'globby sync (working directory)', 25 | run: globbySync, 26 | }, 27 | { 28 | name: 'globby sync (upstream/main)', 29 | run: globbyMainBranch.globbySync, 30 | }, 31 | { 32 | name: 'globby stream (working directory)', 33 | run: patterns => new Promise(resolve => { 34 | globbyStream(patterns).on('data', () => {}).on('end', resolve); 35 | }), 36 | }, 37 | { 38 | name: 'globby stream (upstream/main)', 39 | run: patterns => new Promise(resolve => { 40 | globbyMainBranch.globbyStream(patterns).on('data', () => {}).on('end', resolve); 41 | }), 42 | }, 43 | { 44 | name: 'glob-stream', 45 | run: patterns => new Promise(resolve => { 46 | gs(patterns).on('data', () => {}).on('end', resolve); 47 | }), 48 | }, 49 | { 50 | name: 'fast-glob async', 51 | run: fastGlob, 52 | }, 53 | { 54 | name: 'fast-glob sync', 55 | run: fastGlob.sync, 56 | }, 57 | ]; 58 | 59 | const benchs = [ 60 | { 61 | name: 'negative globs (some files inside dir)', 62 | patterns: [ 63 | 'a/*', 64 | '!a/c*', 65 | ], 66 | }, 67 | { 68 | name: 'negative globs (whole dir)', 69 | patterns: [ 70 | 'a/*', 71 | '!a/**', 72 | ], 73 | }, 74 | { 75 | name: 'multiple positive globs', 76 | patterns: [ 77 | 'a/*', 78 | 'b/*', 79 | ], 80 | }, 81 | ]; 82 | 83 | const before = () => { 84 | process.chdir(__dirname); 85 | fs.rmdirSync(BENCH_DIR, {recursive: true}); 86 | fs.mkdirSync(BENCH_DIR); 87 | process.chdir(BENCH_DIR); 88 | 89 | const directories = ['a', 'b'] 90 | .map(directory => `${directory}/`); 91 | 92 | for (const directory of directories) { 93 | fs.mkdirSync(directory); 94 | for (let i = 0; i < 500; i++) { 95 | fs.writeFileSync(directory + (i < 100 ? 'c' : 'd') + i, ''); 96 | } 97 | } 98 | }; 99 | 100 | const after = () => { 101 | process.chdir(__dirname); 102 | fs.rmdirSync(BENCH_DIR, {recursive: true}); 103 | }; 104 | 105 | const suites = []; 106 | for (const {name, patterns} of benchs) { 107 | const suite = new Benchmark.Suite(name, { 108 | onStart() { 109 | before(); 110 | 111 | console.log(`[*] Started Benchmarks "${this.name}"`); 112 | }, 113 | onCycle(event) { 114 | console.log(`[+] ${String(event.target)}`); 115 | }, 116 | onComplete() { 117 | after(); 118 | 119 | console.log(`\nFastest is ${this.filter('fastest').map('name')} \n`); 120 | }, 121 | }); 122 | 123 | for (const {name, run} of runners) { 124 | suite.add(name, run.bind(undefined, patterns)); 125 | } 126 | 127 | suites.push(suite); 128 | } 129 | 130 | let index = 0; 131 | const run = suite => { 132 | suite.on('complete', () => { 133 | const next = suites[++index]; 134 | if (next) { 135 | run(next); 136 | } 137 | }); 138 | suite.run({async: true}); 139 | }; 140 | 141 | run(suites[0]); 142 | -------------------------------------------------------------------------------- /tests/utilities.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import {fileURLToPath, pathToFileURL} from 'node:url'; 4 | import {temporaryDirectory} from 'tempy'; 5 | 6 | export const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url)); 7 | 8 | export const getPathValues = path => [path, pathToFileURL(path)]; 9 | 10 | export const createContextAwareFs = () => { 11 | const customFs = { 12 | ...fs, 13 | }; 14 | 15 | const customPromises = { 16 | ...fs.promises, 17 | readFile(...args) { 18 | if (this !== customPromises) { 19 | throw new Error('Detached promises.readFile context'); 20 | } 21 | 22 | return fs.promises.readFile(...args); 23 | }, 24 | stat(...args) { 25 | if (this !== customPromises) { 26 | throw new Error('Detached promises.stat context'); 27 | } 28 | 29 | return fs.promises.stat(...args); 30 | }, 31 | }; 32 | 33 | customFs.promises = customPromises; 34 | 35 | customFs.stat = async function (...args) { 36 | if (this !== customFs) { 37 | throw new Error('Detached stat context'); 38 | } 39 | 40 | return fs.promises.stat(...args); 41 | }; 42 | 43 | customFs.statSync = function (...args) { 44 | if (this !== customFs) { 45 | throw new Error('Detached statSync context'); 46 | } 47 | 48 | return fs.statSync(...args); 49 | }; 50 | 51 | customFs.readFileSync = function (...args) { 52 | if (this !== customFs) { 53 | throw new Error('Detached readFileSync context'); 54 | } 55 | 56 | return fs.readFileSync(...args); 57 | }; 58 | 59 | return customFs; 60 | }; 61 | 62 | export const createCountingFs = () => { 63 | const counts = new Map(); 64 | const increment = filePath => { 65 | const normalizedPath = path.resolve(filePath); 66 | counts.set(normalizedPath, (counts.get(normalizedPath) ?? 0) + 1); 67 | }; 68 | 69 | const customFs = { 70 | ...fs, 71 | }; 72 | 73 | const customPromises = { 74 | ...fs.promises, 75 | async readFile(filePath, ...args) { 76 | if (this !== customPromises) { 77 | throw new Error('Detached promises.readFile context'); 78 | } 79 | 80 | increment(filePath); 81 | return fs.promises.readFile(filePath, ...args); 82 | }, 83 | async stat(...args) { 84 | if (this !== customPromises) { 85 | throw new Error('Detached promises.stat context'); 86 | } 87 | 88 | return fs.promises.stat(...args); 89 | }, 90 | }; 91 | 92 | customFs.promises = customPromises; 93 | 94 | customFs.readFileSync = function (filePath, ...args) { 95 | if (this !== customFs) { 96 | throw new Error('Detached readFileSync context'); 97 | } 98 | 99 | increment(filePath); 100 | return fs.readFileSync(filePath, ...args); 101 | }; 102 | 103 | customFs.statSync = function (...args) { 104 | if (this !== customFs) { 105 | throw new Error('Detached statSync context'); 106 | } 107 | 108 | return fs.statSync(...args); 109 | }; 110 | 111 | return { 112 | fs: customFs, 113 | getReadCount: filePath => counts.get(path.resolve(filePath)) ?? 0, 114 | }; 115 | }; 116 | 117 | export const createTemporaryGitRepository = () => { 118 | const repository = temporaryDirectory(); 119 | fs.mkdirSync(path.join(repository, '.git')); 120 | return repository; 121 | }; 122 | 123 | export const invalidPatterns = [ 124 | {}, 125 | [{}], 126 | true, 127 | [true], 128 | false, 129 | [false], 130 | null, 131 | [null], 132 | undefined, 133 | [undefined], 134 | Number.NaN, 135 | [Number.NaN], 136 | 5, 137 | [5], 138 | function () {}, 139 | [function () {}], 140 | [['string']], 141 | ]; 142 | 143 | export const isUnique = array => new Set(array).size === array.length; 144 | -------------------------------------------------------------------------------- /tests/convert-path-to-pattern.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import test from 'ava'; 4 | import {globby, globbySync, convertPathToPattern} from '../index.js'; 5 | 6 | // Tests for convertPathToPattern() 7 | // Special glob characters like (), [], {} in literal paths must be escaped 8 | 9 | const testDirectory = 'temp-convert-path'; 10 | 11 | test.before(() => { 12 | // Create test directories with special characters 13 | const directories = [ 14 | path.join(testDirectory, 'Program Files (x86)', 'app'), 15 | path.join(testDirectory, 'Folder [a-z]', 'data'), 16 | path.join(testDirectory, 'Normal Folder', 'files'), 17 | path.join(testDirectory, 'github.com + Globby'), // Issue #81 - plus character 18 | path.join(testDirectory, 'folder {with} braces'), // Issue #81 - braces 19 | path.join(testDirectory, 'Next.js (app)', '[slug]'), // Issue #81 - Next.js patterns 20 | path.join(testDirectory, 'Next.js (app)', '[...error]'), // Issue #81 - Next.js spread patterns 21 | ]; 22 | 23 | for (const directory of directories) { 24 | fs.mkdirSync(directory, {recursive: true}); 25 | fs.writeFileSync(path.join(directory, 'test.txt'), 'content'); 26 | fs.writeFileSync(path.join(directory, 'test.js'), 'content'); 27 | } 28 | }); 29 | 30 | test.after.always(() => { 31 | if (fs.existsSync(testDirectory)) { 32 | fs.rmSync(testDirectory, {recursive: true, force: true}); 33 | } 34 | }); 35 | 36 | test('paths with parentheses fail without convertPathToPattern', t => { 37 | const directory = path.join(testDirectory, 'Program Files (x86)', 'app'); 38 | const pattern = directory.replaceAll(path.sep, '/') + '/*.txt'; 39 | 40 | // Without escaping, parentheses are interpreted as extglob syntax 41 | const result = globbySync(pattern); 42 | t.deepEqual(result, []); 43 | }); 44 | 45 | test('paths with bracket ranges fail without convertPathToPattern', t => { 46 | const directory = path.join(testDirectory, 'Folder [a-z]', 'data'); 47 | const pattern = directory.replaceAll(path.sep, '/') + '/*.txt'; 48 | 49 | // Without escaping, [a-z] is interpreted as a character class 50 | const result = globbySync(pattern); 51 | t.deepEqual(result, []); 52 | }); 53 | 54 | test('paths with parentheses work with convertPathToPattern', t => { 55 | const directory = path.join(testDirectory, 'Program Files (x86)', 'app'); 56 | const pattern = convertPathToPattern(directory) + '/*.txt'; 57 | 58 | const result = globbySync(pattern); 59 | t.is(result.length, 1); 60 | t.true(result[0].includes('test.txt')); 61 | }); 62 | 63 | test('paths with bracket ranges work with convertPathToPattern', t => { 64 | const directory = path.join(testDirectory, 'Folder [a-z]', 'data'); 65 | const pattern = convertPathToPattern(directory) + '/*.txt'; 66 | 67 | const result = globbySync(pattern); 68 | t.is(result.length, 1); 69 | t.true(result[0].includes('test.txt')); 70 | }); 71 | 72 | test('paths with only spaces work without convertPathToPattern', t => { 73 | const directory = path.join(testDirectory, 'Normal Folder', 'files'); 74 | const pattern = directory.replaceAll(path.sep, '/') + '/*.txt'; 75 | 76 | // Spaces don't need escaping in glob patterns 77 | const result = globbySync(pattern); 78 | t.is(result.length, 1); 79 | t.true(result[0].includes('test.txt')); 80 | }); 81 | 82 | test('async version works with convertPathToPattern', async t => { 83 | const directory = path.join(testDirectory, 'Program Files (x86)', 'app'); 84 | const pattern = convertPathToPattern(directory) + '/*.txt'; 85 | 86 | const result = await globby(pattern); 87 | t.is(result.length, 1); 88 | t.true(result[0].includes('test.txt')); 89 | }); 90 | 91 | test('combining converted path with glob pattern', t => { 92 | const directory = path.join(testDirectory, 'Program Files (x86)', 'app'); 93 | const pattern = convertPathToPattern(directory) + '/*.{txt,js}'; 94 | 95 | const result = globbySync(pattern); 96 | t.is(result.length, 2); 97 | }); 98 | 99 | test('recursive glob through directory with special characters', t => { 100 | // Recursive patterns work because they traverse all directories 101 | // without needing to match the exact directory name 102 | const pattern = testDirectory + '/**/*.txt'; 103 | 104 | const result = globbySync(pattern); 105 | t.true(result.length >= 3); // At least 3 test.txt files 106 | 107 | // Verify all expected files are found 108 | const expectedPaths = [ 109 | 'Program Files (x86)/app/test.txt', 110 | 'Folder [a-z]/data/test.txt', 111 | 'Normal Folder/files/test.txt', 112 | ]; 113 | 114 | for (const expectedPath of expectedPaths) { 115 | t.true(result.some(file => file.includes(expectedPath))); 116 | } 117 | }); 118 | 119 | // Tests for issue #81 - special characters in directory names 120 | 121 | test('paths with plus character work without convertPathToPattern', t => { 122 | const directory = path.join(testDirectory, 'github.com + Globby'); 123 | const pattern = directory.replaceAll(path.sep, '/') + '/*.txt'; 124 | 125 | // Plus character followed by space is treated as literal 126 | const result = globbySync(pattern); 127 | t.is(result.length, 1); 128 | t.true(result[0].includes('test.txt')); 129 | }); 130 | 131 | test('paths with plus character work with convertPathToPattern', t => { 132 | const directory = path.join(testDirectory, 'github.com + Globby'); 133 | const pattern = convertPathToPattern(directory) + '/*.txt'; 134 | 135 | const result = globbySync(pattern); 136 | t.is(result.length, 1); 137 | t.true(result[0].includes('test.txt')); 138 | }); 139 | 140 | test('paths with braces work without convertPathToPattern', t => { 141 | const directory = path.join(testDirectory, 'folder {with} braces'); 142 | const pattern = directory.replaceAll(path.sep, '/') + '/*.txt'; 143 | 144 | // Braces are only special when they contain comma-separated alternatives like {a,b} 145 | // Single words in braces like {with} are treated as literal 146 | const result = globbySync(pattern); 147 | t.is(result.length, 1); 148 | t.true(result[0].includes('test.txt')); 149 | }); 150 | 151 | test('paths with braces work with convertPathToPattern', t => { 152 | const directory = path.join(testDirectory, 'folder {with} braces'); 153 | const pattern = convertPathToPattern(directory) + '/*.txt'; 154 | 155 | const result = globbySync(pattern); 156 | t.is(result.length, 1); 157 | t.true(result[0].includes('test.txt')); 158 | }); 159 | 160 | test('Next.js [slug] pattern fails without convertPathToPattern', t => { 161 | const directory = path.join(testDirectory, 'Next.js (app)', '[slug]'); 162 | const pattern = directory.replaceAll(path.sep, '/') + '/*.txt'; 163 | 164 | // Without escaping, [] and () are glob syntax 165 | const result = globbySync(pattern); 166 | t.deepEqual(result, []); 167 | }); 168 | 169 | test('Next.js [slug] pattern works with convertPathToPattern', t => { 170 | const directory = path.join(testDirectory, 'Next.js (app)', '[slug]'); 171 | const pattern = convertPathToPattern(directory) + '/*.txt'; 172 | 173 | const result = globbySync(pattern); 174 | t.is(result.length, 1); 175 | t.true(result[0].includes('test.txt')); 176 | }); 177 | 178 | test('Next.js [...error] pattern fails without convertPathToPattern', t => { 179 | const directory = path.join(testDirectory, 'Next.js (app)', '[...error]'); 180 | const pattern = directory.replaceAll(path.sep, '/') + '/*.txt'; 181 | 182 | // Without escaping, [] and () are glob syntax 183 | const result = globbySync(pattern); 184 | t.deepEqual(result, []); 185 | }); 186 | 187 | test('Next.js [...error] pattern works with convertPathToPattern', t => { 188 | const directory = path.join(testDirectory, 'Next.js (app)', '[...error]'); 189 | const pattern = convertPathToPattern(directory) + '/*.txt'; 190 | 191 | const result = globbySync(pattern); 192 | t.is(result.length, 1); 193 | t.true(result[0].includes('test.txt')); 194 | }); 195 | 196 | test('convertPathToPattern with async globby', async t => { 197 | const directory = path.join(testDirectory, 'Next.js (app)', '[slug]'); 198 | const pattern = convertPathToPattern(directory) + '/**/*'; 199 | 200 | const result = await globby(pattern); 201 | t.is(result.length, 2); // Test.txt and test.js 202 | }); 203 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {type Buffer} from 'node:buffer'; 2 | import {expectType} from 'tsd'; 3 | import { 4 | type GlobTask, 5 | type GlobEntry, 6 | type GlobbyStream, 7 | type GlobbyEntryStream, 8 | type GlobbyFilterFunction, 9 | globby, 10 | globbySync, 11 | globbyStream, 12 | generateGlobTasks, 13 | generateGlobTasksSync, 14 | isDynamicPattern, 15 | isGitIgnored, 16 | isGitIgnoredSync, 17 | } from './index.js'; 18 | 19 | const __dirname = ''; 20 | 21 | // Globby 22 | expectType>(globby('*.tmp')); 23 | expectType>(globby(['a.tmp', '*.tmp', '!{c,d,e}.tmp'])); 24 | 25 | expectType>(globby('*.tmp', {expandDirectories: false})); 26 | expectType>(globby('*.tmp', {expandDirectories: ['a*', 'b*']})); 27 | expectType>(globby('*.tmp', { 28 | expandDirectories: { 29 | files: ['a', 'b'], 30 | extensions: ['tmp'], 31 | }, 32 | })); 33 | expectType>(globby('*.tmp', {gitignore: true})); 34 | expectType>(globby('*.tmp', {ignore: ['**/b.tmp']})); 35 | expectType>(globby('*.tmp', {objectMode: true})); 36 | expectType>(globby('*.tmp', {stats: true})); 37 | 38 | // Globby (sync) 39 | expectType(globbySync('*.tmp')); 40 | expectType(globbySync(['a.tmp', '*.tmp', '!{c,d,e}.tmp'])); 41 | 42 | expectType(globbySync('*.tmp', {expandDirectories: false})); 43 | expectType(globbySync('*.tmp', {expandDirectories: ['a*', 'b*']})); 44 | expectType(globbySync('*.tmp', { 45 | expandDirectories: { 46 | files: ['a', 'b'], 47 | extensions: ['tmp'], 48 | }, 49 | })); 50 | expectType(globbySync('*.tmp', {gitignore: true})); 51 | expectType(globbySync('*.tmp', {ignore: ['**/b.tmp']})); 52 | expectType(globbySync('*.tmp', {objectMode: true})); 53 | expectType(globbySync('*.tmp', {stats: true})); 54 | 55 | // Globby (stream) 56 | expectType(globbyStream('*.tmp')); 57 | expectType(globbyStream(['a.tmp', '*.tmp', '!{c,d,e}.tmp'])); 58 | 59 | expectType(globbyStream('*.tmp', {expandDirectories: false})); 60 | expectType(globbyStream('*.tmp', {expandDirectories: ['a*', 'b*']})); 61 | expectType(globbyStream('*.tmp', { 62 | expandDirectories: { 63 | files: ['a', 'b'], 64 | extensions: ['tmp'], 65 | }, 66 | })); 67 | expectType(globbyStream('*.tmp', {gitignore: true})); 68 | expectType(globbyStream('*.tmp', {ignore: ['**/b.tmp']})); 69 | expectType(globbyStream('*.tmp', {objectMode: true})); 70 | expectType(globbyStream('*.tmp', {stats: true})); 71 | expectType(globbyStream('*.tmp', {objectMode: true, gitignore: true})); 72 | expectType(globbyStream('*.tmp', {stats: true, gitignore: true})); 73 | 74 | // eslint-disable-next-line unicorn/prefer-top-level-await 75 | (async () => { 76 | const streamResult = []; 77 | for await (const path of globbyStream('*.tmp')) { 78 | streamResult.push(path); 79 | } 80 | 81 | // With the GlobbyStream interface, we can properly type the result as string[] 82 | expectType(streamResult); 83 | })(); 84 | 85 | // eslint-disable-next-line unicorn/prefer-top-level-await 86 | (async () => { 87 | const streamResult = []; 88 | for await (const entry of globbyStream('*.tmp', {objectMode: true})) { 89 | streamResult.push(entry); 90 | } 91 | 92 | expectType(streamResult); 93 | })(); 94 | 95 | // eslint-disable-next-line unicorn/prefer-top-level-await 96 | (async () => { 97 | const streamResult = []; 98 | for await (const entry of globbyStream('*.tmp', {stats: true})) { 99 | streamResult.push(entry); 100 | } 101 | 102 | expectType(streamResult); 103 | })(); 104 | 105 | // GenerateGlobTasks 106 | expectType>(generateGlobTasks('*.tmp')); 107 | expectType>(generateGlobTasks(['a.tmp', '*.tmp', '!{c,d,e}.tmp'])); 108 | 109 | expectType>(generateGlobTasks('*.tmp', {expandDirectories: false})); 110 | expectType>(generateGlobTasks('*.tmp', {expandDirectories: ['a*', 'b*']})); 111 | expectType>(generateGlobTasks('*.tmp', { 112 | expandDirectories: { 113 | files: ['a', 'b'], 114 | extensions: ['tmp'], 115 | }, 116 | })); 117 | expectType>(generateGlobTasks('*.tmp', {gitignore: true})); 118 | expectType>(generateGlobTasks('*.tmp', {ignore: ['**/b.tmp']})); 119 | 120 | // GenerateGlobTasksSync 121 | expectType(generateGlobTasksSync('*.tmp')); 122 | expectType(generateGlobTasksSync(['a.tmp', '*.tmp', '!{c,d,e}.tmp'])); 123 | 124 | expectType(generateGlobTasksSync('*.tmp', {expandDirectories: false})); 125 | expectType(generateGlobTasksSync('*.tmp', {expandDirectories: ['a*', 'b*']})); 126 | expectType(generateGlobTasksSync('*.tmp', { 127 | expandDirectories: { 128 | files: ['a', 'b'], 129 | extensions: ['tmp'], 130 | }, 131 | })); 132 | expectType(generateGlobTasksSync('*.tmp', {gitignore: true})); 133 | expectType(generateGlobTasksSync('*.tmp', {ignore: ['**/b.tmp']})); 134 | 135 | // IsDynamicPattern 136 | expectType(isDynamicPattern('**')); 137 | expectType(isDynamicPattern(['**', 'path1', 'path2'])); 138 | expectType(isDynamicPattern(['**', 'path1', 'path2'], {extglob: false})); 139 | expectType(isDynamicPattern(['**'], {cwd: new URL('file:///path/to/cwd')})); 140 | expectType(isDynamicPattern(['**'], {cwd: __dirname})); 141 | 142 | // IsGitIgnored 143 | expectType>(isGitIgnored()); 144 | expectType>(isGitIgnored({ 145 | cwd: __dirname, 146 | })); 147 | expectType>(isGitIgnored({ 148 | cwd: new URL('file:///path/to/cwd'), 149 | })); 150 | 151 | // Supported option: suppressErrors 152 | expectType>(isGitIgnored({ 153 | suppressErrors: true, 154 | })); 155 | 156 | // Supported option: deep 157 | expectType>(isGitIgnored({ 158 | deep: 2, 159 | })); 160 | 161 | // Supported option: ignore 162 | expectType>(isGitIgnored({ 163 | ignore: ['**/node_modules'], 164 | })); 165 | 166 | // Supported option: followSymbolicLinks 167 | expectType>(isGitIgnored({ 168 | followSymbolicLinks: false, 169 | })); 170 | 171 | // Supported option: concurrency 172 | expectType>(isGitIgnored({ 173 | concurrency: 4, 174 | })); 175 | 176 | // Supported option: throwErrorOnBrokenSymbolicLink 177 | expectType>(isGitIgnored({ 178 | throwErrorOnBrokenSymbolicLink: false, 179 | })); 180 | 181 | // Multiple supported options combined 182 | expectType>(isGitIgnored({ 183 | cwd: __dirname, 184 | suppressErrors: true, 185 | deep: 1, 186 | ignore: ['**/node_modules', '**/dist'], 187 | followSymbolicLinks: false, 188 | concurrency: 8, 189 | throwErrorOnBrokenSymbolicLink: false, 190 | })); 191 | 192 | // IsGitIgnoredSync 193 | expectType(isGitIgnoredSync()); 194 | expectType(isGitIgnoredSync({ 195 | cwd: __dirname, 196 | })); 197 | expectType(isGitIgnoredSync({ 198 | cwd: new URL('file:///path/to/cwd'), 199 | })); 200 | 201 | // Supported option: suppressErrors (sync) 202 | expectType(isGitIgnoredSync({ 203 | suppressErrors: true, 204 | })); 205 | 206 | // Supported option: deep (sync) 207 | expectType(isGitIgnoredSync({ 208 | deep: 2, 209 | })); 210 | 211 | // Supported option: ignore (sync) 212 | expectType(isGitIgnoredSync({ 213 | ignore: ['**/node_modules'], 214 | })); 215 | 216 | // Supported option: followSymbolicLinks (sync) 217 | expectType(isGitIgnoredSync({ 218 | followSymbolicLinks: false, 219 | })); 220 | 221 | // Supported option: concurrency (sync) 222 | expectType(isGitIgnoredSync({ 223 | concurrency: 4, 224 | })); 225 | 226 | // Supported option: throwErrorOnBrokenSymbolicLink (sync) 227 | expectType(isGitIgnoredSync({ 228 | throwErrorOnBrokenSymbolicLink: true, 229 | })); 230 | 231 | // Multiple supported options combined (sync) 232 | expectType(isGitIgnoredSync({ 233 | cwd: __dirname, 234 | suppressErrors: true, 235 | deep: 1, 236 | ignore: ['**/node_modules', '**/dist'], 237 | followSymbolicLinks: false, 238 | concurrency: 4, 239 | throwErrorOnBrokenSymbolicLink: false, 240 | })); 241 | -------------------------------------------------------------------------------- /utilities.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import {promisify} from 'node:util'; 4 | import isPathInside from 'is-path-inside'; 5 | 6 | export const isNegativePattern = pattern => pattern[0] === '!'; 7 | 8 | /** 9 | Normalize an absolute pattern to be relative. 10 | 11 | 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:\`). 12 | 13 | This function strips leading `/` to make patterns relative to cwd, ensuring consistent cross-platform behavior. 14 | 15 | @param {string} pattern - The pattern to normalize. 16 | */ 17 | export const normalizeAbsolutePatternToRelative = pattern => pattern.startsWith('/') ? pattern.slice(1) : pattern; 18 | 19 | export const bindFsMethod = (object, methodName) => { 20 | const method = object?.[methodName]; 21 | return typeof method === 'function' ? method.bind(object) : undefined; 22 | }; 23 | 24 | // Only used as a fallback for legacy fs implementations 25 | export const promisifyFsMethod = (object, methodName) => { 26 | const method = object?.[methodName]; 27 | if (typeof method !== 'function') { 28 | return undefined; 29 | } 30 | 31 | return promisify(method.bind(object)); 32 | }; 33 | 34 | export const normalizeDirectoryPatternForFastGlob = pattern => { 35 | if (!pattern.endsWith('/')) { 36 | return pattern; 37 | } 38 | 39 | const trimmedPattern = pattern.replace(/\/+$/u, ''); 40 | if (!trimmedPattern) { 41 | return '/**'; 42 | } 43 | 44 | // Special case for '**/' to avoid producing '**/**/**' 45 | if (trimmedPattern === '**') { 46 | return '**/**'; 47 | } 48 | 49 | const hasLeadingSlash = trimmedPattern.startsWith('/'); 50 | const patternBody = hasLeadingSlash ? trimmedPattern.slice(1) : trimmedPattern; 51 | const hasInnerSlash = patternBody.includes('/'); 52 | const needsRecursivePrefix = !hasLeadingSlash && !hasInnerSlash && !trimmedPattern.startsWith('**/'); 53 | const recursivePrefix = needsRecursivePrefix ? '**/' : ''; 54 | 55 | return `${recursivePrefix}${trimmedPattern}/**`; 56 | }; 57 | 58 | /** 59 | Extract the parent directory prefix from a pattern (e.g., '../' or '../../'). 60 | 61 | Note: Patterns should have trailing slash after '..' (e.g., '../foo' not '..foo'). The directoryToGlob function ensures this in the normal pipeline. 62 | 63 | @param {string} pattern - The pattern to analyze. 64 | @returns {string} The parent directory prefix, or empty string if none. 65 | */ 66 | export const getParentDirectoryPrefix = pattern => { 67 | const normalizedPattern = isNegativePattern(pattern) ? pattern.slice(1) : pattern; 68 | const match = normalizedPattern.match(/^(\.\.\/)+/); 69 | return match ? match[0] : ''; 70 | }; 71 | 72 | /** 73 | Adjust ignore patterns to match the relative base of the main patterns. 74 | 75 | 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. 76 | 77 | 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. 78 | 79 | @param {string[]} patterns - The main glob patterns. 80 | @param {string[]} ignorePatterns - The ignore patterns to adjust. 81 | @returns {string[]} Adjusted ignore patterns. 82 | */ 83 | export const adjustIgnorePatternsForParentDirectories = (patterns, ignorePatterns) => { 84 | // Early exit for empty arrays 85 | if (patterns.length === 0 || ignorePatterns.length === 0) { 86 | return ignorePatterns; 87 | } 88 | 89 | // Get parent directory prefixes for all patterns (empty string if no prefix) 90 | const parentPrefixes = patterns.map(pattern => getParentDirectoryPrefix(pattern)); 91 | 92 | // Check if all patterns have the same parent prefix 93 | const firstPrefix = parentPrefixes[0]; 94 | if (!firstPrefix) { 95 | return ignorePatterns; // No parent directories in any pattern 96 | } 97 | 98 | const allSamePrefix = parentPrefixes.every(prefix => prefix === firstPrefix); 99 | if (!allSamePrefix) { 100 | return ignorePatterns; // Mixed bases - don't adjust 101 | } 102 | 103 | // Adjust ignore patterns that start with **/ 104 | return ignorePatterns.map(pattern => { 105 | // Only adjust patterns starting with **/ that don't already have a parent reference 106 | if (pattern.startsWith('**/') && !pattern.startsWith('../')) { 107 | return firstPrefix + pattern; 108 | } 109 | 110 | return pattern; 111 | }); 112 | }; 113 | 114 | /** 115 | Find the git root directory by searching upward for a .git directory. 116 | 117 | @param {string} cwd - The directory to start searching from. 118 | @param {Object} [fsImplementation] - Optional fs implementation. 119 | @returns {string|undefined} The git root directory path, or undefined if not found. 120 | */ 121 | const getAsyncStatMethod = fsImplementation => 122 | bindFsMethod(fsImplementation?.promises, 'stat') 123 | ?? bindFsMethod(fs.promises, 'stat'); 124 | 125 | const getStatSyncMethod = fsImplementation => { 126 | if (fsImplementation) { 127 | return bindFsMethod(fsImplementation, 'statSync'); 128 | } 129 | 130 | return bindFsMethod(fs, 'statSync'); 131 | }; 132 | 133 | const pathHasGitDirectory = stats => Boolean(stats?.isDirectory?.() || stats?.isFile?.()); 134 | 135 | const buildPathChain = (startPath, rootPath) => { 136 | const chain = []; 137 | let currentPath = startPath; 138 | 139 | chain.push(currentPath); 140 | 141 | while (currentPath !== rootPath) { 142 | const parentPath = path.dirname(currentPath); 143 | if (parentPath === currentPath) { 144 | break; 145 | } 146 | 147 | currentPath = parentPath; 148 | chain.push(currentPath); 149 | } 150 | 151 | return chain; 152 | }; 153 | 154 | const findGitRootInChain = async (paths, statMethod) => { 155 | for (const directory of paths) { 156 | const gitPath = path.join(directory, '.git'); 157 | 158 | try { 159 | const stats = await statMethod(gitPath); // eslint-disable-line no-await-in-loop 160 | if (pathHasGitDirectory(stats)) { 161 | return directory; 162 | } 163 | } catch { 164 | // Ignore errors and continue searching 165 | } 166 | } 167 | 168 | return undefined; 169 | }; 170 | 171 | const findGitRootSyncUncached = (cwd, fsImplementation) => { 172 | const statSyncMethod = getStatSyncMethod(fsImplementation); 173 | if (!statSyncMethod) { 174 | return undefined; 175 | } 176 | 177 | const currentPath = path.resolve(cwd); 178 | const {root} = path.parse(currentPath); 179 | const chain = buildPathChain(currentPath, root); 180 | 181 | for (const directory of chain) { 182 | const gitPath = path.join(directory, '.git'); 183 | try { 184 | const stats = statSyncMethod(gitPath); 185 | if (pathHasGitDirectory(stats)) { 186 | return directory; 187 | } 188 | } catch { 189 | // Ignore errors and continue searching 190 | } 191 | } 192 | 193 | return undefined; 194 | }; 195 | 196 | export const findGitRootSync = (cwd, fsImplementation) => { 197 | if (typeof cwd !== 'string') { 198 | throw new TypeError('cwd must be a string'); 199 | } 200 | 201 | return findGitRootSyncUncached(cwd, fsImplementation); 202 | }; 203 | 204 | const findGitRootAsyncUncached = async (cwd, fsImplementation) => { 205 | const statMethod = getAsyncStatMethod(fsImplementation); 206 | if (!statMethod) { 207 | return findGitRootSync(cwd, fsImplementation); 208 | } 209 | 210 | const currentPath = path.resolve(cwd); 211 | const {root} = path.parse(currentPath); 212 | const chain = buildPathChain(currentPath, root); 213 | 214 | return findGitRootInChain(chain, statMethod); 215 | }; 216 | 217 | export const findGitRoot = async (cwd, fsImplementation) => { 218 | if (typeof cwd !== 'string') { 219 | throw new TypeError('cwd must be a string'); 220 | } 221 | 222 | return findGitRootAsyncUncached(cwd, fsImplementation); 223 | }; 224 | 225 | /** 226 | Get paths to all .gitignore files from git root to cwd (inclusive). 227 | 228 | @param {string} gitRoot - The git root directory. 229 | @param {string} cwd - The current working directory. 230 | @returns {string[]} Array of .gitignore file paths to search for. 231 | */ 232 | const isWithinGitRoot = (gitRoot, cwd) => { 233 | const resolvedGitRoot = path.resolve(gitRoot); 234 | const resolvedCwd = path.resolve(cwd); 235 | return resolvedCwd === resolvedGitRoot || isPathInside(resolvedCwd, resolvedGitRoot); 236 | }; 237 | 238 | export const getParentGitignorePaths = (gitRoot, cwd) => { 239 | if (gitRoot && typeof gitRoot !== 'string') { 240 | throw new TypeError('gitRoot must be a string or undefined'); 241 | } 242 | 243 | if (typeof cwd !== 'string') { 244 | throw new TypeError('cwd must be a string'); 245 | } 246 | 247 | // If no gitRoot provided, return empty array 248 | if (!gitRoot) { 249 | return []; 250 | } 251 | 252 | if (!isWithinGitRoot(gitRoot, cwd)) { 253 | return []; 254 | } 255 | 256 | const chain = buildPathChain(path.resolve(cwd), path.resolve(gitRoot)); 257 | 258 | return [...chain] 259 | .reverse() 260 | .map(directory => path.join(directory, '.gitignore')); 261 | }; 262 | 263 | /** 264 | Convert ignore patterns to fast-glob compatible format. 265 | Returns empty array if patterns should be handled by predicate only. 266 | 267 | @param {string[]} patterns - Ignore patterns from .gitignore files 268 | @param {boolean} usingGitRoot - Whether patterns are relative to git root 269 | @param {Function} normalizeDirectoryPatternForFastGlob - Function to normalize directory patterns 270 | @returns {string[]} Patterns safe to pass to fast-glob, or empty array 271 | */ 272 | export const convertPatternsForFastGlob = (patterns, usingGitRoot, normalizeDirectoryPatternForFastGlob) => { 273 | // Determine which patterns are safe to pass to fast-glob 274 | // If there are negation patterns, we can't pass file patterns to fast-glob 275 | // because fast-glob doesn't understand negations and would filter out files 276 | // that should be re-included by negation patterns. 277 | // If we're using git root, patterns are relative to git root not cwd, 278 | // so we can't pass them to fast-glob which expects cwd-relative patterns. 279 | // We only pass patterns to fast-glob if there are NO negations AND we're not using git root. 280 | 281 | if (usingGitRoot) { 282 | return []; // Patterns are relative to git root, not cwd 283 | } 284 | 285 | const result = []; 286 | let hasNegations = false; 287 | 288 | // Single pass to check for negations and collect positive patterns 289 | for (const pattern of patterns) { 290 | if (isNegativePattern(pattern)) { 291 | hasNegations = true; 292 | break; // Early exit on first negation 293 | } 294 | 295 | result.push(normalizeDirectoryPatternForFastGlob(pattern)); 296 | } 297 | 298 | return hasNegations ? [] : result; 299 | }; 300 | -------------------------------------------------------------------------------- /tests/parent-directory-patterns.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import process from 'node:process'; 3 | import path from 'node:path'; 4 | import test from 'ava'; 5 | import {temporaryDirectory} from 'tempy'; 6 | import { 7 | getParentDirectoryPrefix, 8 | adjustIgnorePatternsForParentDirectories, 9 | getParentGitignorePaths, 10 | findGitRoot, 11 | findGitRootSync, 12 | convertPatternsForFastGlob, 13 | } from '../utilities.js'; 14 | 15 | const createVirtualGitFs = gitDirectories => { 16 | const stats = { 17 | isDirectory() { 18 | return true; 19 | }, 20 | isFile() { 21 | return false; 22 | }, 23 | }; 24 | 25 | const statForPath = filePath => { 26 | const normalizedPath = path.resolve(filePath); 27 | if (gitDirectories.has(normalizedPath)) { 28 | return stats; 29 | } 30 | 31 | const error = new Error('Path not found'); 32 | error.code = 'ENOENT'; 33 | throw error; 34 | }; 35 | 36 | return { 37 | statSync: statForPath, 38 | promises: { 39 | stat: async filePath => statForPath(filePath), 40 | }, 41 | }; 42 | }; 43 | 44 | test('getParentDirectoryPrefix - single parent directory', t => { 45 | t.is(getParentDirectoryPrefix('../foo'), '../'); 46 | t.is(getParentDirectoryPrefix('../**'), '../'); 47 | t.is(getParentDirectoryPrefix('../*.js'), '../'); 48 | }); 49 | 50 | test('getParentDirectoryPrefix - multiple parent directories', t => { 51 | t.is(getParentDirectoryPrefix('../../foo'), '../../'); 52 | t.is(getParentDirectoryPrefix('../../../bar/**'), '../../../'); 53 | }); 54 | 55 | test('getParentDirectoryPrefix - no parent directory', t => { 56 | t.is(getParentDirectoryPrefix('foo'), ''); 57 | t.is(getParentDirectoryPrefix('src/**'), ''); 58 | t.is(getParentDirectoryPrefix('**/*.js'), ''); 59 | }); 60 | 61 | test('getParentDirectoryPrefix - relative current directory', t => { 62 | t.is(getParentDirectoryPrefix('./foo'), ''); 63 | t.is(getParentDirectoryPrefix('./src/**'), ''); 64 | }); 65 | 66 | test('getParentDirectoryPrefix - ignores negation prefix', t => { 67 | t.is(getParentDirectoryPrefix('!../foo'), '../'); 68 | t.is(getParentDirectoryPrefix('!../../bar/**'), '../../'); 69 | }); 70 | 71 | test('adjustIgnorePatternsForParentDirectories - single level parent', t => { 72 | const result = adjustIgnorePatternsForParentDirectories( 73 | ['../**'], 74 | ['**/node_modules/**', '**/dist/**'], 75 | ); 76 | t.deepEqual(result, ['../**/node_modules/**', '../**/dist/**']); 77 | }); 78 | 79 | test('adjustIgnorePatternsForParentDirectories - double level parent', t => { 80 | const result = adjustIgnorePatternsForParentDirectories( 81 | ['../../foo/**'], 82 | ['**/node_modules/**'], 83 | ); 84 | t.deepEqual(result, ['../../**/node_modules/**']); 85 | }); 86 | 87 | test('adjustIgnorePatternsForParentDirectories - no adjustment for non-globstar patterns', t => { 88 | const result = adjustIgnorePatternsForParentDirectories( 89 | ['../**'], 90 | ['node_modules/**', 'dist/'], 91 | ); 92 | t.deepEqual(result, ['node_modules/**', 'dist/']); 93 | }); 94 | 95 | test('adjustIgnorePatternsForParentDirectories - already prefixed ignores', t => { 96 | const result = adjustIgnorePatternsForParentDirectories( 97 | ['../**'], 98 | ['../**/build/**', '**/node_modules/**'], 99 | ); 100 | t.deepEqual(result, ['../**/build/**', '../**/node_modules/**']); 101 | }); 102 | 103 | test('adjustIgnorePatternsForParentDirectories - mixed pattern bases', t => { 104 | const result = adjustIgnorePatternsForParentDirectories( 105 | ['../**', 'src/**'], 106 | ['**/node_modules/**'], 107 | ); 108 | t.deepEqual(result, ['**/node_modules/**'], 'should not adjust when patterns have different bases'); 109 | }); 110 | 111 | test('adjustIgnorePatternsForParentDirectories - all patterns with same prefix', t => { 112 | const result = adjustIgnorePatternsForParentDirectories( 113 | ['../../lib/**', '../../*.js'], 114 | ['**/test/**'], 115 | ); 116 | t.deepEqual(result, ['../../**/test/**']); 117 | }); 118 | 119 | test('adjustIgnorePatternsForParentDirectories - no parent directories', t => { 120 | const result = adjustIgnorePatternsForParentDirectories( 121 | ['src/**', 'lib/**'], 122 | ['**/node_modules/**'], 123 | ); 124 | t.deepEqual(result, ['**/node_modules/**'], 'should not adjust when no parent directories'); 125 | }); 126 | 127 | test('adjustIgnorePatternsForParentDirectories - empty ignore array', t => { 128 | const result = adjustIgnorePatternsForParentDirectories( 129 | ['../**'], 130 | [], 131 | ); 132 | t.deepEqual(result, []); 133 | }); 134 | 135 | test('adjustIgnorePatternsForParentDirectories - empty pattern array', t => { 136 | const result = adjustIgnorePatternsForParentDirectories( 137 | [], 138 | ['**/node_modules/**'], 139 | ); 140 | t.deepEqual(result, ['**/node_modules/**']); 141 | }); 142 | 143 | test('adjustIgnorePatternsForParentDirectories - mixed globstar and non-globstar', t => { 144 | const result = adjustIgnorePatternsForParentDirectories( 145 | ['../**'], 146 | ['**/node_modules/**', 'build/', 'dist/**', '**/test/**'], 147 | ); 148 | t.deepEqual(result, ['../**/node_modules/**', 'build/', 'dist/**', '../**/test/**']); 149 | }); 150 | 151 | test('adjustIgnorePatternsForParentDirectories - negated patterns still adjust', t => { 152 | const result = adjustIgnorePatternsForParentDirectories( 153 | ['../**', '!../dist/**'], 154 | ['**/node_modules/**'], 155 | ); 156 | t.deepEqual(result, ['../**/node_modules/**']); 157 | }); 158 | 159 | test('adjustIgnorePatternsForParentDirectories - patterns with different parent levels', t => { 160 | const result = adjustIgnorePatternsForParentDirectories( 161 | ['../**', '../../bar/**'], 162 | ['**/node_modules/**'], 163 | ); 164 | t.deepEqual(result, ['**/node_modules/**'], 'should not adjust when parent levels differ'); 165 | }); 166 | 167 | test('adjustIgnorePatternsForParentDirectories - single pattern single ignore', t => { 168 | const result = adjustIgnorePatternsForParentDirectories( 169 | ['../foo'], 170 | ['**/node_modules/**'], 171 | ); 172 | t.deepEqual(result, ['../**/node_modules/**']); 173 | }); 174 | 175 | test('adjustIgnorePatternsForParentDirectories - ignore with leading slash', t => { 176 | const result = adjustIgnorePatternsForParentDirectories( 177 | ['../**'], 178 | ['/absolute/path/**', '**/node_modules/**'], 179 | ); 180 | t.deepEqual(result, ['/absolute/path/**', '../**/node_modules/**'], 'should not adjust absolute paths'); 181 | }); 182 | 183 | test('adjustIgnorePatternsForParentDirectories - bare ** pattern', t => { 184 | const result = adjustIgnorePatternsForParentDirectories( 185 | ['../**'], 186 | ['**'], 187 | ); 188 | t.deepEqual(result, ['**'], 'bare ** without trailing slash is not adjusted'); 189 | }); 190 | 191 | test('adjustIgnorePatternsForParentDirectories - empty string in patterns', t => { 192 | const result = adjustIgnorePatternsForParentDirectories( 193 | ['', '../**'], 194 | ['**/test/**'], 195 | ); 196 | t.deepEqual(result, ['**/test/**'], 'mixed empty and parent patterns should not adjust'); 197 | }); 198 | 199 | test('adjustIgnorePatternsForParentDirectories - many parent levels', t => { 200 | const result = adjustIgnorePatternsForParentDirectories( 201 | ['../../../../../deep/**'], 202 | ['**/test/**', '**/node_modules/**'], 203 | ); 204 | t.deepEqual(result, ['../../../../../**/test/**', '../../../../../**/node_modules/**']); 205 | }); 206 | 207 | test('getParentDirectoryPrefix - pattern without trailing slash', t => { 208 | t.is(getParentDirectoryPrefix('..'), '', 'pattern without trailing slash returns empty string'); 209 | t.is(getParentDirectoryPrefix('../..'), '../', 'matches only first .. with slash'); 210 | }); 211 | 212 | test('getParentDirectoryPrefix - three dots', t => { 213 | t.is(getParentDirectoryPrefix('.../foo'), '', 'three dots is not a valid parent reference'); 214 | }); 215 | 216 | test('getParentGitignorePaths includes git root when repo is at filesystem root', t => { 217 | const filesystemRoot = path.parse(process.cwd()).root; 218 | const projectDirectory = path.join(filesystemRoot, 'project'); 219 | const packagesDirectory = path.join(projectDirectory, 'packages'); 220 | const childDirectory = path.join(packagesDirectory, 'app'); 221 | 222 | const result = getParentGitignorePaths(filesystemRoot, childDirectory); 223 | 224 | t.deepEqual(result, [ 225 | path.join(filesystemRoot, '.gitignore'), 226 | path.join(projectDirectory, '.gitignore'), 227 | path.join(packagesDirectory, '.gitignore'), 228 | path.join(childDirectory, '.gitignore'), 229 | ]); 230 | }); 231 | 232 | test('getParentGitignorePaths returns empty when cwd is outside git root', t => { 233 | const gitRoot = path.join(process.cwd(), 'repo-root'); 234 | const outsideDirectory = path.resolve(gitRoot, '..', 'sibling'); 235 | 236 | const result = getParentGitignorePaths(gitRoot, outsideDirectory); 237 | 238 | t.deepEqual(result, []); 239 | }); 240 | 241 | // Tests for input validation 242 | test('findGitRoot validates cwd parameter', async t => { 243 | await t.throwsAsync( 244 | () => findGitRoot(123), 245 | {instanceOf: TypeError, message: 'cwd must be a string'}, 246 | ); 247 | 248 | await t.throwsAsync( 249 | () => findGitRoot(null), 250 | {instanceOf: TypeError, message: 'cwd must be a string'}, 251 | ); 252 | 253 | t.throws( 254 | () => findGitRootSync(undefined), 255 | {instanceOf: TypeError, message: 'cwd must be a string'}, 256 | ); 257 | }); 258 | 259 | test('getParentGitignorePaths validates parameters', t => { 260 | t.throws( 261 | () => getParentGitignorePaths('valid', 123), 262 | {instanceOf: TypeError, message: 'cwd must be a string'}, 263 | ); 264 | 265 | t.throws( 266 | () => getParentGitignorePaths(123, 'valid'), 267 | {instanceOf: TypeError, message: 'gitRoot must be a string or undefined'}, 268 | ); 269 | 270 | // Should not throw with valid parameters 271 | t.notThrows(() => getParentGitignorePaths('/git/root', '/git/root/sub')); 272 | t.notThrows(() => getParentGitignorePaths(undefined, '/some/path')); 273 | }); 274 | 275 | test('findGitRoot reflects repositories created after the first lookup', async t => { 276 | const projectRoot = path.join(temporaryDirectory(), 'project'); 277 | fs.mkdirSync(projectRoot, {recursive: true}); 278 | 279 | t.is(await findGitRoot(projectRoot), undefined); 280 | t.is(findGitRootSync(projectRoot), undefined); 281 | 282 | fs.mkdirSync(path.join(projectRoot, '.git')); 283 | 284 | t.is(await findGitRoot(projectRoot), projectRoot); 285 | t.is(findGitRootSync(projectRoot), projectRoot); 286 | }); 287 | 288 | test('findGitRoot respects custom filesystem implementations', async t => { 289 | const virtualProjectRoot = path.join(temporaryDirectory(), 'virtual-project'); 290 | fs.mkdirSync(virtualProjectRoot, {recursive: true}); 291 | 292 | t.is(await findGitRoot(virtualProjectRoot), undefined); 293 | t.is(findGitRootSync(virtualProjectRoot), undefined); 294 | 295 | const virtualFs = createVirtualGitFs(new Set([path.join(virtualProjectRoot, '.git')])); 296 | 297 | t.is(await findGitRoot(virtualProjectRoot, virtualFs), virtualProjectRoot); 298 | t.is(findGitRootSync(virtualProjectRoot, virtualFs), virtualProjectRoot); 299 | }); 300 | 301 | // Test for pattern conversion optimization 302 | test('convertPatternsForFastGlob optimizes with single pass', t => { 303 | const normalizer = pattern => `normalized:${pattern}`; 304 | 305 | // Test early exit on first negation 306 | const patterns1 = ['file1.js', 'file2.js', '!important.js', 'file3.js']; 307 | const result1 = convertPatternsForFastGlob(patterns1, false, normalizer); 308 | t.deepEqual(result1, []); // Should return empty on negation 309 | 310 | // Test with no negations 311 | const patterns2 = ['file1.js', 'file2.js', 'file3.js']; 312 | const result2 = convertPatternsForFastGlob(patterns2, false, normalizer); 313 | t.deepEqual(result2, [ 314 | 'normalized:file1.js', 315 | 'normalized:file2.js', 316 | 'normalized:file3.js', 317 | ]); 318 | 319 | // Test with git root 320 | const result3 = convertPatternsForFastGlob(patterns2, true, normalizer); 321 | t.deepEqual(result3, []); // Should return empty when using git root 322 | }); 323 | 324 | // Test for no stack overflow with deep directories 325 | test('findGitRoot handles deep directory structures without stack overflow', async t => { 326 | const temporary = temporaryDirectory(); 327 | 328 | // Create a deep directory structure (100 levels to avoid filesystem limits) 329 | let deepPath = temporary; 330 | for (let i = 0; i < 100; i++) { 331 | deepPath = path.join(deepPath, `level${i % 10}`); 332 | } 333 | 334 | fs.mkdirSync(deepPath, {recursive: true}); 335 | 336 | // Should not cause stack overflow 337 | const result = await findGitRoot(deepPath); 338 | t.is(result, undefined); // No git root found 339 | 340 | const syncResult = findGitRootSync(deepPath); 341 | t.is(syncResult, undefined); 342 | }); 343 | -------------------------------------------------------------------------------- /tests/gitignore-comprehensive.js: -------------------------------------------------------------------------------- 1 | import {execSync} from 'node:child_process'; 2 | import process from 'node:process'; 3 | import fs from 'node:fs'; 4 | import path from 'node:path'; 5 | import test from 'ava'; 6 | import {globby, globbySync, globbyStream} from '../index.js'; 7 | 8 | const testRoot = '/tmp/claude/gitignore-tests'; 9 | 10 | // Declarative test format for simplicity 11 | const runGitignoreTest = async (t, {gitignore, files, directories = []}) => { 12 | const testDir = path.join(testRoot, t.title.replaceAll(/[^\w-]/g, '-')); 13 | 14 | // Clean and setup 15 | if (fs.existsSync(testDir)) { 16 | fs.rmSync(testDir, {recursive: true}); 17 | } 18 | 19 | fs.mkdirSync(testDir, {recursive: true}); 20 | 21 | // Create directory structure 22 | const dirSet = new Set(directories); 23 | for (const dir of directories) { 24 | fs.mkdirSync(path.join(testDir, dir), {recursive: true}); 25 | } 26 | 27 | // Create files (skip if it's actually a directory) 28 | for (const file of files) { 29 | if (dirSet.has(file)) { 30 | continue; // Skip directories in the files array 31 | } 32 | 33 | const filePath = path.join(testDir, file); 34 | fs.mkdirSync(path.dirname(filePath), {recursive: true}); 35 | fs.writeFileSync(filePath, ''); 36 | } 37 | 38 | // Create .gitignore file(s) 39 | if (typeof gitignore === 'string') { 40 | fs.writeFileSync(path.join(testDir, '.gitignore'), gitignore); 41 | } else { 42 | // Support multiple .gitignore files: {path: content} 43 | for (const [gitignorePath, content] of Object.entries(gitignore)) { 44 | fs.writeFileSync(path.join(testDir, gitignorePath), content); 45 | } 46 | } 47 | 48 | // Initialize Git 49 | const originalCwd = process.cwd(); 50 | process.chdir(testDir); 51 | try { 52 | execSync('git init -q', {stdio: 'pipe'}); 53 | } finally { 54 | process.chdir(originalCwd); 55 | } 56 | 57 | // Get Git's view of untracked files 58 | let gitFiles; 59 | process.chdir(testDir); 60 | try { 61 | const gitOutput = execSync('git status --porcelain -uall', {encoding: 'utf8'}); 62 | gitFiles = gitOutput 63 | .split('\n') 64 | .filter(line => line.startsWith('??')) 65 | .map(line => line.slice(3).trim()) 66 | .filter(f => f && !f.endsWith('.gitignore')) 67 | .sort(); 68 | } finally { 69 | process.chdir(originalCwd); 70 | } 71 | 72 | // Get globby's view (with dot:true to match Git's behavior) 73 | const options = {cwd: testDir, gitignore: true, dot: true}; 74 | const isRelevantFile = f => !f.startsWith('.git/') && !f.endsWith('.gitignore'); 75 | 76 | const globbyFilesRaw = await globby('**/*', options); 77 | const globbyFiles = globbyFilesRaw.filter(f => isRelevantFile(f)).sort(); 78 | 79 | const globbySyncFiles = globbySync('**/*', options).filter(f => isRelevantFile(f)).sort(); 80 | 81 | const globbyStreamFiles = []; 82 | for await (const file of globbyStream('**/*', options)) { 83 | if (isRelevantFile(file)) { 84 | globbyStreamFiles.push(file); 85 | } 86 | } 87 | 88 | globbyStreamFiles.sort(); 89 | 90 | // Verify all three methods match Git 91 | t.deepEqual(globbyFiles, gitFiles, 'globby async matches Git'); 92 | t.deepEqual(globbySyncFiles, gitFiles, 'globbySync matches Git'); 93 | t.deepEqual(globbyStreamFiles, gitFiles, 'globbyStream matches Git'); 94 | }; 95 | 96 | // Basic patterns 97 | test('simple file extension pattern', runGitignoreTest, { 98 | gitignore: '*.log', 99 | files: ['app.js', 'test.log', 'debug.log', 'src/index.js', 'src/error.log'], 100 | }); 101 | 102 | test('simple directory name', runGitignoreTest, { 103 | gitignore: 'node_modules', 104 | files: ['index.js', 'node_modules/pkg/index.js', 'node_modules/pkg/lib/utils.js'], 105 | }); 106 | 107 | test('directory with trailing slash', runGitignoreTest, { 108 | gitignore: 'build/', 109 | files: ['index.js', 'build/output.js', 'build/assets/style.css'], 110 | }); 111 | 112 | test('wildcard in filename', runGitignoreTest, { 113 | gitignore: 'test-*.js', 114 | files: ['app.js', 'test-unit.js', 'test-integration.js', 'testing.js'], 115 | }); 116 | 117 | test('question mark wildcard', runGitignoreTest, { 118 | gitignore: 'test?.js', 119 | files: ['test1.js', 'test2.js', 'testA.js', 'test12.js', 'test.js'], 120 | }); 121 | 122 | test('character class', runGitignoreTest, { 123 | gitignore: 'test[0-9].js', 124 | files: ['test0.js', 'test5.js', 'test9.js', 'testA.js', 'test.js'], 125 | }); 126 | 127 | // Note: Brace expansion is not standard gitignore - removing this test 128 | 129 | // Anchored patterns 130 | test('leading slash anchors to root', runGitignoreTest, { 131 | gitignore: '/build', 132 | files: ['build/output.js', 'src/build/temp.js', 'docs/build/index.html'], 133 | }); 134 | 135 | test('pattern with slash in middle', runGitignoreTest, { 136 | gitignore: 'src/temp', 137 | files: ['src/temp', 'src/app.js', 'lib/temp', 'temp'], 138 | }); 139 | 140 | // Patterns matching at any level 141 | test('pattern without slash matches recursively', runGitignoreTest, { 142 | gitignore: 'temp', 143 | files: ['temp', 'src/temp', 'src/lib/temp', 'tests/fixtures/temp', 'app.js'], 144 | }); 145 | 146 | test('pattern with trailing slash only matches directories', runGitignoreTest, { 147 | gitignore: 'temp/', 148 | files: ['temp/file.js', 'src/temp/data.js', 'app.js'], 149 | directories: ['temp', 'src/temp'], 150 | }); 151 | 152 | // Double asterisk patterns 153 | test('double asterisk in middle', runGitignoreTest, { 154 | gitignore: '**/temp/**', 155 | files: ['src/temp/file.js', 'lib/temp/data/info.json', 'temp/test.js', 'app.js'], 156 | }); 157 | 158 | test('double asterisk with extension', runGitignoreTest, { 159 | gitignore: '**/*.log', 160 | files: ['app.log', 'src/debug.log', 'src/lib/error.log', 'index.js'], 161 | }); 162 | 163 | // Negation patterns 164 | test('simple negation', runGitignoreTest, { 165 | gitignore: '*.log\n!important.log', 166 | files: ['app.log', 'important.log', 'debug.log', 'index.js'], 167 | }); 168 | 169 | test('multiple negations', runGitignoreTest, { 170 | gitignore: '*.log\n!important.log\n!debug.log', 171 | files: ['app.log', 'important.log', 'debug.log', 'error.log'], 172 | }); 173 | 174 | test('negation order matters', runGitignoreTest, { 175 | gitignore: '*.log\n!important.log\nimportant-test.log', 176 | files: ['app.log', 'important.log', 'important-test.log'], 177 | }); 178 | 179 | test('negation with wildcard for directory', runGitignoreTest, { 180 | gitignore: 'node_modules/*\n!node_modules/custom-pkg/', 181 | files: ['node_modules/react/index.js', 'node_modules/custom-pkg/index.js', 'node_modules/custom-pkg/lib/utils.js', 'index.js'], 182 | }); 183 | 184 | test('negation without wildcard does not re-include', runGitignoreTest, { 185 | gitignore: 'node_modules\n!node_modules/custom-pkg', 186 | files: ['node_modules/react/index.js', 'node_modules/custom-pkg/index.js', 'index.js'], 187 | }); 188 | 189 | test('nested negations', runGitignoreTest, { 190 | gitignore: 'build/*\n!build/assets/\nbuild/assets/*.cache', 191 | files: ['build/output.js', 'build/assets/style.css', 'build/assets/app.cache', 'build/cache/data.json'], 192 | }); 193 | 194 | // Multiple .gitignore files 195 | test('subdirectory gitignore', runGitignoreTest, { 196 | gitignore: { 197 | '.gitignore': '*.log', 198 | 'src/.gitignore': '*.tmp', 199 | }, 200 | files: ['app.log', 'index.js', 'src/test.tmp', 'src/app.js', 'src/debug.log'], 201 | }); 202 | 203 | test('subdirectory gitignore with negation', runGitignoreTest, { 204 | gitignore: { 205 | '.gitignore': '*.log', 206 | 'src/.gitignore': '!important.log', 207 | }, 208 | files: ['app.log', 'src/test.log', 'src/important.log', 'src/app.js'], 209 | }); 210 | 211 | test('deeply nested gitignore files', runGitignoreTest, { 212 | gitignore: { 213 | '.gitignore': '*.log', 214 | 'src/.gitignore': '*.tmp', 215 | 'src/lib/.gitignore': '*.cache', 216 | }, 217 | files: ['app.log', 'src/test.tmp', 'src/app.js', 'src/lib/data.cache', 'src/lib/index.js'], 218 | }); 219 | 220 | // Real-world scenarios 221 | test('typical node_modules ignore', runGitignoreTest, { 222 | gitignore: 'node_modules/', 223 | files: ['index.js', 'package.json', 'node_modules/react/index.js', 'node_modules/react/lib/React.js', 'node_modules/lodash/index.js'], 224 | }); 225 | 226 | test('node_modules with exception', runGitignoreTest, { 227 | gitignore: 'node_modules/*\n!node_modules/custom-pkg/', 228 | files: ['node_modules/custom-pkg/index.js', 'node_modules/custom-pkg/src/lib.js', 'node_modules/react/index.js', 'node_modules/lodash/index.js', 'index.js'], 229 | }); 230 | 231 | test('build directory with exceptions', runGitignoreTest, { 232 | gitignore: 'dist/*\n!dist/types/\n!dist/.gitkeep', 233 | files: ['dist/.gitkeep', 'dist/types/index.d.ts', 'dist/types/lib.d.ts', 'dist/bundles/main.js', 'dist/bundles/vendor.js', 'src/app.js'], 234 | }); 235 | 236 | test('coverage directory', runGitignoreTest, { 237 | gitignore: 'coverage/', 238 | files: ['coverage/index.html', 'coverage/lcov.info', 'coverage/src/app.js.html', 'src/app.js', 'tests/app.test.js'], 239 | }); 240 | 241 | test('IDE directories', runGitignoreTest, { 242 | gitignore: '.idea/\n.vscode/\n*.swp', 243 | files: ['.idea/workspace.xml', '.vscode/settings.json', 'index.js', 'temp.swp'], 244 | }); 245 | 246 | test('logs and temp files', runGitignoreTest, { 247 | gitignore: '*.log\n*.tmp\nlogs/\ntemp/', 248 | files: ['app.log', 'test.tmp', 'logs/debug.log', 'temp/data.json', 'src/app.js'], 249 | }); 250 | 251 | // Edge cases 252 | test('only comments', runGitignoreTest, { 253 | gitignore: '# This is a comment\n# Another comment', 254 | files: ['app.js', 'test.js'], 255 | }); 256 | 257 | test('comments and empty lines', runGitignoreTest, { 258 | gitignore: '# Comment\n*.log\n\n# Another comment\n*.tmp\n\n', 259 | files: ['app.log', 'test.tmp', 'index.js'], 260 | }); 261 | 262 | test('very deeply nested files', runGitignoreTest, { 263 | gitignore: '*.log', 264 | files: ['a/b/c/d/e/f/g/h/test.log', 'a/b/c/d/e/f/g/h/app.js'], 265 | }); 266 | 267 | test('dotfiles', runGitignoreTest, { 268 | gitignore: '.*\n!.gitignore', 269 | files: ['.env', '.config', '.eslintrc', 'app.js'], 270 | }); 271 | 272 | test('negation of dotfiles', runGitignoreTest, { 273 | gitignore: '.*\n!.env.example', 274 | files: ['.env', '.env.example', '.config', 'app.js'], 275 | }); 276 | 277 | test('multiple wildcards', runGitignoreTest, { 278 | gitignore: '*-test-*.js', 279 | files: ['app-test-unit.js', 'lib-test-integration.js', 'app.js', 'test.js'], 280 | }); 281 | 282 | test('complex nested structure', runGitignoreTest, { 283 | gitignore: '*.log\n*.tmp\nnode_modules/*\n!node_modules/keep/\nbuild/\n!build/public/\nbuild/public/*.cache', 284 | files: ['app.log', 'test.tmp', 'node_modules/keep/index.js', 'node_modules/react/index.js', 'build/public/index.html', 'build/public/app.cache', 'build/private/data.json', 'src/app.js'], 285 | }); 286 | 287 | test('pattern with multiple slashes', runGitignoreTest, { 288 | gitignore: 'src/lib/temp', 289 | files: ['src/lib/temp', 'src/lib/index.js', 'lib/temp', 'temp'], 290 | }); 291 | 292 | test('pattern with trailing slash and negation', runGitignoreTest, { 293 | gitignore: 'temp/\n!temp/keep/', 294 | files: ['temp/delete.js', 'temp/keep/important.js', 'app.js'], 295 | directories: ['temp', 'temp/keep'], 296 | }); 297 | 298 | // Performance-critical scenarios 299 | test('large node_modules structure', runGitignoreTest, { 300 | gitignore: 'node_modules/', 301 | files: [ 302 | 'index.js', 303 | ...Array.from({length: 20}, (_, i) => `node_modules/pkg${i}/index.js`), 304 | ...Array.from({length: 20}, (_, i) => `node_modules/pkg${i}/lib/utils.js`), 305 | ], 306 | }); 307 | 308 | test('many ignored extensions', runGitignoreTest, { 309 | gitignore: '*.log\n*.tmp\n*.cache\n*.swp\n*.bak\n*.old', 310 | files: ['app.log', 'test.tmp', 'data.cache', 'file.swp', 'backup.bak', 'version.old', 'index.js'], 311 | }); 312 | 313 | // Patterns with special characters 314 | test('pattern with brackets', runGitignoreTest, { 315 | gitignore: '*[generated]*', 316 | files: ['app[generated].js', 'test-generated-file.js', 'normal.js'], 317 | }); 318 | 319 | // Subdirectory patterns 320 | test('subdirectory wildcard pattern', runGitignoreTest, { 321 | gitignore: 'src/**/*.test.js', 322 | files: ['src/app.test.js', 'src/lib/utils.test.js', 'tests/app.test.js', 'src/app.js'], 323 | }); 324 | 325 | test('multiple directory levels', runGitignoreTest, { 326 | gitignore: 'a/b/c/', 327 | files: ['a/b/c/d.js', 'a/b/c/e.js', 'a/b/index.js', 'a/index.js'], 328 | directories: ['a/b/c'], 329 | }); 330 | 331 | // Mixed scenarios 332 | test('combination of anchored and recursive patterns', runGitignoreTest, { 333 | gitignore: '/temp\n*.log', 334 | files: ['temp/file.js', 'src/temp', 'app.log', 'src/debug.log', 'index.js'], 335 | directories: ['temp'], 336 | }); 337 | 338 | test('negation with different pattern types', runGitignoreTest, { 339 | gitignore: '*.js\n!src/**/*.js\n!*.config.js', 340 | files: ['app.js', 'webpack.config.js', 'src/index.js', 'src/lib/utils.js', 'tests/app.js'], 341 | }); 342 | 343 | test('overlapping patterns', runGitignoreTest, { 344 | gitignore: '*.log\ntest.*\n!test.js', 345 | files: ['app.log', 'test.log', 'test.js', 'test.tmp', 'index.js'], 346 | }); 347 | -------------------------------------------------------------------------------- /ignore.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import fs from 'node:fs'; 3 | import fsPromises from 'node:fs/promises'; 4 | import path from 'node:path'; 5 | import fastGlob from 'fast-glob'; 6 | import gitIgnore from 'ignore'; 7 | import isPathInside from 'is-path-inside'; 8 | import slash from 'slash'; 9 | import {toPath} from 'unicorn-magic/node'; 10 | import { 11 | isNegativePattern, 12 | bindFsMethod, 13 | promisifyFsMethod, 14 | findGitRoot, 15 | findGitRootSync, 16 | getParentGitignorePaths, 17 | } from './utilities.js'; 18 | 19 | const defaultIgnoredDirectories = [ 20 | '**/node_modules', 21 | '**/flow-typed', 22 | '**/coverage', 23 | '**/.git', 24 | ]; 25 | const ignoreFilesGlobOptions = { 26 | absolute: true, 27 | dot: true, 28 | }; 29 | 30 | export const GITIGNORE_FILES_PATTERN = '**/.gitignore'; 31 | 32 | const getReadFileMethod = fsImplementation => 33 | bindFsMethod(fsImplementation?.promises, 'readFile') 34 | ?? bindFsMethod(fsPromises, 'readFile') 35 | ?? promisifyFsMethod(fsImplementation, 'readFile'); 36 | 37 | const getReadFileSyncMethod = fsImplementation => 38 | bindFsMethod(fsImplementation, 'readFileSync') 39 | ?? bindFsMethod(fs, 'readFileSync'); 40 | 41 | const shouldSkipIgnoreFileError = (error, suppressErrors) => { 42 | if (!error) { 43 | return Boolean(suppressErrors); 44 | } 45 | 46 | if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { 47 | return true; 48 | } 49 | 50 | return Boolean(suppressErrors); 51 | }; 52 | 53 | const createIgnoreFileReadError = (filePath, error) => { 54 | if (error instanceof Error) { 55 | error.message = `Failed to read ignore file at ${filePath}: ${error.message}`; 56 | return error; 57 | } 58 | 59 | return new Error(`Failed to read ignore file at ${filePath}: ${String(error)}`); 60 | }; 61 | 62 | const processIgnoreFileCore = (filePath, readMethod, suppressErrors) => { 63 | try { 64 | const content = readMethod(filePath, 'utf8'); 65 | return {filePath, content}; 66 | } catch (error) { 67 | if (shouldSkipIgnoreFileError(error, suppressErrors)) { 68 | return undefined; 69 | } 70 | 71 | throw createIgnoreFileReadError(filePath, error); 72 | } 73 | }; 74 | 75 | const readIgnoreFilesSafely = async (paths, readFileMethod, suppressErrors) => { 76 | const fileResults = await Promise.all(paths.map(async filePath => { 77 | try { 78 | const content = await readFileMethod(filePath, 'utf8'); 79 | return {filePath, content}; 80 | } catch (error) { 81 | if (shouldSkipIgnoreFileError(error, suppressErrors)) { 82 | return undefined; 83 | } 84 | 85 | throw createIgnoreFileReadError(filePath, error); 86 | } 87 | })); 88 | 89 | return fileResults.filter(Boolean); 90 | }; 91 | 92 | const readIgnoreFilesSafelySync = (paths, readFileSyncMethod, suppressErrors) => paths 93 | .map(filePath => processIgnoreFileCore(filePath, readFileSyncMethod, suppressErrors)) 94 | .filter(Boolean); 95 | 96 | const dedupePaths = paths => { 97 | const seen = new Set(); 98 | return paths.filter(filePath => { 99 | if (seen.has(filePath)) { 100 | return false; 101 | } 102 | 103 | seen.add(filePath); 104 | return true; 105 | }); 106 | }; 107 | 108 | const globIgnoreFiles = (globFunction, patterns, normalizedOptions) => globFunction(patterns, { 109 | ...normalizedOptions, 110 | ...ignoreFilesGlobOptions, // Must be last to ensure absolute/dot flags stick 111 | }); 112 | 113 | const getParentIgnorePaths = (gitRoot, normalizedOptions) => gitRoot 114 | ? getParentGitignorePaths(gitRoot, normalizedOptions.cwd) 115 | : []; 116 | 117 | const combineIgnoreFilePaths = (gitRoot, normalizedOptions, childPaths) => dedupePaths([ 118 | ...getParentIgnorePaths(gitRoot, normalizedOptions), 119 | ...childPaths, 120 | ]); 121 | 122 | const buildIgnoreResult = (files, normalizedOptions, gitRoot) => { 123 | const baseDir = gitRoot || normalizedOptions.cwd; 124 | const patterns = getPatternsFromIgnoreFiles(files, baseDir); 125 | 126 | return { 127 | patterns, 128 | predicate: createIgnorePredicate(patterns, normalizedOptions.cwd, baseDir), 129 | usingGitRoot: Boolean(gitRoot && gitRoot !== normalizedOptions.cwd), 130 | }; 131 | }; 132 | 133 | // Apply base path to gitignore patterns based on .gitignore spec 2.22.1 134 | // https://git-scm.com/docs/gitignore#_pattern_format 135 | // See also https://github.com/sindresorhus/globby/issues/146 136 | const applyBaseToPattern = (pattern, base) => { 137 | if (!base) { 138 | return pattern; 139 | } 140 | 141 | const isNegative = isNegativePattern(pattern); 142 | const cleanPattern = isNegative ? pattern.slice(1) : pattern; 143 | 144 | // Check if pattern has non-trailing slashes 145 | const slashIndex = cleanPattern.indexOf('/'); 146 | const hasNonTrailingSlash = slashIndex !== -1 && slashIndex !== cleanPattern.length - 1; 147 | 148 | let result; 149 | if (!hasNonTrailingSlash) { 150 | // "If there is no separator at the beginning or middle of the pattern, 151 | // then the pattern may also match at any level below the .gitignore level." 152 | // So patterns like '*.log' or 'temp' or 'build/' (trailing slash) match recursively. 153 | result = path.posix.join(base, '**', cleanPattern); 154 | } else if (cleanPattern.startsWith('/')) { 155 | // "If there is a separator at the beginning [...] of the pattern, 156 | // then the pattern is relative to the directory level of the particular .gitignore file itself." 157 | // Leading slash anchors the pattern to the .gitignore's directory. 158 | result = path.posix.join(base, cleanPattern.slice(1)); 159 | } else { 160 | // "If there is a separator [...] middle [...] of the pattern, 161 | // then the pattern is relative to the directory level of the particular .gitignore file itself." 162 | // Patterns like 'src/foo' are relative to the .gitignore's directory. 163 | result = path.posix.join(base, cleanPattern); 164 | } 165 | 166 | return isNegative ? '!' + result : result; 167 | }; 168 | 169 | const parseIgnoreFile = (file, cwd) => { 170 | const base = slash(path.relative(cwd, path.dirname(file.filePath))); 171 | 172 | return file.content 173 | .split(/\r?\n/) 174 | .filter(line => line && !line.startsWith('#')) 175 | .map(pattern => applyBaseToPattern(pattern, base)); 176 | }; 177 | 178 | const toRelativePath = (fileOrDirectory, cwd) => { 179 | if (path.isAbsolute(fileOrDirectory)) { 180 | // When paths are equal, path.relative returns empty string which is valid 181 | // isPathInside returns false for equal paths, so check this case first 182 | const relativePath = path.relative(cwd, fileOrDirectory); 183 | if (relativePath && !isPathInside(fileOrDirectory, cwd)) { 184 | // Path is outside cwd - it cannot be ignored by patterns in cwd 185 | // Return undefined to indicate this path is outside scope 186 | return undefined; 187 | } 188 | 189 | return relativePath; 190 | } 191 | 192 | // Normalize relative paths: 193 | // - Git treats './foo' as 'foo' when checking against patterns 194 | // - Patterns starting with './' in .gitignore are invalid and don't match anything 195 | // - The ignore library expects normalized paths without './' prefix 196 | if (fileOrDirectory.startsWith('./')) { 197 | return fileOrDirectory.slice(2); 198 | } 199 | 200 | // Paths with ../ point outside cwd and cannot match patterns from this directory 201 | // Return undefined to indicate this path is outside scope 202 | if (fileOrDirectory.startsWith('../')) { 203 | return undefined; 204 | } 205 | 206 | return fileOrDirectory; 207 | }; 208 | 209 | const createIgnorePredicate = (patterns, cwd, baseDir) => { 210 | const ignores = gitIgnore().add(patterns); 211 | // Normalize to handle path separator and . / .. components consistently 212 | const resolvedCwd = path.normalize(path.resolve(cwd)); 213 | const resolvedBaseDir = path.normalize(path.resolve(baseDir)); 214 | 215 | return fileOrDirectory => { 216 | fileOrDirectory = toPath(fileOrDirectory); 217 | 218 | // Never ignore the cwd itself - use normalized comparison 219 | const normalizedPath = path.normalize(path.resolve(fileOrDirectory)); 220 | if (normalizedPath === resolvedCwd) { 221 | return false; 222 | } 223 | 224 | // Convert to relative path from baseDir (use normalized baseDir) 225 | const relativePath = toRelativePath(fileOrDirectory, resolvedBaseDir); 226 | 227 | // If path is outside baseDir (undefined), it can't be ignored by patterns 228 | if (relativePath === undefined) { 229 | return false; 230 | } 231 | 232 | return relativePath ? ignores.ignores(slash(relativePath)) : false; 233 | }; 234 | }; 235 | 236 | const normalizeOptions = (options = {}) => { 237 | const ignoreOption = options.ignore 238 | ? (Array.isArray(options.ignore) ? options.ignore : [options.ignore]) 239 | : []; 240 | 241 | const cwd = toPath(options.cwd) ?? process.cwd(); 242 | 243 | // Adjust deep option for fast-glob: fast-glob's deep counts differently than expected 244 | // User's deep: 0 = root only -> fast-glob needs: 1 245 | // User's deep: 1 = root + 1 level -> fast-glob needs: 2 246 | const deep = typeof options.deep === 'number' ? Math.max(0, options.deep) + 1 : Number.POSITIVE_INFINITY; 247 | 248 | // Only pass through specific fast-glob options that make sense for finding ignore files 249 | return { 250 | cwd, 251 | suppressErrors: options.suppressErrors ?? false, 252 | deep, 253 | ignore: [...ignoreOption, ...defaultIgnoredDirectories], 254 | followSymbolicLinks: options.followSymbolicLinks ?? true, 255 | concurrency: options.concurrency, 256 | throwErrorOnBrokenSymbolicLink: options.throwErrorOnBrokenSymbolicLink ?? false, 257 | fs: options.fs, 258 | }; 259 | }; 260 | 261 | const collectIgnoreFileArtifactsAsync = async (patterns, options, includeParentIgnoreFiles) => { 262 | const normalizedOptions = normalizeOptions(options); 263 | const childPaths = await globIgnoreFiles(fastGlob, patterns, normalizedOptions); 264 | const gitRoot = includeParentIgnoreFiles 265 | ? await findGitRoot(normalizedOptions.cwd, normalizedOptions.fs) 266 | : undefined; 267 | const allPaths = combineIgnoreFilePaths(gitRoot, normalizedOptions, childPaths); 268 | const readFileMethod = getReadFileMethod(normalizedOptions.fs); 269 | const files = await readIgnoreFilesSafely(allPaths, readFileMethod, normalizedOptions.suppressErrors); 270 | 271 | return {files, normalizedOptions, gitRoot}; 272 | }; 273 | 274 | const collectIgnoreFileArtifactsSync = (patterns, options, includeParentIgnoreFiles) => { 275 | const normalizedOptions = normalizeOptions(options); 276 | const childPaths = globIgnoreFiles(fastGlob.sync, patterns, normalizedOptions); 277 | const gitRoot = includeParentIgnoreFiles 278 | ? findGitRootSync(normalizedOptions.cwd, normalizedOptions.fs) 279 | : undefined; 280 | const allPaths = combineIgnoreFilePaths(gitRoot, normalizedOptions, childPaths); 281 | const readFileSyncMethod = getReadFileSyncMethod(normalizedOptions.fs); 282 | const files = readIgnoreFilesSafelySync(allPaths, readFileSyncMethod, normalizedOptions.suppressErrors); 283 | 284 | return {files, normalizedOptions, gitRoot}; 285 | }; 286 | 287 | export const isIgnoredByIgnoreFiles = async (patterns, options) => { 288 | const {files, normalizedOptions, gitRoot} = await collectIgnoreFileArtifactsAsync(patterns, options, false); 289 | return buildIgnoreResult(files, normalizedOptions, gitRoot).predicate; 290 | }; 291 | 292 | export const isIgnoredByIgnoreFilesSync = (patterns, options) => { 293 | const {files, normalizedOptions, gitRoot} = collectIgnoreFileArtifactsSync(patterns, options, false); 294 | return buildIgnoreResult(files, normalizedOptions, gitRoot).predicate; 295 | }; 296 | 297 | const getPatternsFromIgnoreFiles = (files, baseDir) => files.flatMap(file => parseIgnoreFile(file, baseDir)); 298 | 299 | /** 300 | Read ignore files and return both patterns and predicate. 301 | This avoids reading the same files twice (once for patterns, once for filtering). 302 | 303 | @param {string[]} patterns - Patterns to find ignore files 304 | @param {Object} options - Options object 305 | @param {boolean} [includeParentIgnoreFiles=false] - Whether to search for parent .gitignore files 306 | @returns {Promise<{patterns: string[], predicate: Function, usingGitRoot: boolean}>} 307 | */ 308 | export const getIgnorePatternsAndPredicate = async (patterns, options, includeParentIgnoreFiles = false) => { 309 | const {files, normalizedOptions, gitRoot} = await collectIgnoreFileArtifactsAsync( 310 | patterns, 311 | options, 312 | includeParentIgnoreFiles, 313 | ); 314 | 315 | return buildIgnoreResult(files, normalizedOptions, gitRoot); 316 | }; 317 | 318 | /** 319 | Read ignore files and return both patterns and predicate (sync version). 320 | 321 | @param {string[]} patterns - Patterns to find ignore files 322 | @param {Object} options - Options object 323 | @param {boolean} [includeParentIgnoreFiles=false] - Whether to search for parent .gitignore files 324 | @returns {{patterns: string[], predicate: Function, usingGitRoot: boolean}} 325 | */ 326 | export const getIgnorePatternsAndPredicateSync = (patterns, options, includeParentIgnoreFiles = false) => { 327 | const {files, normalizedOptions, gitRoot} = collectIgnoreFileArtifactsSync( 328 | patterns, 329 | options, 330 | includeParentIgnoreFiles, 331 | ); 332 | 333 | return buildIgnoreResult(files, normalizedOptions, gitRoot); 334 | }; 335 | 336 | export const isGitIgnored = options => isIgnoredByIgnoreFiles(GITIGNORE_FILES_PATTERN, options); 337 | export const isGitIgnoredSync = options => isIgnoredByIgnoreFilesSync(GITIGNORE_FILES_PATTERN, options); 338 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # globby 2 | 3 | > User-friendly glob matching 4 | 5 | Based on [`fast-glob`](https://github.com/mrmlnc/fast-glob) but adds a bunch of useful features. 6 | 7 | ## Features 8 | 9 | - Promise API 10 | - Multiple patterns 11 | - Negated patterns: `['foo*', '!foobar']` 12 | - Negation-only patterns: `['!foobar']` → matches all files except `foobar` 13 | - Expands directories: `foo` → `foo/**/*` 14 | - Supports `.gitignore` and similar ignore config files 15 | - Supports `URL` as `cwd` 16 | 17 | ## Install 18 | 19 | ```sh 20 | npm install globby 21 | ``` 22 | 23 | ## Usage 24 | 25 | ``` 26 | ├── unicorn 27 | ├── cake 28 | └── rainbow 29 | ``` 30 | 31 | ```js 32 | import {globby} from 'globby'; 33 | 34 | const paths = await globby(['*', '!cake']); 35 | 36 | console.log(paths); 37 | //=> ['unicorn', 'rainbow'] 38 | ``` 39 | 40 | ## API 41 | 42 | 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()`. 43 | 44 | **Windows:** Patterns with backslashes will silently fail. Use `path.posix.join()` or [`convertPathToPattern()`](#convertpathtopatternpath). 45 | 46 | ### globby(patterns, options?) 47 | 48 | Returns a `Promise` of matching paths. 49 | 50 | #### patterns 51 | 52 | Type: `string | string[]` 53 | 54 | See supported `minimatch` [patterns](https://github.com/isaacs/minimatch#usage). 55 | 56 | #### options 57 | 58 | Type: `object` 59 | 60 | See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones below. 61 | 62 | ##### expandDirectories 63 | 64 | Type: `boolean | string[] | object`\ 65 | Default: `true` 66 | 67 | 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: 68 | 69 | ```js 70 | import {globby} from 'globby'; 71 | 72 | const paths = await globby('images', { 73 | expandDirectories: { 74 | files: ['cat', 'unicorn', '*.jpg'], 75 | extensions: ['png'] 76 | } 77 | }); 78 | 79 | console.log(paths); 80 | //=> ['cat.png', 'unicorn.png', 'cow.jpg', 'rainbow.jpg'] 81 | ``` 82 | 83 | Note that if you set this option to `false`, you won't get back matched directories unless you set `onlyFiles: false`. 84 | 85 | ##### gitignore 86 | 87 | Type: `boolean`\ 88 | Default: `false` 89 | 90 | Respect ignore patterns in `.gitignore` files that apply to the globbed files. 91 | 92 | 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. 93 | 94 | Gitignore patterns take priority over user patterns, matching Git's behavior. To include gitignored files, set this to `false`. 95 | 96 | **Performance:** Globby reads `.gitignore` files before globbing. When there are no negation patterns (like `!important.log`) and no parent `.gitignore` files are found, it passes ignore patterns to fast-glob to skip traversing ignored directories entirely, which significantly improves performance for large `node_modules` or build directories. When negation patterns or parent `.gitignore` files are present, all filtering is done after traversal to ensure correct Git-compatible behavior. For optimal performance, prefer specific `.gitignore` patterns without negations, or use `ignoreFiles: '.gitignore'` to target only the root ignore file. 97 | 98 | ##### ignoreFiles 99 | 100 | Type: `string | string[]`\ 101 | Default: `undefined` 102 | 103 | Glob patterns to look for ignore files, which are then used to ignore globbed files. 104 | 105 | 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. 106 | 107 | **Performance tip:** Using a specific path like `'.gitignore'` is much faster than recursive patterns. 108 | 109 | ##### expandNegationOnlyPatterns 110 | 111 | Type: `boolean`\ 112 | Default: `true` 113 | 114 | When only negation patterns are provided (e.g., `['!*.json']`), automatically prepend a catch-all pattern (`**/*`) to match all files before applying negations. 115 | 116 | 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. 117 | 118 | ```js 119 | import {globby} from 'globby'; 120 | 121 | // Default behavior: matches all files except .json 122 | await globby(['!*.json']); 123 | //=> ['file.txt', 'image.png', ...] 124 | 125 | // Disable expansion: returns empty array 126 | await globby(['!*.json'], {expandNegationOnlyPatterns: false}); 127 | //=> [] 128 | ``` 129 | 130 | ##### fs 131 | 132 | Type: [`FileSystemAdapter`](https://github.com/mrmlnc/fast-glob#fs)\ 133 | Default: `undefined` 134 | 135 | Custom file system implementation (useful for testing or virtual file systems). 136 | 137 | **Note:** When using `gitignore` or `ignoreFiles`, the custom fs must also provide `readFile`/`readFileSync` methods. 138 | 139 | ### globbySync(patterns, options?) 140 | 141 | Returns `string[]` of matching paths. 142 | 143 | ### globbyStream(patterns, options?) 144 | 145 | Returns a [`stream.Readable`](https://nodejs.org/api/stream.html#stream_readable_streams) of matching paths. 146 | 147 | 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: 148 | 149 | ```js 150 | import {globbyStream} from 'globby'; 151 | 152 | for await (const path of globbyStream('*.tmp')) { 153 | console.log(path); 154 | } 155 | ``` 156 | 157 | ### convertPathToPattern(path) 158 | 159 | Converts a path to a pattern by escaping special glob characters like `()`, `[]`, `{}`. On Windows, also converts backslashes to forward slashes. 160 | 161 | Use this when your literal paths contain characters with special meaning in globs. 162 | 163 | ```js 164 | import {globby, convertPathToPattern} from 'globby'; 165 | 166 | // ❌ Fails - parentheses are glob syntax 167 | await globby('C:/Program Files (x86)/*.txt'); 168 | //=> [] 169 | 170 | // ✅ Works 171 | const base = convertPathToPattern('C:/Program Files (x86)'); 172 | await globby(`${base}/*.txt`); 173 | //=> ['C:/Program Files (x86)/file.txt'] 174 | ``` 175 | 176 | [Learn more.](https://github.com/mrmlnc/fast-glob#convertpathtopatternpath) 177 | 178 | ### generateGlobTasks(patterns, options?) 179 | 180 | Returns an `Promise` 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. 181 | 182 | 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. 183 | 184 | ### generateGlobTasksSync(patterns, options?) 185 | 186 | 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. 187 | 188 | Takes the same arguments as `generateGlobTasks`. 189 | 190 | ### isDynamicPattern(patterns, options?) 191 | 192 | Returns a `boolean` of whether there are any special glob characters in the `patterns`. 193 | 194 | Note that the options affect the results. 195 | 196 | This function is backed by [`fast-glob`](https://github.com/mrmlnc/fast-glob#isdynamicpatternpattern-options). 197 | 198 | ### isGitIgnored(options?) 199 | 200 | Returns a `Promise<(path: URL | string) => boolean>` indicating whether a given path is ignored via a `.gitignore` file. 201 | 202 | #### options 203 | 204 | Type: `object` 205 | 206 | ##### cwd 207 | 208 | Type: `URL | string`\ 209 | Default: `process.cwd()` 210 | 211 | The current working directory in which to search. 212 | 213 | ##### suppressErrors 214 | 215 | Type: `boolean`\ 216 | Default: `false` 217 | 218 | Suppress errors when encountering directories or files without read permissions. 219 | 220 | ##### deep 221 | 222 | Type: `number`\ 223 | Default: `Infinity` 224 | 225 | Maximum depth to search for `.gitignore` files. 226 | 227 | - `0` - Only search in the start directory 228 | - `1` - Search in the start directory and one level of subdirectories 229 | - `2` - Search in the start directory and two levels of subdirectories 230 | 231 | ##### ignore 232 | 233 | Type: `string | string[]`\ 234 | Default: `[]` 235 | 236 | Glob patterns to exclude from `.gitignore` file search. 237 | 238 | ##### followSymbolicLinks 239 | 240 | Type: `boolean`\ 241 | Default: `true` 242 | 243 | Indicates whether to traverse descendants of symbolic link directories. 244 | 245 | ##### concurrency 246 | 247 | Type: `number`\ 248 | Default: `os.cpus().length` 249 | 250 | Specifies the maximum number of concurrent requests from a reader to read directories. 251 | 252 | ##### throwErrorOnBrokenSymbolicLink 253 | 254 | Type: `boolean`\ 255 | Default: `false` 256 | 257 | Throw an error when symbolic link is broken if `true` or safely return `lstat` call if `false`. 258 | 259 | ##### fs 260 | 261 | Type: [`FileSystemAdapter`](https://github.com/mrmlnc/fast-glob#fs)\ 262 | Default: `undefined` 263 | 264 | Custom file system implementation (useful for testing or virtual file systems). 265 | 266 | **Note:** The custom fs must provide `readFile`/`readFileSync` methods for reading `.gitignore` files. 267 | 268 | ```js 269 | import {isGitIgnored} from 'globby'; 270 | 271 | const isIgnored = await isGitIgnored(); 272 | 273 | console.log(isIgnored('some/file')); 274 | ``` 275 | 276 | ```js 277 | // Suppress errors when encountering unreadable directories 278 | const isIgnored = await isGitIgnored({suppressErrors: true}); 279 | ``` 280 | 281 | ```js 282 | // Limit search depth and exclude certain directories 283 | const isIgnored = await isGitIgnored({ 284 | deep: 2, 285 | ignore: ['**/node_modules/**', '**/dist/**'] 286 | }); 287 | ``` 288 | 289 | ### isGitIgnoredSync(options?) 290 | 291 | Returns a `(path: URL | string) => boolean` indicating whether a given path is ignored via a `.gitignore` file. 292 | 293 | See [`isGitIgnored`](#isgitignoredoptions) for options. 294 | 295 | 296 | ### isIgnoredByIgnoreFiles(patterns, options?) 297 | 298 | Returns a `Promise<(path: URL | string) => boolean>` indicating whether a given path is ignored via the ignore files. 299 | 300 | 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. 301 | 302 | #### patterns 303 | 304 | Type: `string | string[]` 305 | 306 | Glob patterns to look for ignore files. 307 | 308 | #### options 309 | 310 | Type: `object` 311 | 312 | See [`isGitIgnored` options](#isgitignoredoptions) for all available options. 313 | 314 | ```js 315 | import {isIgnoredByIgnoreFiles} from 'globby'; 316 | 317 | const isIgnored = await isIgnoredByIgnoreFiles("**/.gitignore"); 318 | 319 | console.log(isIgnored('some/file')); 320 | ``` 321 | 322 | ```js 323 | // Suppress errors when encountering unreadable directories 324 | const isIgnored = await isIgnoredByIgnoreFiles("**/.eslintignore", {suppressErrors: true}); 325 | ``` 326 | 327 | ```js 328 | // Limit search depth and concurrency 329 | const isIgnored = await isIgnoredByIgnoreFiles("**/.prettierignore", { 330 | deep: 3, 331 | concurrency: 4 332 | }); 333 | ``` 334 | 335 | ### isIgnoredByIgnoreFilesSync(patterns, options?) 336 | 337 | Returns a `(path: URL | string) => boolean` indicating whether a given path is ignored via the ignore files. 338 | 339 | 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. 340 | 341 | See [`isIgnoredByIgnoreFiles`](#isignoredbyignorefilespatterns-options) for patterns and options. 342 | 343 | ```js 344 | import {isIgnoredByIgnoreFilesSync} from 'globby'; 345 | 346 | const isIgnored = isIgnoredByIgnoreFilesSync("**/.gitignore"); 347 | 348 | console.log(isIgnored('some/file')); 349 | ``` 350 | 351 | ## Globbing patterns 352 | 353 | Just a quick overview. 354 | 355 | - `*` matches any number of characters, but not `/` 356 | - `?` matches a single character, but not `/` 357 | - `**` matches any number of characters, including `/`, as long as it's the only thing in a path part 358 | - `{}` allows for a comma-separated list of "or" expressions 359 | - `!` at the beginning of a pattern will negate the match 360 | 361 | ### Negation patterns 362 | 363 | Globby supports negation patterns to exclude files. There are two ways to use them: 364 | 365 | **With positive patterns:** 366 | ```js 367 | await globby(['src/**/*.js', '!src/**/*.test.js']); 368 | // Matches all .js files except test files 369 | ``` 370 | 371 | **Negation-only patterns:** 372 | ```js 373 | await globby(['!*.json', '!*.xml'], {cwd: 'config'}); 374 | // Matches all files in config/ except .json and .xml files 375 | ``` 376 | 377 | 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']`. 378 | 379 | **Note:** The prepended `**/*` pattern respects the `dot` option. By default, dotfiles (files starting with `.`) are not matched unless you set `dot: true`. 380 | 381 | [Various patterns and expected matches.](https://github.com/sindresorhus/multimatch/blob/main/test/test.js) 382 | 383 | ## Related 384 | 385 | - [multimatch](https://github.com/sindresorhus/multimatch) - Match against a list instead of the filesystem 386 | - [matcher](https://github.com/sindresorhus/matcher) - Simple wildcard matching 387 | - [del](https://github.com/sindresorhus/del) - Delete files and directories 388 | - [make-dir](https://github.com/sindresorhus/make-dir) - Make a directory and its parents if needed 389 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import type FastGlob from 'fast-glob'; 2 | 3 | export type GlobEntry = FastGlob.Entry; 4 | 5 | export type GlobTask = { 6 | readonly patterns: string[]; 7 | readonly options: Options; 8 | }; 9 | 10 | export type ExpandDirectoriesOption = 11 | | boolean 12 | | readonly string[] 13 | | {files?: readonly string[]; extensions?: readonly string[]}; 14 | 15 | type FastGlobOptionsWithoutCwd = Omit; 16 | 17 | export type Options = { 18 | /** 19 | 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. 20 | 21 | Note that if you set this option to `false`, you won't get back matched directories unless you set `onlyFiles: false`. 22 | 23 | @default true 24 | 25 | @example 26 | ``` 27 | import {globby} from 'globby'; 28 | 29 | const paths = await globby('images', { 30 | expandDirectories: { 31 | files: ['cat', 'unicorn', '*.jpg'], 32 | extensions: ['png'] 33 | } 34 | }); 35 | 36 | console.log(paths); 37 | //=> ['cat.png', 'unicorn.png', 'cow.jpg', 'rainbow.jpg'] 38 | ``` 39 | */ 40 | readonly expandDirectories?: ExpandDirectoriesOption; 41 | 42 | /** 43 | Respect ignore patterns in `.gitignore` files that apply to the globbed files. 44 | 45 | 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. 46 | 47 | Gitignore patterns take priority over user patterns, matching Git's behavior. To include gitignored files, set this to `false`. 48 | 49 | 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. 50 | 51 | @default false 52 | */ 53 | readonly gitignore?: boolean; 54 | 55 | /** 56 | Glob patterns to look for ignore files, which are then used to ignore globbed files. 57 | 58 | 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. 59 | 60 | Performance tip: Using a specific path like `'.gitignore'` is much faster than recursive patterns. 61 | 62 | @default undefined 63 | */ 64 | readonly ignoreFiles?: string | readonly string[]; 65 | 66 | /** 67 | When only negation patterns are provided (e.g., `['!*.json']`), automatically prepend a catch-all pattern (`**\/*`) to match all files before applying negations. 68 | 69 | 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. 70 | 71 | @default true 72 | 73 | @example 74 | ``` 75 | import {globby} from 'globby'; 76 | 77 | // Default behavior: matches all files except .json 78 | await globby(['!*.json']); 79 | //=> ['file.txt', 'image.png', ...] 80 | 81 | // Disable expansion: returns empty array 82 | await globby(['!*.json'], {expandNegationOnlyPatterns: false}); 83 | //=> [] 84 | ``` 85 | */ 86 | readonly expandNegationOnlyPatterns?: boolean; 87 | 88 | /** 89 | The current working directory in which to search. 90 | 91 | @default process.cwd() 92 | */ 93 | readonly cwd?: URL | string; 94 | } & FastGlobOptionsWithoutCwd; 95 | 96 | export type GitignoreOptions = { 97 | /** 98 | The current working directory in which to search. 99 | 100 | @default process.cwd() 101 | */ 102 | readonly cwd?: URL | string; 103 | 104 | /** 105 | Suppress errors when encountering directories or files without read permissions. 106 | 107 | By default, fast-glob only suppresses `ENOENT` errors. Set to `true` to suppress any error. 108 | 109 | @default false 110 | */ 111 | readonly suppressErrors?: boolean; 112 | 113 | /** 114 | Specifies the maximum depth of ignore file search relative to the start directory. 115 | 116 | @default Infinity 117 | */ 118 | readonly deep?: number; 119 | 120 | /** 121 | Glob patterns to exclude from ignore file search. 122 | 123 | @default [] 124 | */ 125 | readonly ignore?: string | readonly string[]; 126 | 127 | /** 128 | Indicates whether to traverse descendants of symbolic link directories. 129 | 130 | @default true 131 | */ 132 | readonly followSymbolicLinks?: boolean; 133 | 134 | /** 135 | Specifies the maximum number of concurrent requests from a reader to read directories. 136 | 137 | @default os.cpus().length 138 | */ 139 | readonly concurrency?: number; 140 | 141 | /** 142 | Throw an error when symbolic link is broken if `true` or safely return `lstat` call if `false`. 143 | 144 | @default false 145 | */ 146 | readonly throwErrorOnBrokenSymbolicLink?: boolean; 147 | 148 | /** 149 | Custom file system implementation (useful for testing or virtual file systems). 150 | 151 | @default undefined 152 | */ 153 | readonly fs?: FastGlob.Options['fs']; 154 | }; 155 | 156 | export type GlobbyFilterFunction = (path: URL | string) => boolean; 157 | 158 | type AsyncIterableReadable = Omit & { 159 | [Symbol.asyncIterator](): NodeJS.AsyncIterator; 160 | }; 161 | 162 | /** 163 | A readable stream that yields string paths from glob patterns. 164 | */ 165 | export type GlobbyStream = AsyncIterableReadable; 166 | 167 | /** 168 | A readable stream that yields `GlobEntry` objects from glob patterns when `objectMode` is enabled. 169 | */ 170 | export type GlobbyEntryStream = AsyncIterableReadable; 171 | 172 | /** 173 | Find files and directories using glob patterns. 174 | 175 | 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()`. 176 | 177 | Windows: Patterns with backslashes will silently fail. Use `path.posix.join()` or `convertPathToPattern()`. 178 | 179 | @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. 180 | @param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package. 181 | @returns The matching paths. 182 | 183 | @example 184 | ``` 185 | import {globby} from 'globby'; 186 | 187 | const paths = await globby(['*', '!cake']); 188 | 189 | console.log(paths); 190 | //=> ['unicorn', 'rainbow'] 191 | ``` 192 | 193 | @example 194 | ``` 195 | import {globby} from 'globby'; 196 | 197 | // Negation-only patterns match all files except the negated ones 198 | const paths = await globby(['!*.json', '!*.xml'], {cwd: 'config'}); 199 | 200 | console.log(paths); 201 | //=> ['config.js', 'settings.yaml'] 202 | ``` 203 | */ 204 | export function globby( 205 | patterns: string | readonly string[], 206 | options: Options & ({objectMode: true} | {stats: true}) 207 | ): Promise; 208 | export function globby( 209 | patterns: string | readonly string[], 210 | options?: Options 211 | ): Promise; 212 | 213 | /** 214 | Find files and directories using glob patterns. 215 | 216 | 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()`. 217 | 218 | @param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns). 219 | @param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package. 220 | @returns The matching paths. 221 | */ 222 | export function globbySync( 223 | patterns: string | readonly string[], 224 | options: Options & ({objectMode: true} | {stats: true}) 225 | ): GlobEntry[]; 226 | export function globbySync( 227 | patterns: string | readonly string[], 228 | options?: Options 229 | ): string[]; 230 | 231 | /** 232 | Find files and directories using glob patterns. 233 | 234 | 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()`. 235 | 236 | @param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns). 237 | @param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package. 238 | @returns The stream of matching paths. 239 | 240 | @example 241 | ``` 242 | import {globbyStream} from 'globby'; 243 | 244 | for await (const path of globbyStream('*.tmp')) { 245 | console.log(path); 246 | } 247 | ``` 248 | */ 249 | export function globbyStream( 250 | patterns: string | readonly string[], 251 | options: Options & ({objectMode: true} | {stats: true}) 252 | ): GlobbyEntryStream; 253 | export function globbyStream( 254 | patterns: string | readonly string[], 255 | options?: Options 256 | ): GlobbyStream; 257 | 258 | /** 259 | 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. 260 | 261 | @param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns). 262 | @param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package. 263 | @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. 264 | */ 265 | export function generateGlobTasks( 266 | patterns: string | readonly string[], 267 | options?: Options 268 | ): Promise; 269 | 270 | /** 271 | @see generateGlobTasks 272 | 273 | @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. 274 | */ 275 | export function generateGlobTasksSync( 276 | patterns: string | readonly string[], 277 | options?: Options 278 | ): GlobTask[]; 279 | 280 | /** 281 | Note that the options affect the results. 282 | 283 | This function is backed by [`fast-glob`](https://github.com/mrmlnc/fast-glob#isdynamicpatternpattern-options). 284 | 285 | @param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns). 286 | @param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3). 287 | @returns Whether there are any special glob characters in the `patterns`. 288 | */ 289 | export function isDynamicPattern( 290 | patterns: string | readonly string[], 291 | options?: FastGlobOptionsWithoutCwd & { 292 | /** 293 | The current working directory in which to search. 294 | 295 | @default process.cwd() 296 | */ 297 | readonly cwd?: URL | string; 298 | } 299 | ): boolean; 300 | 301 | /** 302 | `.gitignore` files matched by the ignore config are not used for the resulting filter function. 303 | 304 | @returns A filter function indicating whether a given path is ignored via a `.gitignore` file. 305 | 306 | @example 307 | ``` 308 | import {isGitIgnored} from 'globby'; 309 | 310 | const isIgnored = await isGitIgnored(); 311 | 312 | console.log(isIgnored('some/file')); 313 | ``` 314 | */ 315 | export function isGitIgnored(options?: GitignoreOptions): Promise; 316 | 317 | /** 318 | @see isGitIgnored 319 | 320 | @returns A filter function indicating whether a given path is ignored via a `.gitignore` file. 321 | */ 322 | export function isGitIgnoredSync(options?: GitignoreOptions): GlobbyFilterFunction; 323 | 324 | /** 325 | Converts a path to a pattern by escaping special glob characters like `()`, `[]`, `{}`. On Windows, also converts backslashes to forward slashes. 326 | 327 | Use this when your literal paths contain characters with special meaning in globs. 328 | 329 | @param source - A file system path to convert to a safe glob pattern. 330 | @returns The path with special glob characters escaped. 331 | 332 | @example 333 | ``` 334 | import {globby, convertPathToPattern} from 'globby'; 335 | 336 | // ❌ Fails - parentheses are glob syntax 337 | await globby('C:/Program Files (x86)/*.txt'); 338 | //=> [] 339 | 340 | // ✅ Works 341 | const base = convertPathToPattern('C:/Program Files (x86)'); 342 | await globby(`${base}/*.txt`); 343 | //=> ['C:/Program Files (x86)/file.txt'] 344 | ``` 345 | */ 346 | export function convertPathToPattern(source: string): FastGlob.Pattern; 347 | 348 | /** 349 | Check if a path is ignored by the ignore files. 350 | 351 | @param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns). 352 | @param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package. 353 | @returns A filter function indicating whether a given path is ignored via the ignore files. 354 | 355 | 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. 356 | 357 | @example 358 | ``` 359 | import {isIgnoredByIgnoreFiles} from 'globby'; 360 | 361 | const isIgnored = await isIgnoredByIgnoreFiles('**\/.gitignore'); 362 | 363 | console.log(isIgnored('some/file')); 364 | ``` 365 | */ 366 | export function isIgnoredByIgnoreFiles( 367 | patterns: string | readonly string[], 368 | options?: Options 369 | ): Promise; 370 | 371 | /** 372 | Check if a path is ignored by the ignore files. 373 | 374 | @param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns). 375 | @param options - See the [`fast-glob` options](https://github.com/mrmlnc/fast-glob#options-3) in addition to the ones in this package. 376 | @returns A filter function indicating whether a given path is ignored via the ignore files. 377 | 378 | 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. 379 | 380 | @see {@link isIgnoredByIgnoreFiles} 381 | 382 | @example 383 | ``` 384 | import {isIgnoredByIgnoreFilesSync} from 'globby'; 385 | 386 | const isIgnored = isIgnoredByIgnoreFilesSync('**\/.gitignore'); 387 | 388 | console.log(isIgnored('some/file')); 389 | ``` 390 | */ 391 | export function isIgnoredByIgnoreFilesSync( 392 | patterns: string | readonly string[], 393 | options?: Options 394 | ): GlobbyFilterFunction; 395 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import fs from 'node:fs'; 3 | import nodePath from 'node:path'; 4 | import {Readable} from 'node:stream'; 5 | import mergeStreams from '@sindresorhus/merge-streams'; 6 | import fastGlob from 'fast-glob'; 7 | import {toPath} from 'unicorn-magic/node'; 8 | import { 9 | GITIGNORE_FILES_PATTERN, 10 | getIgnorePatternsAndPredicate, 11 | getIgnorePatternsAndPredicateSync, 12 | } from './ignore.js'; 13 | import { 14 | bindFsMethod, 15 | promisifyFsMethod, 16 | isNegativePattern, 17 | normalizeAbsolutePatternToRelative, 18 | normalizeDirectoryPatternForFastGlob, 19 | adjustIgnorePatternsForParentDirectories, 20 | convertPatternsForFastGlob, 21 | } from './utilities.js'; 22 | 23 | const assertPatternsInput = patterns => { 24 | if (patterns.some(pattern => typeof pattern !== 'string')) { 25 | throw new TypeError('Patterns must be a string or an array of strings'); 26 | } 27 | }; 28 | 29 | const getStatMethod = fsImplementation => 30 | bindFsMethod(fsImplementation?.promises, 'stat') 31 | ?? bindFsMethod(fs.promises, 'stat') 32 | ?? promisifyFsMethod(fsImplementation, 'stat'); 33 | 34 | const getStatSyncMethod = fsImplementation => 35 | bindFsMethod(fsImplementation, 'statSync') 36 | ?? bindFsMethod(fs, 'statSync'); 37 | 38 | const isDirectory = async (path, fsImplementation) => { 39 | try { 40 | const stats = await getStatMethod(fsImplementation)(path); 41 | return stats.isDirectory(); 42 | } catch { 43 | return false; 44 | } 45 | }; 46 | 47 | const isDirectorySync = (path, fsImplementation) => { 48 | try { 49 | const stats = getStatSyncMethod(fsImplementation)(path); 50 | return stats.isDirectory(); 51 | } catch { 52 | return false; 53 | } 54 | }; 55 | 56 | const normalizePathForDirectoryGlob = (filePath, cwd) => { 57 | const path = isNegativePattern(filePath) ? filePath.slice(1) : filePath; 58 | return nodePath.isAbsolute(path) ? path : nodePath.join(cwd, path); 59 | }; 60 | 61 | const shouldExpandGlobstarDirectory = pattern => { 62 | const match = pattern?.match(/\*\*\/([^/]+)$/); 63 | if (!match) { 64 | return false; 65 | } 66 | 67 | const dirname = match[1]; 68 | const hasWildcards = /[*?[\]{}]/.test(dirname); 69 | const hasExtension = nodePath.extname(dirname) && !dirname.startsWith('.'); 70 | 71 | return !hasWildcards && !hasExtension; 72 | }; 73 | 74 | const getDirectoryGlob = ({directoryPath, files, extensions}) => { 75 | const extensionGlob = extensions?.length > 0 ? `.${extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]}` : ''; 76 | return files 77 | ? files.map(file => nodePath.posix.join(directoryPath, `**/${nodePath.extname(file) ? file : `${file}${extensionGlob}`}`)) 78 | : [nodePath.posix.join(directoryPath, `**${extensionGlob ? `/*${extensionGlob}` : ''}`)]; 79 | }; 80 | 81 | const directoryToGlob = async (directoryPaths, { 82 | cwd = process.cwd(), 83 | files, 84 | extensions, 85 | fs: fsImplementation, 86 | } = {}) => { 87 | const globs = await Promise.all(directoryPaths.map(async directoryPath => { 88 | // Check pattern without negative prefix 89 | const checkPattern = isNegativePattern(directoryPath) ? directoryPath.slice(1) : directoryPath; 90 | 91 | // Expand globstar directory patterns like **/dirname to **/dirname/** 92 | if (shouldExpandGlobstarDirectory(checkPattern)) { 93 | return getDirectoryGlob({directoryPath, files, extensions}); 94 | } 95 | 96 | // Original logic for checking actual directories 97 | const pathToCheck = normalizePathForDirectoryGlob(directoryPath, cwd); 98 | return (await isDirectory(pathToCheck, fsImplementation)) ? getDirectoryGlob({directoryPath, files, extensions}) : directoryPath; 99 | })); 100 | 101 | return globs.flat(); 102 | }; 103 | 104 | const directoryToGlobSync = (directoryPaths, { 105 | cwd = process.cwd(), 106 | files, 107 | extensions, 108 | fs: fsImplementation, 109 | } = {}) => directoryPaths.flatMap(directoryPath => { 110 | // Check pattern without negative prefix 111 | const checkPattern = isNegativePattern(directoryPath) ? directoryPath.slice(1) : directoryPath; 112 | 113 | // Expand globstar directory patterns like **/dirname to **/dirname/** 114 | if (shouldExpandGlobstarDirectory(checkPattern)) { 115 | return getDirectoryGlob({directoryPath, files, extensions}); 116 | } 117 | 118 | // Original logic for checking actual directories 119 | const pathToCheck = normalizePathForDirectoryGlob(directoryPath, cwd); 120 | return isDirectorySync(pathToCheck, fsImplementation) ? getDirectoryGlob({directoryPath, files, extensions}) : directoryPath; 121 | }); 122 | 123 | const toPatternsArray = patterns => { 124 | patterns = [...new Set([patterns].flat())]; 125 | assertPatternsInput(patterns); 126 | return patterns; 127 | }; 128 | 129 | const checkCwdOption = (cwd, fsImplementation = fs) => { 130 | if (!cwd || !fsImplementation.statSync) { 131 | return; 132 | } 133 | 134 | let stats; 135 | try { 136 | stats = fsImplementation.statSync(cwd); 137 | } catch { 138 | // If stat fails (e.g., path doesn't exist), let fast-glob handle it 139 | return; 140 | } 141 | 142 | if (!stats.isDirectory()) { 143 | throw new Error(`The \`cwd\` option must be a path to a directory, got: ${cwd}`); 144 | } 145 | }; 146 | 147 | const normalizeOptions = (options = {}) => { 148 | // Normalize ignore to an array (fast-glob accepts string but we need array internally) 149 | const ignore = options.ignore 150 | ? (Array.isArray(options.ignore) ? options.ignore : [options.ignore]) 151 | : []; 152 | 153 | options = { 154 | ...options, 155 | ignore, 156 | expandDirectories: options.expandDirectories ?? true, 157 | cwd: toPath(options.cwd), 158 | }; 159 | 160 | checkCwdOption(options.cwd, options.fs); 161 | 162 | return options; 163 | }; 164 | 165 | const normalizeArguments = function_ => async (patterns, options) => function_(toPatternsArray(patterns), normalizeOptions(options)); 166 | const normalizeArgumentsSync = function_ => (patterns, options) => function_(toPatternsArray(patterns), normalizeOptions(options)); 167 | 168 | const getIgnoreFilesPatterns = options => { 169 | const {ignoreFiles, gitignore} = options; 170 | 171 | const patterns = ignoreFiles ? toPatternsArray(ignoreFiles) : []; 172 | if (gitignore) { 173 | patterns.push(GITIGNORE_FILES_PATTERN); 174 | } 175 | 176 | return patterns; 177 | }; 178 | 179 | /** 180 | Apply gitignore patterns to options and return filter predicate. 181 | 182 | 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. 183 | 184 | When there are no negations, we optimize by passing patterns to fast-glob's ignore option to skip directories during traversal (performance optimization). 185 | 186 | All patterns (including negated) are always used in the filter predicate to ensure correct Git-compatible behavior. 187 | 188 | @returns {Promise<{options: Object, filter: Function}>} 189 | */ 190 | const applyIgnoreFilesAndGetFilter = async options => { 191 | const ignoreFilesPatterns = getIgnoreFilesPatterns(options); 192 | 193 | if (ignoreFilesPatterns.length === 0) { 194 | return { 195 | options, 196 | filter: createFilterFunction(false, options.cwd), 197 | }; 198 | } 199 | 200 | // Read ignore files once and get both patterns and predicate 201 | // Enable parent .gitignore search when using gitignore option 202 | const includeParentIgnoreFiles = options.gitignore === true; 203 | const {patterns, predicate, usingGitRoot} = await getIgnorePatternsAndPredicate(ignoreFilesPatterns, options, includeParentIgnoreFiles); 204 | 205 | // Convert patterns to fast-glob format (may return empty array if predicate should handle everything) 206 | const patternsForFastGlob = convertPatternsForFastGlob(patterns, usingGitRoot, normalizeDirectoryPatternForFastGlob); 207 | 208 | const modifiedOptions = { 209 | ...options, 210 | ignore: [...options.ignore, ...patternsForFastGlob], 211 | }; 212 | 213 | return { 214 | options: modifiedOptions, 215 | filter: createFilterFunction(predicate, options.cwd), 216 | }; 217 | }; 218 | 219 | /** 220 | Apply gitignore patterns to options and return filter predicate (sync version). 221 | 222 | @returns {{options: Object, filter: Function}} 223 | */ 224 | const applyIgnoreFilesAndGetFilterSync = options => { 225 | const ignoreFilesPatterns = getIgnoreFilesPatterns(options); 226 | 227 | if (ignoreFilesPatterns.length === 0) { 228 | return { 229 | options, 230 | filter: createFilterFunction(false, options.cwd), 231 | }; 232 | } 233 | 234 | // Read ignore files once and get both patterns and predicate 235 | // Enable parent .gitignore search when using gitignore option 236 | const includeParentIgnoreFiles = options.gitignore === true; 237 | const {patterns, predicate, usingGitRoot} = getIgnorePatternsAndPredicateSync(ignoreFilesPatterns, options, includeParentIgnoreFiles); 238 | 239 | // Convert patterns to fast-glob format (may return empty array if predicate should handle everything) 240 | const patternsForFastGlob = convertPatternsForFastGlob(patterns, usingGitRoot, normalizeDirectoryPatternForFastGlob); 241 | 242 | const modifiedOptions = { 243 | ...options, 244 | ignore: [...options.ignore, ...patternsForFastGlob], 245 | }; 246 | 247 | return { 248 | options: modifiedOptions, 249 | filter: createFilterFunction(predicate, options.cwd), 250 | }; 251 | }; 252 | 253 | const createFilterFunction = (isIgnored, cwd) => { 254 | const seen = new Set(); 255 | const basePath = cwd || process.cwd(); 256 | const pathCache = new Map(); // Cache for resolved paths 257 | 258 | return fastGlobResult => { 259 | const pathKey = nodePath.normalize(fastGlobResult.path ?? fastGlobResult); 260 | 261 | // Check seen set first (fast path) 262 | if (seen.has(pathKey)) { 263 | return false; 264 | } 265 | 266 | // Only compute absolute path and check predicate if needed 267 | if (isIgnored) { 268 | let absolutePath = pathCache.get(pathKey); 269 | if (absolutePath === undefined) { 270 | absolutePath = nodePath.isAbsolute(pathKey) ? pathKey : nodePath.resolve(basePath, pathKey); 271 | pathCache.set(pathKey, absolutePath); 272 | 273 | // Only clear path cache if it gets too large 274 | // Never clear 'seen' as it's needed for deduplication 275 | if (pathCache.size > 10_000) { 276 | pathCache.clear(); 277 | } 278 | } 279 | 280 | if (isIgnored(absolutePath)) { 281 | return false; 282 | } 283 | } 284 | 285 | seen.add(pathKey); 286 | return true; 287 | }; 288 | }; 289 | 290 | const unionFastGlobResults = (results, filter) => results.flat().filter(fastGlobResult => filter(fastGlobResult)); 291 | 292 | const convertNegativePatterns = (patterns, options) => { 293 | // If all patterns are negative and expandNegationOnlyPatterns is enabled (default), 294 | // prepend a positive catch-all pattern to make negation-only patterns work intuitively 295 | // (e.g., '!*.json' matches all files except JSON) 296 | if (patterns.length > 0 && patterns.every(pattern => isNegativePattern(pattern))) { 297 | if (options.expandNegationOnlyPatterns === false) { 298 | return []; 299 | } 300 | 301 | patterns = ['**/*', ...patterns]; 302 | } 303 | 304 | patterns = patterns.map(pattern => isNegativePattern(pattern) 305 | ? `!${normalizeAbsolutePatternToRelative(pattern.slice(1))}` 306 | : pattern); 307 | 308 | const tasks = []; 309 | 310 | while (patterns.length > 0) { 311 | const index = patterns.findIndex(pattern => isNegativePattern(pattern)); 312 | 313 | if (index === -1) { 314 | tasks.push({patterns, options}); 315 | break; 316 | } 317 | 318 | const ignorePattern = patterns[index].slice(1); 319 | 320 | for (const task of tasks) { 321 | task.options.ignore.push(ignorePattern); 322 | } 323 | 324 | if (index !== 0) { 325 | tasks.push({ 326 | patterns: patterns.slice(0, index), 327 | options: { 328 | ...options, 329 | ignore: [ 330 | ...options.ignore, 331 | ignorePattern, 332 | ], 333 | }, 334 | }); 335 | } 336 | 337 | patterns = patterns.slice(index + 1); 338 | } 339 | 340 | return tasks; 341 | }; 342 | 343 | const applyParentDirectoryIgnoreAdjustments = tasks => tasks.map(task => ({ 344 | patterns: task.patterns, 345 | options: { 346 | ...task.options, 347 | ignore: adjustIgnorePatternsForParentDirectories(task.patterns, task.options.ignore), 348 | }, 349 | })); 350 | 351 | const normalizeExpandDirectoriesOption = (options, cwd) => ({ 352 | ...(cwd ? {cwd} : {}), 353 | ...(Array.isArray(options) ? {files: options} : options), 354 | }); 355 | 356 | const generateTasks = async (patterns, options) => { 357 | const globTasks = convertNegativePatterns(patterns, options); 358 | 359 | const {cwd, expandDirectories, fs: fsImplementation} = options; 360 | 361 | if (!expandDirectories) { 362 | return applyParentDirectoryIgnoreAdjustments(globTasks); 363 | } 364 | 365 | const directoryToGlobOptions = { 366 | ...normalizeExpandDirectoriesOption(expandDirectories, cwd), 367 | fs: fsImplementation, 368 | }; 369 | 370 | return Promise.all(globTasks.map(async task => { 371 | let {patterns, options} = task; 372 | 373 | [ 374 | patterns, 375 | options.ignore, 376 | ] = await Promise.all([ 377 | directoryToGlob(patterns, directoryToGlobOptions), 378 | directoryToGlob(options.ignore, {cwd, fs: fsImplementation}), 379 | ]); 380 | 381 | // Adjust ignore patterns for parent directory references 382 | options.ignore = adjustIgnorePatternsForParentDirectories(patterns, options.ignore); 383 | 384 | return {patterns, options}; 385 | })); 386 | }; 387 | 388 | const generateTasksSync = (patterns, options) => { 389 | const globTasks = convertNegativePatterns(patterns, options); 390 | const {cwd, expandDirectories, fs: fsImplementation} = options; 391 | 392 | if (!expandDirectories) { 393 | return applyParentDirectoryIgnoreAdjustments(globTasks); 394 | } 395 | 396 | const directoryToGlobSyncOptions = { 397 | ...normalizeExpandDirectoriesOption(expandDirectories, cwd), 398 | fs: fsImplementation, 399 | }; 400 | 401 | return globTasks.map(task => { 402 | let {patterns, options} = task; 403 | patterns = directoryToGlobSync(patterns, directoryToGlobSyncOptions); 404 | options.ignore = directoryToGlobSync(options.ignore, {cwd, fs: fsImplementation}); 405 | 406 | // Adjust ignore patterns for parent directory references 407 | options.ignore = adjustIgnorePatternsForParentDirectories(patterns, options.ignore); 408 | 409 | return {patterns, options}; 410 | }); 411 | }; 412 | 413 | export const globby = normalizeArguments(async (patterns, options) => { 414 | // Apply ignore files and get filter (reads .gitignore files once) 415 | const {options: modifiedOptions, filter} = await applyIgnoreFilesAndGetFilter(options); 416 | 417 | // Generate tasks with modified options (includes gitignore patterns in ignore option) 418 | const tasks = await generateTasks(patterns, modifiedOptions); 419 | 420 | const results = await Promise.all(tasks.map(task => fastGlob(task.patterns, task.options))); 421 | return unionFastGlobResults(results, filter); 422 | }); 423 | 424 | export const globbySync = normalizeArgumentsSync((patterns, options) => { 425 | // Apply ignore files and get filter (reads .gitignore files once) 426 | const {options: modifiedOptions, filter} = applyIgnoreFilesAndGetFilterSync(options); 427 | 428 | // Generate tasks with modified options (includes gitignore patterns in ignore option) 429 | const tasks = generateTasksSync(patterns, modifiedOptions); 430 | 431 | const results = tasks.map(task => fastGlob.sync(task.patterns, task.options)); 432 | return unionFastGlobResults(results, filter); 433 | }); 434 | 435 | export const globbyStream = normalizeArgumentsSync((patterns, options) => { 436 | // Apply ignore files and get filter (reads .gitignore files once) 437 | const {options: modifiedOptions, filter} = applyIgnoreFilesAndGetFilterSync(options); 438 | 439 | // Generate tasks with modified options (includes gitignore patterns in ignore option) 440 | const tasks = generateTasksSync(patterns, modifiedOptions); 441 | 442 | const streams = tasks.map(task => fastGlob.stream(task.patterns, task.options)); 443 | 444 | if (streams.length === 0) { 445 | return Readable.from([]); 446 | } 447 | 448 | const stream = mergeStreams(streams).filter(fastGlobResult => filter(fastGlobResult)); 449 | 450 | // Returning a web stream will require revisiting once Readable.toWeb integration is viable. 451 | // return Readable.toWeb(stream); 452 | 453 | return stream; 454 | }); 455 | 456 | export const isDynamicPattern = normalizeArgumentsSync((patterns, options) => patterns.some(pattern => fastGlob.isDynamicPattern(pattern, options))); 457 | 458 | export const generateGlobTasks = normalizeArguments(generateTasks); 459 | export const generateGlobTasksSync = normalizeArgumentsSync(generateTasksSync); 460 | 461 | export { 462 | isGitIgnored, 463 | isGitIgnoredSync, 464 | isIgnoredByIgnoreFiles, 465 | isIgnoredByIgnoreFilesSync, 466 | } from './ignore.js'; 467 | 468 | export const {convertPathToPattern} = fastGlob; 469 | -------------------------------------------------------------------------------- /tests/generate-glob-tasks.js: -------------------------------------------------------------------------------- 1 | import {format} from 'node:util'; 2 | import process from 'node:process'; 3 | import path from 'node:path'; 4 | import fs from 'node:fs'; 5 | import test from 'ava'; 6 | import {temporaryDirectory} from 'tempy'; 7 | import { 8 | generateGlobTasks, 9 | generateGlobTasksSync, 10 | } from '../index.js'; 11 | import { 12 | invalidPatterns, 13 | getPathValues, 14 | isUnique, 15 | } from './utilities.js'; 16 | 17 | const cwdDirectoryError = {message: /The `cwd` option must be a path to a directory, got:/}; 18 | 19 | const runGenerateGlobTasks = async (t, patterns, options) => { 20 | const promiseResult = await generateGlobTasks(patterns, options); 21 | const syncResult = generateGlobTasksSync(patterns, options); 22 | 23 | t.deepEqual( 24 | promiseResult, 25 | syncResult, 26 | 'generateGlobTasksSync() result is different than generateGlobTasks()', 27 | ); 28 | 29 | return promiseResult; 30 | }; 31 | 32 | const getTasks = async (t, patterns, options) => { 33 | const tasks = await runGenerateGlobTasks(t, patterns, options); 34 | return tasks.map(({patterns, options: {ignore}}) => ({patterns, ignore})); 35 | }; 36 | 37 | test('generateGlobTasks', async t => { 38 | const tasks = await runGenerateGlobTasks(t, ['*.tmp', '!b.tmp'], {ignore: ['c.tmp']}); 39 | 40 | t.is(tasks.length, 1); 41 | t.deepEqual(tasks[0].patterns, ['*.tmp']); 42 | t.deepEqual(tasks[0].options.ignore, ['c.tmp', 'b.tmp']); 43 | await t.notThrowsAsync(generateGlobTasks('*')); 44 | t.notThrows(() => generateGlobTasksSync('*')); 45 | }); 46 | 47 | // Rejected for being an invalid pattern 48 | for (const value of invalidPatterns) { 49 | const valueString = format(value); 50 | const message = 'Patterns must be a string or an array of strings'; 51 | 52 | test(`throws for invalid patterns input: ${valueString}`, async t => { 53 | await t.throwsAsync(generateGlobTasks(value), {instanceOf: TypeError, message}); 54 | t.throws(() => generateGlobTasksSync(value), {instanceOf: TypeError, message}); 55 | }); 56 | } 57 | 58 | test('throws when specifying a file as cwd', async t => { 59 | for (const file of getPathValues(path.resolve('fixtures/gitignore/bar.js'))) { 60 | // eslint-disable-next-line no-await-in-loop 61 | await t.throwsAsync(generateGlobTasks('*', {cwd: file}), cwdDirectoryError); 62 | t.throws(() => generateGlobTasksSync('*', {cwd: file}), cwdDirectoryError); 63 | } 64 | }); 65 | 66 | test('cwd', async t => { 67 | const cwd = process.cwd(); 68 | for (const cwdDirectory of getPathValues(cwd)) { 69 | // eslint-disable-next-line no-await-in-loop 70 | const [task] = await runGenerateGlobTasks(t, ['*'], {cwd: cwdDirectory}); 71 | t.is(task.options.cwd, cwd); 72 | } 73 | }); 74 | 75 | test('expandDirectories option', async t => { 76 | { 77 | const tasks = await runGenerateGlobTasks(t, ['fixtures'], {ignore: ['fixtures/negative']}); 78 | t.is(tasks.length, 1); 79 | t.deepEqual(tasks[0].patterns, ['fixtures/**']); 80 | t.deepEqual(tasks[0].options.ignore, ['fixtures/negative/**']); 81 | } 82 | 83 | { 84 | const tasks = await runGenerateGlobTasks(t, ['fixtures'], {ignore: ['fixtures/negative'], expandDirectories: false}); 85 | t.is(tasks.length, 1); 86 | t.deepEqual(tasks[0].patterns, ['fixtures']); 87 | t.deepEqual(tasks[0].options.ignore, ['fixtures/negative']); 88 | } 89 | 90 | { 91 | const tasks = await runGenerateGlobTasks(t, ['fixtures'], {expandDirectories: ['a*', 'b*']}); 92 | t.is(tasks.length, 1); 93 | t.deepEqual(tasks[0].patterns, ['fixtures/**/a*', 'fixtures/**/b*']); 94 | t.deepEqual(tasks[0].options.ignore, []); 95 | } 96 | 97 | { 98 | const tasks = await runGenerateGlobTasks(t, ['fixtures'], { 99 | expandDirectories: { 100 | files: ['a', 'b*'], 101 | extensions: ['tmp', 'txt'], 102 | }, 103 | ignore: ['**/b.tmp'], 104 | }); 105 | t.is(tasks.length, 1); 106 | t.deepEqual(tasks[0].patterns, ['fixtures/**/a.{tmp,txt}', 'fixtures/**/b*.{tmp,txt}']); 107 | t.deepEqual(tasks[0].options.ignore, ['**/b.tmp']); 108 | } 109 | }); 110 | 111 | test('adjust ignore patterns when expandDirectories is false', async t => { 112 | const tasks = await runGenerateGlobTasks(t, ['../**'], { 113 | ignore: ['**/node_modules/**'], 114 | expandDirectories: false, 115 | }); 116 | 117 | t.deepEqual(tasks[0].options.ignore, ['../**/node_modules/**']); 118 | }); 119 | 120 | test('combine tasks', async t => { 121 | t.deepEqual( 122 | await getTasks(t, ['a', 'b']), 123 | [{patterns: ['a', 'b'], ignore: []}], 124 | ); 125 | 126 | t.deepEqual( 127 | await getTasks(t, ['!a', 'b']), 128 | [{patterns: ['b'], ignore: []}], 129 | ); 130 | 131 | t.deepEqual( 132 | await getTasks(t, ['!a']), 133 | [{patterns: ['**/*'], ignore: ['a']}], 134 | ); 135 | 136 | t.deepEqual( 137 | await getTasks(t, ['a', 'b', '!c', '!d']), 138 | [{patterns: ['a', 'b'], ignore: ['c', 'd']}], 139 | ); 140 | 141 | t.deepEqual( 142 | await getTasks(t, ['a', 'b', '!c', '!d', 'e']), 143 | [ 144 | {patterns: ['a', 'b'], ignore: ['c', 'd']}, 145 | {patterns: ['e'], ignore: []}, 146 | ], 147 | ); 148 | 149 | t.deepEqual( 150 | await getTasks(t, ['a', 'b', '!c', 'd', 'e', '!f', '!g', 'h']), 151 | [ 152 | {patterns: ['a', 'b'], ignore: ['c', 'f', 'g']}, 153 | {patterns: ['d', 'e'], ignore: ['f', 'g']}, 154 | {patterns: ['h'], ignore: []}, 155 | ], 156 | ); 157 | }); 158 | 159 | test('random patterns', async t => { 160 | for (let index = 0; index < 500; index++) { 161 | const positivePatterns = []; 162 | const negativePatterns = []; 163 | const negativePatternsAtStart = []; 164 | 165 | const patterns = Array.from({length: 1 + Math.floor(Math.random() * 20)}, (_, index) => { 166 | const negative = Math.random() > 0.5; 167 | let pattern = String(index + 1); 168 | if (negative) { 169 | negativePatterns.push(pattern); 170 | 171 | if (positivePatterns.length === 0) { 172 | negativePatternsAtStart.push(pattern); 173 | } 174 | 175 | pattern = `!${pattern}`; 176 | } else { 177 | positivePatterns.push(pattern); 178 | } 179 | 180 | return pattern; 181 | }); 182 | 183 | // eslint-disable-next-line no-await-in-loop 184 | const tasks = await getTasks(t, patterns); 185 | const patternsToDebug = JSON.stringify(patterns); 186 | 187 | t.true( 188 | tasks.length <= negativePatterns.length - negativePatternsAtStart.length + 1, 189 | `Unexpected tasks: ${patternsToDebug}`, 190 | ); 191 | 192 | for (const [index, {patterns, ignore}] of tasks.entries()) { 193 | t.not( 194 | patterns.length, 195 | 0, 196 | `Unexpected empty patterns: ${patternsToDebug}`, 197 | ); 198 | 199 | t.true( 200 | isUnique(patterns), 201 | `patterns should be unique: ${patternsToDebug}`, 202 | ); 203 | 204 | t.true( 205 | isUnique(ignore), 206 | `ignore should be unique: ${patternsToDebug}`, 207 | ); 208 | 209 | if (index !== 0 && ignore.length > 0) { 210 | t.deepEqual( 211 | tasks[index - 1].ignore.slice(-ignore.length), 212 | ignore, 213 | `Unexpected ignore: ${patternsToDebug}`, 214 | ); 215 | } 216 | } 217 | 218 | const allPatterns = tasks.flatMap(({patterns}) => patterns); 219 | const allIgnore = tasks.flatMap(({ignore}) => ignore); 220 | 221 | // When there are only negative patterns, we auto-add '**/*' as a positive pattern 222 | const isNegationOnly = positivePatterns.length === 0 && negativePatterns.length > 0; 223 | const expectedPatternCount = isNegationOnly ? 1 : positivePatterns.length; 224 | 225 | t.is( 226 | new Set(allPatterns).size, 227 | expectedPatternCount, 228 | `positive patterns should be in patterns: ${patternsToDebug}`, 229 | ); 230 | 231 | // When there are only negative patterns, all of them go into ignore (including negativePatternsAtStart) 232 | // Otherwise, negativePatternsAtStart are discarded 233 | const expectedIgnoreCount = isNegationOnly 234 | ? negativePatterns.length 235 | : negativePatterns.length - negativePatternsAtStart.length; 236 | 237 | t.is( 238 | new Set(allIgnore).size, 239 | expectedIgnoreCount, 240 | `negative patterns should be in ignore: ${patternsToDebug}`, 241 | ); 242 | } 243 | }); 244 | 245 | // Test for https://github.com/sindresorhus/globby/issues/147 246 | test('expandDirectories should work with globstar prefix', async t => { 247 | const cwd = temporaryDirectory(); 248 | const filePath = path.join(cwd, 'a', 'b'); 249 | fs.mkdirSync(filePath, {recursive: true}); 250 | const tasks = await runGenerateGlobTasks(t, ['**/b'], {cwd}); 251 | t.is(tasks.length, 1); 252 | t.deepEqual(tasks[0].patterns, ['**/b/**']); 253 | }); 254 | 255 | test('expandDirectories should not expand invalid globstar patterns', async t => { 256 | const cwd = temporaryDirectory(); 257 | const filePath = path.join(cwd, 'a', 'b'); 258 | fs.mkdirSync(filePath, {recursive: true}); 259 | 260 | // Test patterns that should NOT be expanded 261 | const invalidPatterns = [ 262 | '**/b.txt', // File pattern with extension 263 | '**/*', // Wildcard pattern 264 | '**', // Just globstar 265 | '**/b/c', // Path with slash 266 | '**/b?', // Question mark wildcard 267 | '**/b[abc]', // Bracket wildcard 268 | ]; 269 | 270 | for (const pattern of invalidPatterns) { 271 | // eslint-disable-next-line no-await-in-loop 272 | const tasks = await runGenerateGlobTasks(t, [pattern], {cwd}); 273 | t.is(tasks.length, 1, `Pattern ${pattern} should not be expanded`); 274 | t.deepEqual(tasks[0].patterns, [pattern], `Pattern ${pattern} should remain unchanged`); 275 | } 276 | }); 277 | 278 | test('expandDirectories should work with globstar in middle of pattern', async t => { 279 | const cwd = temporaryDirectory(); 280 | 281 | // Create nested directory structure 282 | fs.mkdirSync(path.join(cwd, 'src', 'components', 'button'), {recursive: true}); 283 | fs.mkdirSync(path.join(cwd, 'lib', 'utils', 'button'), {recursive: true}); 284 | 285 | // Test patterns with globstar not at the start 286 | const validPatterns = [ 287 | {pattern: 'src/**/button', expected: 'src/**/button/**'}, 288 | {pattern: 'lib/**/button', expected: 'lib/**/button/**'}, 289 | {pattern: 'src/components/**/button', expected: 'src/components/**/button/**'}, 290 | ]; 291 | 292 | for (const {pattern, expected} of validPatterns) { 293 | // eslint-disable-next-line no-await-in-loop 294 | const tasks = await runGenerateGlobTasks(t, [pattern], {cwd}); 295 | t.is(tasks.length, 1, `Pattern ${pattern} should generate one task`); 296 | t.deepEqual(tasks[0].patterns, [expected], `Pattern ${pattern} should expand to ${expected}`); 297 | } 298 | 299 | // Test patterns that should NOT be expanded (even with globstar in middle) 300 | const invalidMiddlePatterns = [ 301 | 'src/**/button.js', // File extension 302 | 'src/**/*', // Wildcard after globstar 303 | 'src/**/button/index', // Additional path after globstar directory 304 | 'src/**/button?', // Question mark wildcard 305 | ]; 306 | 307 | for (const pattern of invalidMiddlePatterns) { 308 | // eslint-disable-next-line no-await-in-loop 309 | const tasks = await runGenerateGlobTasks(t, [pattern], {cwd}); 310 | t.is(tasks.length, 1, `Pattern ${pattern} should not be expanded`); 311 | t.deepEqual(tasks[0].patterns, [pattern], `Pattern ${pattern} should remain unchanged`); 312 | } 313 | }); 314 | 315 | test('expandDirectories critical edge cases', async t => { 316 | const cwd = temporaryDirectory(); 317 | 318 | // Create test directories with various edge case names 319 | fs.mkdirSync(path.join(cwd, '.git'), {recursive: true}); 320 | fs.mkdirSync(path.join(cwd, '.vscode'), {recursive: true}); 321 | fs.mkdirSync(path.join(cwd, 'node.js'), {recursive: true}); // Directory that looks like a file 322 | fs.mkdirSync(path.join(cwd, 'build-output'), {recursive: true}); // Hyphens 323 | fs.mkdirSync(path.join(cwd, 'test_files'), {recursive: true}); // Underscores 324 | fs.mkdirSync(path.join(cwd, 'v1.0.0'), {recursive: true}); // Dots in name 325 | fs.mkdirSync(path.join(cwd, '文件夹'), {recursive: true}); // Unicode Chinese 326 | fs.mkdirSync(path.join(cwd, 'café'), {recursive: true}); // Unicode accents 327 | fs.mkdirSync(path.join(cwd, '@scope'), {recursive: true}); // Npm scope style 328 | 329 | // Test hidden directories (should expand - they're valid directory names) 330 | const hiddenDirectoryPatterns = [ 331 | {pattern: '**/.git', expected: '**/.git/**'}, 332 | {pattern: '**/.vscode', expected: '**/.vscode/**'}, 333 | ]; 334 | 335 | for (const {pattern, expected} of hiddenDirectoryPatterns) { 336 | // eslint-disable-next-line no-await-in-loop 337 | const tasks = await runGenerateGlobTasks(t, [pattern], {cwd}); 338 | t.is(tasks.length, 1, `Hidden directory pattern ${pattern} should generate one task`); 339 | t.deepEqual(tasks[0].patterns, [expected], `Hidden directory ${pattern} should expand to ${expected}`); 340 | } 341 | 342 | // Test directories that look like files (should expand if no actual extension) 343 | const ambiguousPatterns = [ 344 | {pattern: '**/node.js', expected: '**/node.js', shouldExpand: false}, // Has .js extension 345 | {pattern: '**/build-output', expected: '**/build-output/**', shouldExpand: true}, // Hyphens OK 346 | {pattern: '**/test_files', expected: '**/test_files/**', shouldExpand: true}, // Underscores OK 347 | {pattern: '**/v1.0.0', expected: '**/v1.0.0', shouldExpand: false}, // Has extension .0 348 | {pattern: '**/@scope', expected: '**/@scope/**', shouldExpand: true}, // Special chars OK 349 | ]; 350 | 351 | for (const {pattern, expected, shouldExpand} of ambiguousPatterns) { 352 | // eslint-disable-next-line no-await-in-loop 353 | const tasks = await runGenerateGlobTasks(t, [pattern], {cwd}); 354 | const message = shouldExpand ? 'should expand' : 'should not expand'; 355 | t.deepEqual(tasks[0].patterns, [expected], `Pattern ${pattern} ${message}`); 356 | } 357 | 358 | // Test Unicode directory names (should expand) 359 | const unicodePatterns = [ 360 | {pattern: '**/文件夹', expected: '**/文件夹/**'}, // Chinese 361 | {pattern: '**/café', expected: '**/café/**'}, // French accents 362 | ]; 363 | 364 | for (const {pattern, expected} of unicodePatterns) { 365 | // eslint-disable-next-line no-await-in-loop 366 | const tasks = await runGenerateGlobTasks(t, [pattern], {cwd}); 367 | t.deepEqual(tasks[0].patterns, [expected], `Unicode pattern ${pattern} should expand to ${expected}`); 368 | } 369 | }); 370 | 371 | test('expandDirectories with negative patterns', async t => { 372 | const cwd = temporaryDirectory(); 373 | fs.mkdirSync(path.join(cwd, 'src', 'components'), {recursive: true}); 374 | fs.mkdirSync(path.join(cwd, 'lib', 'components'), {recursive: true}); 375 | 376 | // Test negative patterns with globstar directories 377 | const negativePatterns = [ 378 | { 379 | patterns: ['**/components', '!lib/**/components'], 380 | expected: { 381 | positive: ['**/components/**'], 382 | negative: ['lib/**/components/**'], 383 | }, 384 | }, 385 | { 386 | patterns: ['src/**/components', '!src/**/components'], 387 | expected: { 388 | positive: ['src/**/components/**'], 389 | negative: ['src/**/components/**'], 390 | }, 391 | }, 392 | ]; 393 | 394 | for (const {patterns, expected} of negativePatterns) { 395 | // eslint-disable-next-line no-await-in-loop 396 | const tasks = await runGenerateGlobTasks(t, patterns, {cwd}); 397 | 398 | // Find positive and negative patterns in results 399 | const positivePatterns = tasks[0].patterns.filter(p => !p.startsWith('!')); 400 | const negativePatterns = tasks[0].options.ignore; 401 | 402 | t.deepEqual(positivePatterns, expected.positive.filter(p => !p.startsWith('!')), `Positive patterns should be expanded correctly for ${patterns.join(', ')}`); 403 | 404 | // Check that negative patterns are properly handled in ignore array 405 | const expectedIgnore = new Set(expected.negative.map(p => p.replace(/^!/, ''))); 406 | t.true( 407 | negativePatterns.some(p => expectedIgnore.has(p)), 408 | `Negative patterns should be in ignore array for ${patterns.join(', ')}`, 409 | ); 410 | } 411 | }); 412 | 413 | test('expandDirectories with multiple globstars', async t => { 414 | const cwd = temporaryDirectory(); 415 | fs.mkdirSync(path.join(cwd, 'a', 'b', 'c', 'target'), {recursive: true}); 416 | 417 | // Patterns with multiple globstars - should expand the last directory pattern 418 | const multiGlobstarPatterns = [ 419 | {pattern: '**/a/**/target', expected: '**/a/**/target/**'}, 420 | {pattern: '**/**/target', expected: '**/**/target/**'}, 421 | {pattern: '**/target/**/nested', expected: '**/target/**/nested/**'}, 422 | ]; 423 | 424 | for (const {pattern, expected} of multiGlobstarPatterns) { 425 | // eslint-disable-next-line no-await-in-loop 426 | const tasks = await runGenerateGlobTasks(t, [pattern], {cwd}); 427 | t.deepEqual(tasks[0].patterns, [expected], `Multi-globstar pattern ${pattern} should expand to ${expected}`); 428 | } 429 | 430 | // Should NOT expand if last part isn't a simple directory 431 | const invalidMultiPatterns = [ 432 | '**/a/**/*.js', // Ends with wildcard extension 433 | '**/a/**/b/c', // Has path after last globstar 434 | '**/a/**', // Ends with globstar 435 | ]; 436 | 437 | for (const pattern of invalidMultiPatterns) { 438 | // eslint-disable-next-line no-await-in-loop 439 | const tasks = await runGenerateGlobTasks(t, [pattern], {cwd}); 440 | t.deepEqual(tasks[0].patterns, [pattern], `Pattern ${pattern} should not be expanded`); 441 | } 442 | }); 443 | -------------------------------------------------------------------------------- /tests/ignore.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import {chmod} from 'node:fs/promises'; 3 | import path from 'node:path'; 4 | import test from 'ava'; 5 | import slash from 'slash'; 6 | import {temporaryDirectory} from 'tempy'; 7 | import { 8 | isIgnoredByIgnoreFiles, 9 | isIgnoredByIgnoreFilesSync, 10 | isGitIgnored, 11 | isGitIgnoredSync, 12 | getIgnorePatternsAndPredicate, 13 | GITIGNORE_FILES_PATTERN, 14 | } from '../ignore.js'; 15 | import { 16 | PROJECT_ROOT, 17 | createContextAwareFs, 18 | createCountingFs, 19 | createTemporaryGitRepository, 20 | getPathValues, 21 | } from './utilities.js'; 22 | 23 | const runIsIgnoredByIgnoreFiles = async (t, patterns, options, function_) => { 24 | const promisePredicate = await isIgnoredByIgnoreFiles(patterns, options); 25 | const syncPredicate = isIgnoredByIgnoreFilesSync(patterns, options); 26 | 27 | const promiseResult = function_(promisePredicate); 28 | const syncResult = function_(syncPredicate); 29 | 30 | t[Array.isArray(promiseResult) ? 'deepEqual' : 'is']( 31 | promiseResult, 32 | syncResult, 33 | 'isIgnoredByIgnoreFilesSync() result is different than isIgnoredByIgnoreFiles()', 34 | ); 35 | 36 | return promiseResult; 37 | }; 38 | 39 | const runIsGitIgnored = async (t, options, function_) => { 40 | const promisePredicate = await isGitIgnored(options); 41 | const syncPredicate = isGitIgnoredSync(options); 42 | 43 | const promiseResult = function_(promisePredicate); 44 | const syncResult = function_(syncPredicate); 45 | 46 | t[Array.isArray(promiseResult) ? 'deepEqual' : 'is']( 47 | promiseResult, 48 | syncResult, 49 | 'isGitIgnoredSync() result is different than isGitIgnored()', 50 | ); 51 | 52 | return promiseResult; 53 | }; 54 | 55 | test('ignore', async t => { 56 | for (const cwd of getPathValues(path.join(PROJECT_ROOT, 'fixtures/gitignore'))) { 57 | // eslint-disable-next-line no-await-in-loop 58 | const actual = await runIsGitIgnored( 59 | t, 60 | {cwd}, 61 | isIgnored => ['foo.js', 'bar.js'].filter(file => !isIgnored(file)), 62 | ); 63 | const expected = ['bar.js']; 64 | t.deepEqual(actual, expected); 65 | } 66 | }); 67 | 68 | test('ignore - mixed path styles', async t => { 69 | const directory = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 70 | 71 | for (const cwd of getPathValues(directory)) { 72 | // eslint-disable-next-line no-await-in-loop 73 | const result = await runIsGitIgnored( 74 | t, 75 | {cwd}, 76 | isIgnored => isIgnored(slash(path.resolve(directory, 'foo.js'))), 77 | ); 78 | 79 | t.true(result); 80 | } 81 | }); 82 | 83 | test('ignore - os paths', async t => { 84 | const directory = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 85 | for (const cwd of getPathValues(directory)) { 86 | // eslint-disable-next-line no-await-in-loop 87 | const result = await runIsGitIgnored( 88 | t, 89 | {cwd}, 90 | isIgnored => isIgnored(path.resolve(directory, 'foo.js')), 91 | ); 92 | 93 | t.true(result); 94 | } 95 | }); 96 | 97 | test('negative ignore', async t => { 98 | for (const cwd of getPathValues(path.join(PROJECT_ROOT, 'fixtures/negative'))) { 99 | // eslint-disable-next-line no-await-in-loop 100 | const actual = await runIsGitIgnored( 101 | t, 102 | {cwd}, 103 | isIgnored => ['foo.js', 'bar.js'].filter(file => !isIgnored(file)), 104 | ); 105 | const expected = ['foo.js']; 106 | t.deepEqual(actual, expected); 107 | } 108 | }); 109 | 110 | test('multiple negation', async t => { 111 | for (const cwd of getPathValues(path.join(PROJECT_ROOT, 'fixtures/multiple-negation'))) { 112 | // eslint-disable-next-line no-await-in-loop 113 | const actual = await runIsGitIgnored( 114 | t, 115 | {cwd}, 116 | isIgnored => [ 117 | '!!!unicorn.js', 118 | '!!unicorn.js', 119 | '!unicorn.js', 120 | 'unicorn.js', 121 | ].filter(file => !isIgnored(file)), 122 | ); 123 | 124 | const expected = ['!!unicorn.js', '!unicorn.js']; 125 | t.deepEqual(actual, expected); 126 | } 127 | }); 128 | 129 | test('check file', async t => { 130 | const directory = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 131 | 132 | for (const ignoredFile of getPathValues(path.join(directory, 'foo.js'))) { 133 | // eslint-disable-next-line no-await-in-loop 134 | const result = await runIsGitIgnored( 135 | t, 136 | {cwd: directory}, 137 | isIgnored => isIgnored(ignoredFile), 138 | ); 139 | 140 | t.true(result); 141 | } 142 | 143 | for (const notIgnoredFile of getPathValues(path.join(directory, 'bar.js'))) { 144 | // eslint-disable-next-line no-await-in-loop 145 | const result = await runIsGitIgnored( 146 | t, 147 | {cwd: directory}, 148 | isIgnored => isIgnored(notIgnoredFile), 149 | ); 150 | 151 | t.false(result); 152 | } 153 | }); 154 | 155 | test('gitignore patterns in subdirectories apply recursively', async t => { 156 | const cwd = path.join(PROJECT_ROOT, 'fixtures', 'gitignore-nested'); 157 | const isIgnored = await isGitIgnored({cwd}); 158 | 159 | // Pattern '*.log' in subdir/.gitignore should ignore files at any level below 160 | t.true(isIgnored('subdir/file.log')); 161 | t.true(isIgnored('subdir/deep/file.log')); 162 | t.true(isIgnored('subdir/deep/deeper/file.log')); 163 | t.false(isIgnored('file.log')); // Not under subdir 164 | 165 | // Pattern 'specific.txt' should ignore at any level below 166 | t.true(isIgnored('subdir/specific.txt')); 167 | t.true(isIgnored('subdir/deep/specific.txt')); 168 | t.false(isIgnored('specific.txt')); // Not under subdir 169 | 170 | // Edge case: pattern with trailing slash (directory pattern) in nested gitignore 171 | // Pattern 'temp/' in subdir/.gitignore should match temp dirs at any level below 172 | // (This is the core fix for issue #146) 173 | t.true(isIgnored('subdir/temp/file.js')); 174 | t.true(isIgnored('subdir/deep/temp/file.js')); 175 | t.false(isIgnored('temp/file.js')); // Not under subdir 176 | }); 177 | 178 | test('gitignore patterns with slashes are relative to gitignore location', async t => { 179 | const cwd = path.join(PROJECT_ROOT, 'fixtures', 'gitignore-nested'); 180 | const isIgnored = await isGitIgnored({cwd}); 181 | 182 | // Pattern 'deep/*.tmp' should only ignore direct children of deep/ 183 | t.true(isIgnored('subdir/deep/file.tmp')); 184 | t.false(isIgnored('subdir/deep/nested/file.tmp')); 185 | t.false(isIgnored('subdir/file.tmp')); 186 | 187 | // Leading slash patterns anchor to gitignore directory 188 | // If subdir/.gitignore had '/specific.txt', it would only match subdir/specific.txt 189 | // not subdir/deep/specific.txt (but our test fixture uses 'specific.txt' without /) 190 | }); 191 | 192 | test('gitignore edge cases with trailing slashes and special patterns', async t => { 193 | const cwd = path.join(PROJECT_ROOT, 'fixtures', 'gitignore-nested'); 194 | const isIgnored = await isGitIgnored({cwd}); 195 | 196 | // Directory patterns with trailing slash (would match directories themselves) 197 | // Note: globby by default uses onlyFiles:true, so directories aren't in results 198 | // But the ignore predicate should still correctly identify them 199 | 200 | // Negation patterns work correctly in subdirectories 201 | // Pattern in root that would be negated in subdirectory still applies 202 | t.true(isIgnored('subdir/file.log')); // *.log from subdir/.gitignore 203 | 204 | // Empty lines and comments in .gitignore files are handled 205 | // (tested implicitly - our fixtures may have them) 206 | }); 207 | 208 | test('npmignore patterns in subdirectories apply recursively (issue #247)', async t => { 209 | const cwd = path.join(PROJECT_ROOT, 'fixtures', 'npmignore-nested'); 210 | const isIgnored = await isIgnoredByIgnoreFiles('**/.npmignore', {cwd}); 211 | 212 | // Pattern 'a.js' in subdir/.npmignore should ignore files at any level below 213 | t.true(isIgnored('subdir/a.js')); 214 | t.true(isIgnored('subdir/deep/a.js')); 215 | t.false(isIgnored('a.js')); // Not under subdir 216 | 217 | // Pattern '*.log' should also work recursively 218 | t.true(isIgnored('subdir/file.log')); 219 | t.true(isIgnored('subdir/deep/file.log')); 220 | t.false(isIgnored('file.log')); // Not under subdir 221 | }); 222 | 223 | test('nested gitignore with negation applies recursively (issue #255)', async t => { 224 | const cwd = path.join(PROJECT_ROOT, 'fixtures', 'gitignore-negation-nested'); 225 | const isIgnored = await isGitIgnored({cwd}); 226 | 227 | // Root .gitignore has 'a*' (ignore all files starting with 'a') 228 | // Nested y/.gitignore has '!a2.txt' (un-ignore a2.txt) 229 | 230 | // Files starting with 'a' should be ignored at root level 231 | t.true(isIgnored('a1.txt')); 232 | t.true(isIgnored('a2.txt')); 233 | 234 | // But a2.txt should NOT be ignored in y/ and its subdirectories due to negation 235 | t.false(isIgnored('y/a2.txt')); 236 | t.false(isIgnored('y/z/a2.txt')); // The core fix - negation applies recursively 237 | 238 | // Other 'a*' files should still be ignored in y/ (negation is specific to a2.txt) 239 | t.true(isIgnored('y/a1.txt')); 240 | t.true(isIgnored('y/z/a1.txt')); 241 | }); 242 | 243 | test('relative paths with ./ and ../ are handled correctly', async t => { 244 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 245 | const isIgnored = await isGitIgnored({cwd}); 246 | 247 | // Paths with ./ are normalized to remove the prefix 248 | t.false(isIgnored('./bar.js')); // Not ignored, same as 'bar.js' 249 | t.true(isIgnored('./foo.js')); // Ignored, same as 'foo.js' 250 | 251 | // Paths with ../ point outside cwd and won't match any patterns 252 | t.false(isIgnored('../anything.js')); // Outside cwd, returns false 253 | t.false(isIgnored('../../foo.js')); // Multiple levels up, still outside 254 | t.false(isIgnored('../fixtures/gitignore/foo.js')); // Outside then back in - still treated as outside 255 | }); 256 | 257 | test('gitignore patterns starting with ./ or ../ do not match files', async t => { 258 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore-dotslash'); 259 | const isIgnored = await isGitIgnored({cwd}); 260 | 261 | // Pattern "./foo.js" in .gitignore does NOT match "foo.js" (matches Git behavior) 262 | t.false(isIgnored('foo.js')); 263 | 264 | // Pattern "../bar.js" in .gitignore does NOT match anything in cwd 265 | t.false(isIgnored('bar.js')); 266 | 267 | // Regular pattern "baz.js" still works normally 268 | t.true(isIgnored('baz.js')); 269 | }); 270 | 271 | test('custom ignore files', async t => { 272 | const cwd = path.join(PROJECT_ROOT, 'fixtures/ignore-files'); 273 | const files = [ 274 | 'ignored-by-eslint.js', 275 | 'ignored-by-prettier.js', 276 | 'not-ignored.js', 277 | ]; 278 | 279 | t.deepEqual( 280 | await runIsIgnoredByIgnoreFiles( 281 | t, 282 | '.eslintignore', 283 | {cwd}, 284 | isEslintIgnored => files.filter(file => isEslintIgnored(file)), 285 | ), 286 | [ 287 | 'ignored-by-eslint.js', 288 | ], 289 | ); 290 | t.deepEqual( 291 | await runIsIgnoredByIgnoreFiles( 292 | t, 293 | '.prettierignore', 294 | {cwd}, 295 | isPrettierIgnored => files.filter(file => isPrettierIgnored(file)), 296 | ), 297 | [ 298 | 'ignored-by-prettier.js', 299 | ], 300 | ); 301 | t.deepEqual( 302 | await runIsIgnoredByIgnoreFiles( 303 | t, 304 | '.{prettier,eslint}ignore', 305 | {cwd}, 306 | isEslintOrPrettierIgnored => files.filter(file => isEslintOrPrettierIgnored(file)), 307 | ), 308 | [ 309 | 'ignored-by-eslint.js', 310 | 'ignored-by-prettier.js', 311 | ], 312 | ); 313 | }); 314 | 315 | test.serial('bad permissions - ignore option', async t => { 316 | const cwd = path.join(PROJECT_ROOT, 'fixtures/bad-permissions'); 317 | const noReadDirectory = path.join(cwd, 'noread'); 318 | 319 | t.teardown(async () => { 320 | await chmod(noReadDirectory, 0o755); 321 | }); 322 | 323 | await chmod(noReadDirectory, 0o000); 324 | 325 | await t.notThrowsAsync(runIsIgnoredByIgnoreFiles( 326 | t, 327 | '**/*', 328 | {cwd, ignore: ['noread']}, 329 | () => {}, 330 | )); 331 | }); 332 | 333 | test.serial('bad permissions - suppressErrors option', async t => { 334 | const cwd = path.join(PROJECT_ROOT, 'fixtures/bad-permissions'); 335 | const noReadDirectory = path.join(cwd, 'noread'); 336 | 337 | t.teardown(async () => { 338 | await chmod(noReadDirectory, 0o755); 339 | }); 340 | 341 | await chmod(noReadDirectory, 0o000); 342 | 343 | // With suppressErrors: true, should not throw even when encountering unreadable directories 344 | await runIsGitIgnored( 345 | t, 346 | {cwd, suppressErrors: true}, 347 | predicate => predicate('test.js'), 348 | ); 349 | 350 | // Should be able to check if files are ignored 351 | const isIgnored = await isGitIgnored({cwd, suppressErrors: true}); 352 | t.is(typeof isIgnored('test.js'), 'boolean'); 353 | 354 | // Also test sync version 355 | t.notThrows(() => { 356 | const isIgnoredSync = isGitIgnoredSync({cwd, suppressErrors: true}); 357 | t.is(typeof isIgnoredSync('test.js'), 'boolean'); 358 | }); 359 | }); 360 | 361 | test.serial('unreadable .gitignore surfaces errors without suppressErrors', async t => { 362 | const cwd = path.join(PROJECT_ROOT, 'fixtures/unreadable-gitignore'); 363 | const gitignorePath = path.join(cwd, '.gitignore'); 364 | 365 | t.teardown(async () => { 366 | await chmod(gitignorePath, 0o644); 367 | }); 368 | 369 | await chmod(gitignorePath, 0o000); 370 | 371 | await t.throwsAsync( 372 | () => isGitIgnored({cwd}), 373 | {message: /Failed to read ignore file/}, 374 | ); 375 | t.throws( 376 | () => isGitIgnoredSync({cwd}), 377 | {message: /Failed to read ignore file/}, 378 | ); 379 | 380 | await t.throwsAsync( 381 | () => isIgnoredByIgnoreFiles('**/.gitignore', {cwd}), 382 | {message: /Failed to read ignore file/}, 383 | ); 384 | t.throws( 385 | () => isIgnoredByIgnoreFilesSync('**/.gitignore', {cwd}), 386 | {message: /Failed to read ignore file/}, 387 | ); 388 | }); 389 | 390 | test.serial('unreadable .gitignore honours suppressErrors option', async t => { 391 | const cwd = path.join(PROJECT_ROOT, 'fixtures/unreadable-gitignore'); 392 | const gitignorePath = path.join(cwd, '.gitignore'); 393 | 394 | t.teardown(async () => { 395 | await chmod(gitignorePath, 0o644); 396 | }); 397 | 398 | await chmod(gitignorePath, 0o000); 399 | 400 | let asyncPredicate; 401 | await t.notThrowsAsync(async () => { 402 | asyncPredicate = await isGitIgnored({cwd, suppressErrors: true}); 403 | }); 404 | t.is(typeof asyncPredicate('foo.js'), 'boolean'); 405 | 406 | t.notThrows(() => { 407 | const syncPredicate = isGitIgnoredSync({cwd, suppressErrors: true}); 408 | t.is(typeof syncPredicate('foo.js'), 'boolean'); 409 | }); 410 | 411 | let asyncIgnorePredicate; 412 | await t.notThrowsAsync(async () => { 413 | asyncIgnorePredicate = await isIgnoredByIgnoreFiles('**/.gitignore', {cwd, suppressErrors: true}); 414 | }); 415 | t.is(typeof asyncIgnorePredicate('foo.js'), 'boolean'); 416 | 417 | t.notThrows(() => { 418 | const syncIgnorePredicate = isIgnoredByIgnoreFilesSync('**/.gitignore', {cwd, suppressErrors: true}); 419 | t.is(typeof syncIgnorePredicate('foo.js'), 'boolean'); 420 | }); 421 | }); 422 | 423 | test('getIgnorePatternsAndPredicate deduplicates overlapping gitignore paths', async t => { 424 | const repository = createTemporaryGitRepository(); 425 | const childDirectory = path.join(repository, 'child'); 426 | 427 | fs.mkdirSync(childDirectory, {recursive: true}); 428 | 429 | const rootGitignore = path.join(repository, '.gitignore'); 430 | const childGitignore = path.join(childDirectory, '.gitignore'); 431 | 432 | fs.writeFileSync(rootGitignore, 'root-ignored.js\n', 'utf8'); 433 | fs.writeFileSync(childGitignore, 'child-ignored.js\n', 'utf8'); 434 | 435 | const {fs: countingFs, getReadCount} = createCountingFs(); 436 | 437 | await getIgnorePatternsAndPredicate([GITIGNORE_FILES_PATTERN], { 438 | cwd: childDirectory, 439 | fs: countingFs, 440 | }, true); 441 | 442 | t.is(getReadCount(rootGitignore), 1); 443 | t.is(getReadCount(childGitignore), 1); 444 | }); 445 | 446 | test('getIgnorePatternsAndPredicate locates git root with promises-only fs', async t => { 447 | const repository = createTemporaryGitRepository(); 448 | const childDirectory = path.join(repository, 'packages/app'); 449 | 450 | fs.mkdirSync(childDirectory, {recursive: true}); 451 | 452 | const rootGitignore = path.join(repository, '.gitignore'); 453 | const ignoredFromRoot = path.join(childDirectory, 'root-ignored.js'); 454 | 455 | fs.writeFileSync(rootGitignore, 'root-ignored.js\n', 'utf8'); 456 | fs.writeFileSync(path.join(childDirectory, '.gitignore'), '!keep.js\n', 'utf8'); 457 | fs.writeFileSync(ignoredFromRoot, '', 'utf8'); 458 | 459 | const asyncOnlyFs = { 460 | promises: { 461 | readFile: fs.promises.readFile.bind(fs.promises), 462 | stat: fs.promises.stat.bind(fs.promises), 463 | }, 464 | }; 465 | 466 | const {predicate} = await getIgnorePatternsAndPredicate([GITIGNORE_FILES_PATTERN], { 467 | cwd: childDirectory, 468 | fs: asyncOnlyFs, 469 | }, true); 470 | 471 | t.true(predicate(ignoredFromRoot)); 472 | }); 473 | 474 | test('getIgnorePatternsAndPredicate falls back to sync git root detection when promises API is missing', async t => { 475 | const repository = createTemporaryGitRepository(); 476 | const childDirectory = path.join(repository, 'packages/app'); 477 | 478 | fs.mkdirSync(childDirectory, {recursive: true}); 479 | 480 | const rootGitignore = path.join(repository, '.gitignore'); 481 | const ignoredFromRoot = path.join(childDirectory, 'root-ignored.js'); 482 | 483 | fs.writeFileSync(rootGitignore, 'root-ignored.js\n', 'utf8'); 484 | fs.writeFileSync(ignoredFromRoot, '', 'utf8'); 485 | 486 | const syncOnlyFs = { 487 | readFileSync: fs.readFileSync.bind(fs), 488 | statSync: fs.statSync.bind(fs), 489 | }; 490 | 491 | const {predicate} = await getIgnorePatternsAndPredicate([GITIGNORE_FILES_PATTERN], { 492 | cwd: childDirectory, 493 | fs: syncOnlyFs, 494 | }, true); 495 | 496 | t.true(predicate(ignoredFromRoot)); 497 | }); 498 | 499 | test('getIgnorePatternsAndPredicate reports usingGitRoot when repository is detected', async t => { 500 | const repository = createTemporaryGitRepository(); 501 | const childDirectory = path.join(repository, 'packages/app'); 502 | 503 | fs.mkdirSync(childDirectory, {recursive: true}); 504 | fs.writeFileSync(path.join(childDirectory, '.gitignore'), 'ignored.js\n', 'utf8'); 505 | 506 | const {usingGitRoot, predicate} = await getIgnorePatternsAndPredicate([GITIGNORE_FILES_PATTERN], { 507 | cwd: childDirectory, 508 | }, true); 509 | 510 | t.true(usingGitRoot); 511 | t.true(predicate(path.join(childDirectory, 'ignored.js'))); 512 | }); 513 | 514 | test('getIgnorePatternsAndPredicate does not use git root when repository is missing', async t => { 515 | const parentDirectory = temporaryDirectory(); 516 | const childDirectory = path.join(parentDirectory, 'packages/app'); 517 | 518 | fs.mkdirSync(childDirectory, {recursive: true}); 519 | fs.writeFileSync(path.join(parentDirectory, '.gitignore'), 'should-not-apply.js\n', 'utf8'); 520 | 521 | const {usingGitRoot, predicate} = await getIgnorePatternsAndPredicate([GITIGNORE_FILES_PATTERN], { 522 | cwd: childDirectory, 523 | }, true); 524 | 525 | t.false(usingGitRoot); 526 | t.false(predicate(path.join(childDirectory, 'should-not-apply.js'))); 527 | }); 528 | 529 | // Extensive fast-glob options tests 530 | test('option: suppressErrors - async', async t => { 531 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 532 | 533 | // Should work without errors 534 | const isIgnored = await isGitIgnored({cwd, suppressErrors: true}); 535 | t.true(isIgnored('foo.js')); 536 | t.false(isIgnored('bar.js')); 537 | }); 538 | 539 | test('option: suppressErrors - sync', t => { 540 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 541 | 542 | // Should work without errors 543 | const isIgnored = isGitIgnoredSync({cwd, suppressErrors: true}); 544 | t.true(isIgnored('foo.js')); 545 | t.false(isIgnored('bar.js')); 546 | }); 547 | 548 | test('option: deep - limit depth to 0 (only root .gitignore)', async t => { 549 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore-nested'); 550 | 551 | // With deep: 0, should only find .gitignore in the root 552 | const isIgnored = await isGitIgnored({cwd, deep: 0}); 553 | 554 | // Root .gitignore patterns should not be loaded (there isn't one in this fixture) 555 | // So nothing should be ignored 556 | t.false(isIgnored('subdir/file.log')); 557 | }); 558 | 559 | test('option: deep - limit depth to 1', async t => { 560 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore-nested'); 561 | 562 | // With deep: 1, should find .gitignore in root and direct subdirectories 563 | const isIgnored = await isGitIgnored({cwd, deep: 1}); 564 | 565 | // Should find subdir/.gitignore 566 | t.true(isIgnored('subdir/file.log')); 567 | t.false(isIgnored('file.log')); // Not ignored by any .gitignore 568 | }); 569 | 570 | test('option: deep - sync version', t => { 571 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore-nested'); 572 | 573 | const isIgnored = isGitIgnoredSync({cwd, deep: 1}); 574 | t.true(isIgnored('subdir/file.log')); 575 | }); 576 | 577 | test('option: ignore - exclude specific .gitignore files', async t => { 578 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore-nested'); 579 | 580 | // Ignore .gitignore files in subdirectories 581 | const isIgnored = await isGitIgnored({cwd, ignore: ['**/subdir/**']}); 582 | 583 | // Should not load subdir/.gitignore 584 | t.false(isIgnored('subdir/file.log')); 585 | }); 586 | 587 | test('option: ignore - string format', async t => { 588 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore-nested'); 589 | 590 | // Test with single string instead of array 591 | const isIgnored = await isGitIgnored({cwd, ignore: '**/subdir/**'}); 592 | 593 | t.false(isIgnored('subdir/file.log')); 594 | }); 595 | 596 | test('option: ignore - sync version', t => { 597 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore-nested'); 598 | 599 | const isIgnored = isGitIgnoredSync({cwd, ignore: ['**/subdir/**']}); 600 | t.false(isIgnored('subdir/file.log')); 601 | }); 602 | 603 | test('option: followSymbolicLinks', async t => { 604 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 605 | 606 | // Test with followSymbolicLinks explicitly set 607 | const isIgnored = await isGitIgnored({cwd, followSymbolicLinks: true}); 608 | 609 | t.true(isIgnored('foo.js')); 610 | t.false(isIgnored('bar.js')); 611 | }); 612 | 613 | test('option: followSymbolicLinks - sync', t => { 614 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 615 | 616 | const isIgnored = isGitIgnoredSync({cwd, followSymbolicLinks: false}); 617 | 618 | t.true(isIgnored('foo.js')); 619 | t.false(isIgnored('bar.js')); 620 | }); 621 | 622 | test('option: concurrency', async t => { 623 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 624 | 625 | // Test with custom concurrency 626 | const isIgnored = await isGitIgnored({cwd, concurrency: 2}); 627 | 628 | t.true(isIgnored('foo.js')); 629 | t.false(isIgnored('bar.js')); 630 | }); 631 | 632 | test('option: concurrency - sync', t => { 633 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 634 | 635 | const isIgnored = isGitIgnoredSync({cwd, concurrency: 4}); 636 | 637 | t.true(isIgnored('foo.js')); 638 | t.false(isIgnored('bar.js')); 639 | }); 640 | 641 | test('option: throwErrorOnBrokenSymbolicLink', async t => { 642 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 643 | 644 | const isIgnored = await isGitIgnored({cwd, throwErrorOnBrokenSymbolicLink: true}); 645 | 646 | t.true(isIgnored('foo.js')); 647 | t.false(isIgnored('bar.js')); 648 | }); 649 | 650 | test('option: throwErrorOnBrokenSymbolicLink - sync', t => { 651 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 652 | 653 | const isIgnored = isGitIgnoredSync({cwd, throwErrorOnBrokenSymbolicLink: false}); 654 | 655 | t.true(isIgnored('foo.js')); 656 | t.false(isIgnored('bar.js')); 657 | }); 658 | 659 | test('option: multiple options combined', async t => { 660 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore-nested'); 661 | 662 | // Test combination of multiple options 663 | const isIgnored = await isGitIgnored({ 664 | cwd, 665 | deep: 2, 666 | ignore: ['**/deep/deeper/**'], 667 | suppressErrors: true, 668 | followSymbolicLinks: false, 669 | concurrency: 4, 670 | throwErrorOnBrokenSymbolicLink: false, 671 | }); 672 | 673 | // Should respect all options 674 | t.true(isIgnored('subdir/file.log')); 675 | t.true(isIgnored('subdir/deep/file.log')); 676 | }); 677 | 678 | test('option: multiple options combined - sync', t => { 679 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore-nested'); 680 | 681 | const isIgnored = isGitIgnoredSync({ 682 | cwd, 683 | deep: 2, 684 | ignore: ['**/deep/deeper/**'], 685 | suppressErrors: true, 686 | followSymbolicLinks: false, 687 | concurrency: 2, 688 | }); 689 | 690 | t.true(isIgnored('subdir/file.log')); 691 | t.true(isIgnored('subdir/deep/file.log')); 692 | }); 693 | 694 | test('isIgnoredByIgnoreFiles - option: suppressErrors', async t => { 695 | const cwd = path.join(PROJECT_ROOT, 'fixtures/ignore-files'); 696 | 697 | const isIgnored = await isIgnoredByIgnoreFiles('.eslintignore', { 698 | cwd, 699 | suppressErrors: true, 700 | }); 701 | 702 | t.true(isIgnored('ignored-by-eslint.js')); 703 | t.false(isIgnored('not-ignored.js')); 704 | }); 705 | 706 | test('isIgnoredByIgnoreFiles - option: deep', async t => { 707 | const cwd = path.join(PROJECT_ROOT, 'fixtures'); 708 | 709 | // With deep: 0, should only find .eslintignore in fixtures directory 710 | const isIgnored = await isIgnoredByIgnoreFiles('**/.eslintignore', { 711 | cwd, 712 | deep: 1, 713 | }); 714 | 715 | // Should find ignore-files/.eslintignore 716 | t.is(typeof isIgnored('ignored-by-eslint.js'), 'boolean'); 717 | }); 718 | 719 | test('isIgnoredByIgnoreFiles - option: ignore', async t => { 720 | const cwd = path.join(PROJECT_ROOT, 'fixtures'); 721 | 722 | // Ignore .eslintignore in specific directories 723 | const isIgnored = await isIgnoredByIgnoreFiles('**/.eslintignore', { 724 | cwd, 725 | ignore: '**/ignore-files/**', 726 | }); 727 | 728 | // Should not find any .eslintignore files 729 | t.is(typeof isIgnored('test.js'), 'boolean'); 730 | }); 731 | 732 | test('isIgnoredByIgnoreFiles - multiple options', async t => { 733 | const cwd = path.join(PROJECT_ROOT, 'fixtures/ignore-files'); 734 | 735 | const isIgnored = await isIgnoredByIgnoreFiles('.{eslint,prettier}ignore', { 736 | cwd, 737 | suppressErrors: true, 738 | deep: 1, 739 | followSymbolicLinks: false, 740 | concurrency: 2, 741 | throwErrorOnBrokenSymbolicLink: false, 742 | }); 743 | 744 | t.true(isIgnored('ignored-by-eslint.js')); 745 | t.true(isIgnored('ignored-by-prettier.js')); 746 | t.false(isIgnored('not-ignored.js')); 747 | }); 748 | 749 | test('isIgnoredByIgnoreFilesSync - option: suppressErrors', t => { 750 | const cwd = path.join(PROJECT_ROOT, 'fixtures/ignore-files'); 751 | 752 | const isIgnored = isIgnoredByIgnoreFilesSync('.eslintignore', { 753 | cwd, 754 | suppressErrors: true, 755 | }); 756 | 757 | t.true(isIgnored('ignored-by-eslint.js')); 758 | t.false(isIgnored('not-ignored.js')); 759 | }); 760 | 761 | test('isIgnoredByIgnoreFilesSync - option: deep', t => { 762 | const cwd = path.join(PROJECT_ROOT, 'fixtures'); 763 | 764 | const isIgnored = isIgnoredByIgnoreFilesSync('**/.eslintignore', { 765 | cwd, 766 | deep: 1, 767 | }); 768 | 769 | t.is(typeof isIgnored('test.js'), 'boolean'); 770 | }); 771 | 772 | test('isIgnoredByIgnoreFilesSync - option: ignore string', t => { 773 | const cwd = path.join(PROJECT_ROOT, 'fixtures'); 774 | 775 | // Test with string instead of array 776 | const isIgnored = isIgnoredByIgnoreFilesSync('**/.eslintignore', { 777 | cwd, 778 | ignore: '**/node_modules/**', 779 | }); 780 | 781 | t.is(typeof isIgnored('test.js'), 'boolean'); 782 | }); 783 | 784 | test('isIgnoredByIgnoreFilesSync - multiple options', t => { 785 | const cwd = path.join(PROJECT_ROOT, 'fixtures/ignore-files'); 786 | 787 | const isIgnored = isIgnoredByIgnoreFilesSync('.{eslint,prettier}ignore', { 788 | cwd, 789 | suppressErrors: true, 790 | deep: 1, 791 | followSymbolicLinks: false, 792 | concurrency: 4, 793 | }); 794 | 795 | t.true(isIgnored('ignored-by-eslint.js')); 796 | t.true(isIgnored('ignored-by-prettier.js')); 797 | t.false(isIgnored('not-ignored.js')); 798 | }); 799 | 800 | test('fs option preserves context for gitignore helpers', async t => { 801 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 802 | const fsImplementation = createContextAwareFs(); 803 | 804 | const asyncPredicate = await isGitIgnored({cwd, fs: fsImplementation}); 805 | const syncPredicate = isGitIgnoredSync({cwd, fs: fsImplementation}); 806 | 807 | t.true(asyncPredicate('foo.js')); 808 | t.true(syncPredicate('foo.js')); 809 | t.false(asyncPredicate('bar.js')); 810 | t.false(syncPredicate('bar.js')); 811 | }); 812 | 813 | test('fs option preserves context for ignore file readers', async t => { 814 | const cwd = path.join(PROJECT_ROOT, 'fixtures/ignore-files'); 815 | const fsImplementation = createContextAwareFs(); 816 | 817 | const asyncPredicate = await isIgnoredByIgnoreFiles('.eslintignore', { 818 | cwd, 819 | fs: fsImplementation, 820 | }); 821 | const syncPredicate = isIgnoredByIgnoreFilesSync('.eslintignore', { 822 | cwd, 823 | fs: fsImplementation, 824 | }); 825 | 826 | t.true(asyncPredicate('ignored-by-eslint.js')); 827 | t.true(syncPredicate('ignored-by-eslint.js')); 828 | t.false(asyncPredicate('not-ignored.js')); 829 | t.false(syncPredicate('not-ignored.js')); 830 | }); 831 | 832 | test('path prefix edge case - paths with similar prefix outside cwd return false', async t => { 833 | const cwd = path.join(PROJECT_ROOT, 'fixtures/gitignore'); 834 | const isIgnored = await isGitIgnored({cwd}); 835 | 836 | // Test that paths outside the cwd but with similar prefix return false (not ignored) 837 | // e.g., if cwd is /foo/bar, then /foo/barextra should not be ignored 838 | // because it's outside cwd and gitignore patterns from cwd don't apply to it 839 | const outsidePath = cwd + 'extra/file.js'; // Creates path like /foo/gitignoreextra/file.js 840 | 841 | // Should return false (not ignored) rather than throwing error 842 | t.false(isIgnored(outsidePath)); 843 | }); 844 | 845 | test('createIgnorePredicate normalizes both cwd and baseDir consistently', async t => { 846 | const temporary = temporaryDirectory(); 847 | const gitignorePath = path.join(temporary, '.gitignore'); 848 | fs.writeFileSync(gitignorePath, 'ignored.js\n'); 849 | 850 | const file1 = path.join(temporary, 'ignored.js'); 851 | const file2 = path.join(temporary, 'kept.js'); 852 | fs.writeFileSync(file1, ''); 853 | fs.writeFileSync(file2, ''); 854 | 855 | // Test with different path formats (relative, absolute, with ..) 856 | const {predicate} = await getIgnorePatternsAndPredicate( 857 | ['.gitignore'], 858 | {cwd: path.join(temporary, '..', path.basename(temporary))}, 859 | ); 860 | 861 | // Should handle normalized paths correctly 862 | t.true(predicate(file1)); 863 | t.false(predicate(file2)); 864 | }); 865 | 866 | test('dedupePaths removes duplicate gitignore file paths', async t => { 867 | const repository = createTemporaryGitRepository(); 868 | const childDirectory = path.join(repository, 'child'); 869 | 870 | fs.mkdirSync(childDirectory); 871 | fs.writeFileSync(path.join(repository, '.gitignore'), 'root\n'); 872 | fs.writeFileSync(path.join(childDirectory, '.gitignore'), 'child\n'); 873 | 874 | // This internally uses dedupePaths to avoid reading the same file twice 875 | const {patterns} = await getIgnorePatternsAndPredicate( 876 | ['**/.gitignore'], 877 | {cwd: childDirectory}, 878 | true, 879 | ); 880 | 881 | // Should have patterns from both files 882 | // The patterns are transformed based on their location relative to the git root 883 | t.true(patterns.some(p => p.includes('root')), 'Should include root pattern'); 884 | t.true(patterns.some(p => p.includes('child')), 'Should include child pattern'); 885 | }); 886 | -------------------------------------------------------------------------------- /tests/globby.js: -------------------------------------------------------------------------------- 1 | import {Buffer} from 'node:buffer'; 2 | import process from 'node:process'; 3 | import fs from 'node:fs'; 4 | import path from 'node:path'; 5 | import {format} from 'node:util'; 6 | import test from 'ava'; 7 | import {temporaryDirectory} from 'tempy'; 8 | import { 9 | globby, 10 | globbySync, 11 | globbyStream, 12 | isDynamicPattern, 13 | } from '../index.js'; 14 | import {normalizeDirectoryPatternForFastGlob, normalizeAbsolutePatternToRelative} from '../utilities.js'; 15 | import { 16 | PROJECT_ROOT, 17 | createContextAwareFs, 18 | createTemporaryGitRepository, 19 | getPathValues, 20 | invalidPatterns, 21 | isUnique, 22 | } from './utilities.js'; 23 | 24 | const cwd = process.cwd(); 25 | const temporary = 'tmp'; 26 | 27 | const cwdDirectoryError = {message: /The `cwd` option must be a path to a directory, got:/}; 28 | 29 | const fixture = [ 30 | 'a.tmp', 31 | 'b.tmp', 32 | 'c.tmp', 33 | 'd.tmp', 34 | 'e.tmp', 35 | ]; 36 | 37 | const stabilizeResult = result => result 38 | .map(fastGlobResult => { 39 | // In `objectMode`, `fastGlobResult.dirent` contains a function that makes `t.deepEqual` assertion fail. 40 | // `fastGlobResult.stats` contains different `atime`. 41 | if (typeof fastGlobResult === 'object') { 42 | const {dirent, stats, ...rest} = fastGlobResult; 43 | return rest; 44 | } 45 | 46 | return fastGlobResult; 47 | }) 48 | .sort((a, b) => (a.path ?? a).localeCompare(b.path ?? b)); 49 | 50 | const runGlobby = async (t, patterns, options) => { 51 | const syncResult = globbySync(patterns, options); 52 | const promiseResult = await globby(patterns, options); 53 | const streamResult = await globbyStream(patterns, options).toArray(); 54 | 55 | const result = stabilizeResult(promiseResult); 56 | t.deepEqual( 57 | stabilizeResult(syncResult), 58 | result, 59 | 'globbySync() result is different than globby()', 60 | ); 61 | t.deepEqual( 62 | stabilizeResult(streamResult), 63 | result, 64 | 'globbyStream() result is different than globby()', 65 | ); 66 | 67 | return promiseResult; 68 | }; 69 | 70 | const blockNodeModulesTraversal = directory => { 71 | const normalizedDirectory = path.normalize(directory); 72 | const directoryPrefix = `${normalizedDirectory}${path.sep}`; 73 | const fsPromises = fs.promises; 74 | const originalReaddir = fs.readdir; 75 | const originalReaddirSync = fs.readdirSync; 76 | const originalReaddirPromise = fsPromises.readdir; 77 | const originalOpendir = fs.opendir; 78 | const originalOpendirSync = fs.opendirSync; 79 | const originalOpendirPromise = fsPromises.opendir; 80 | 81 | const toStringPath = value => { 82 | if (typeof value === 'string') { 83 | return value; 84 | } 85 | 86 | if (value instanceof Buffer) { 87 | return value.toString(); 88 | } 89 | 90 | return value ? String(value) : ''; 91 | }; 92 | 93 | const normalizeCandidate = value => { 94 | const stringPath = toStringPath(value); 95 | if (!stringPath) { 96 | return stringPath; 97 | } 98 | 99 | const absolutePath = path.isAbsolute(stringPath) 100 | ? stringPath 101 | : path.join(directory, stringPath); 102 | return path.normalize(absolutePath); 103 | }; 104 | 105 | const shouldBlock = value => { 106 | if (!value) { 107 | return false; 108 | } 109 | 110 | const normalizedPath = normalizeCandidate(value); 111 | if (!normalizedPath || !normalizedPath.startsWith(directoryPrefix)) { 112 | return false; 113 | } 114 | 115 | return normalizedPath.split(path.sep).includes('node_modules'); 116 | }; 117 | 118 | const createPermissionError = value => { 119 | const error = new Error('Blocked node_modules traversal'); 120 | error.code = 'EACCES'; 121 | error.path = toStringPath(value); 122 | return error; 123 | }; 124 | 125 | const wrapCallbackStyle = original => (...args) => { 126 | let pathValue; 127 | let options; 128 | let callback; 129 | 130 | if (args.length === 2) { 131 | [pathValue, callback] = args; 132 | options = undefined; 133 | } else { 134 | [pathValue, options, callback] = args; 135 | } 136 | 137 | if (typeof options === 'function') { 138 | callback = options; 139 | options = undefined; 140 | } 141 | 142 | if (shouldBlock(pathValue)) { 143 | const error = createPermissionError(pathValue); 144 | queueMicrotask(() => callback(error)); 145 | return; 146 | } 147 | 148 | const callArguments = options === undefined ? [pathValue, callback] : [pathValue, options, callback]; 149 | return original.apply(fs, callArguments); 150 | }; 151 | 152 | const wrapSync = original => (pathValue, options) => { 153 | if (shouldBlock(pathValue)) { 154 | throw createPermissionError(pathValue); 155 | } 156 | 157 | return options === undefined 158 | ? original.call(fs, pathValue) 159 | : original.call(fs, pathValue, options); 160 | }; 161 | 162 | const wrapPromise = original => async (pathValue, options) => { 163 | if (shouldBlock(pathValue)) { 164 | throw createPermissionError(pathValue); 165 | } 166 | 167 | return options === undefined 168 | ? original.call(fsPromises, pathValue) 169 | : original.call(fsPromises, pathValue, options); 170 | }; 171 | 172 | fs.readdir = wrapCallbackStyle(originalReaddir); 173 | fs.readdirSync = wrapSync(originalReaddirSync); 174 | fsPromises.readdir = wrapPromise(originalReaddirPromise); 175 | fs.opendir = wrapCallbackStyle(originalOpendir); 176 | fs.opendirSync = wrapSync(originalOpendirSync); 177 | fsPromises.opendir = wrapPromise(originalOpendirPromise); 178 | 179 | return () => { 180 | fs.readdir = originalReaddir; 181 | fs.readdirSync = originalReaddirSync; 182 | fsPromises.readdir = originalReaddirPromise; 183 | fs.opendir = originalOpendir; 184 | fs.opendirSync = originalOpendirSync; 185 | fsPromises.opendir = originalOpendirPromise; 186 | }; 187 | }; 188 | 189 | test.before(() => { 190 | if (!fs.existsSync(temporary)) { 191 | fs.mkdirSync(temporary); 192 | } 193 | 194 | for (const element of fixture) { 195 | fs.writeFileSync(element, ''); 196 | fs.writeFileSync(path.join(PROJECT_ROOT, temporary, element), ''); 197 | } 198 | }); 199 | 200 | test.after(() => { 201 | for (const element of fixture) { 202 | fs.unlinkSync(element); 203 | fs.unlinkSync(path.join(PROJECT_ROOT, temporary, element)); 204 | } 205 | 206 | fs.rmdirSync(temporary); 207 | }); 208 | 209 | test('normalizeDirectoryPatternForFastGlob handles recursive directory patterns', t => { 210 | t.is(normalizeDirectoryPatternForFastGlob('node_modules/'), '**/node_modules/**'); 211 | t.is(normalizeDirectoryPatternForFastGlob('build/'), '**/build/**'); 212 | t.is(normalizeDirectoryPatternForFastGlob('/dist/'), '/dist/**'); 213 | t.is(normalizeDirectoryPatternForFastGlob('src/cache/'), 'src/cache/**'); 214 | t.is(normalizeDirectoryPatternForFastGlob('packages/**/cache/'), 'packages/**/cache/**'); 215 | t.is(normalizeDirectoryPatternForFastGlob('keep.log'), 'keep.log'); 216 | t.is(normalizeDirectoryPatternForFastGlob('**/'), '**/**', '**/ should normalize to **/** not **/**/**'); 217 | t.is(normalizeDirectoryPatternForFastGlob('/'), '/**', '/ should normalize to /**'); 218 | t.is(normalizeDirectoryPatternForFastGlob(''), '', 'empty string should remain empty'); 219 | }); 220 | 221 | test('normalizeAbsolutePatternToRelative strips leading slash', t => { 222 | t.is(normalizeAbsolutePatternToRelative('/**'), '**'); 223 | t.is(normalizeAbsolutePatternToRelative('/foo'), 'foo'); 224 | t.is(normalizeAbsolutePatternToRelative('/foo/**'), 'foo/**'); 225 | t.is(normalizeAbsolutePatternToRelative('/*.txt'), '*.txt'); 226 | t.is(normalizeAbsolutePatternToRelative('foo'), 'foo', 'relative patterns unchanged'); 227 | t.is(normalizeAbsolutePatternToRelative('**'), '**', 'globstar unchanged'); 228 | t.is(normalizeAbsolutePatternToRelative(''), '', 'empty string unchanged'); 229 | }); 230 | 231 | test('glob', async t => { 232 | const result = await runGlobby(t, '*.tmp'); 233 | t.deepEqual(result.sort(), ['a.tmp', 'b.tmp', 'c.tmp', 'd.tmp', 'e.tmp']); 234 | }); 235 | 236 | test('glob - multiple file paths', async t => { 237 | t.deepEqual(await runGlobby(t, ['a.tmp', 'b.tmp']), ['a.tmp', 'b.tmp']); 238 | }); 239 | 240 | test('glob - empty patterns', async t => { 241 | t.deepEqual(await runGlobby(t, []), []); 242 | }); 243 | 244 | test('glob with multiple patterns', async t => { 245 | t.deepEqual(await runGlobby(t, ['a.tmp', '*.tmp', '!{c,d,e}.tmp']), ['a.tmp', 'b.tmp']); 246 | }); 247 | 248 | test('respect patterns order', async t => { 249 | t.deepEqual(await runGlobby(t, ['!*.tmp', 'a.tmp']), ['a.tmp']); 250 | }); 251 | 252 | test('negation-only patterns match all files in cwd except negated ones', async t => { 253 | // When using negation-only patterns in a scoped directory, it should match all files except the negated ones 254 | t.deepEqual(await runGlobby(t, ['!a.tmp', '!b.tmp'], {cwd: temporary}), ['c.tmp', 'd.tmp', 'e.tmp']); 255 | }); 256 | 257 | test('single negation-only pattern in scoped directory', async t => { 258 | const result = await runGlobby(t, '!a.tmp', {cwd: temporary}); 259 | t.deepEqual(result, ['b.tmp', 'c.tmp', 'd.tmp', 'e.tmp']); 260 | }); 261 | 262 | test('negation-only with brace expansion in scoped directory', async t => { 263 | const result = await runGlobby(t, '!{a,b}.tmp', {cwd: temporary}); 264 | t.deepEqual(result, ['c.tmp', 'd.tmp', 'e.tmp']); 265 | }); 266 | 267 | test('negation pattern with absolute path is normalized to relative', async t => { 268 | // !/** should exclude everything (cross-platform consistent behavior) 269 | // On Unix, /** is normally an absolute path from filesystem root 270 | // We normalize it to ** so it works the same on all platforms 271 | const result = await runGlobby(t, '!/**', {cwd: temporary}); 272 | t.deepEqual(result, []); 273 | }); 274 | 275 | test('expandNegationOnlyPatterns: false returns empty array for negation-only patterns', async t => { 276 | const result = await runGlobby(t, ['!a.tmp', '!b.tmp'], {cwd: temporary, expandNegationOnlyPatterns: false}); 277 | t.deepEqual(result, []); 278 | }); 279 | 280 | test('expandNegationOnlyPatterns: false with single negation pattern returns empty array', async t => { 281 | const result = await runGlobby(t, '!a.tmp', {cwd: temporary, expandNegationOnlyPatterns: false}); 282 | t.deepEqual(result, []); 283 | }); 284 | 285 | test('expandNegationOnlyPatterns: false does not affect mixed patterns', async t => { 286 | // When there are positive patterns, negation-only expansion is not triggered 287 | const result = await runGlobby(t, ['*.tmp', '!a.tmp', '!b.tmp'], {cwd: temporary, expandNegationOnlyPatterns: false}); 288 | t.deepEqual(result, ['c.tmp', 'd.tmp', 'e.tmp']); 289 | }); 290 | 291 | test('expandNegationOnlyPatterns: true (default) works with negation-only patterns', async t => { 292 | const result = await runGlobby(t, ['!a.tmp', '!b.tmp'], {cwd: temporary, expandNegationOnlyPatterns: true}); 293 | t.deepEqual(result, ['c.tmp', 'd.tmp', 'e.tmp']); 294 | }); 295 | 296 | test('glob - stream async iterator support', async t => { 297 | const results = []; 298 | for await (const path of globbyStream('*.tmp')) { 299 | results.push(path); 300 | } 301 | 302 | t.deepEqual(results, ['a.tmp', 'b.tmp', 'c.tmp', 'd.tmp', 'e.tmp']); 303 | }); 304 | 305 | /// test('glob - duplicated patterns', async t => { 306 | // const result1 = await runGlobby(t, [`./${temporary}/**`, `./${temporary}`]); 307 | // t.deepEqual(result1, ['./tmp/a.tmp', './tmp/b.tmp', './tmp/c.tmp', './tmp/d.tmp', './tmp/e.tmp']); 308 | // const result2 = await runGlobby(t, [`./${temporary}`, `./${temporary}/**`]); 309 | // t.deepEqual(result2, ['tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp']); 310 | // }); 311 | 312 | test.serial('cwd option', async t => { 313 | process.chdir(temporary); 314 | t.deepEqual(await runGlobby(t, '*.tmp', {cwd}), ['a.tmp', 'b.tmp', 'c.tmp', 'd.tmp', 'e.tmp']); 315 | t.deepEqual(await runGlobby(t, ['a.tmp', '*.tmp', '!{c,d,e}.tmp'], {cwd}), ['a.tmp', 'b.tmp']); 316 | process.chdir(cwd); 317 | }); 318 | 319 | test('don\'t mutate the options object', async t => { 320 | await runGlobby(t, ['*.tmp', '!b.tmp'], Object.freeze({ignore: Object.freeze([])})); 321 | t.pass(); 322 | }); 323 | 324 | test('expose isDynamicPattern', t => { 325 | t.true(isDynamicPattern('**')); 326 | t.true(isDynamicPattern(['**', 'path1', 'path2'])); 327 | t.false(isDynamicPattern(['path1', 'path2'])); 328 | 329 | for (const cwdDirectory of getPathValues(cwd)) { 330 | t.true(isDynamicPattern('**', {cwd: cwdDirectory})); 331 | } 332 | }); 333 | 334 | test('expandDirectories option', async t => { 335 | t.deepEqual(await runGlobby(t, temporary), ['tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp']); 336 | for (const temporaryDirectory of getPathValues(temporary)) { 337 | // eslint-disable-next-line no-await-in-loop 338 | t.deepEqual(await runGlobby(t, '**', {cwd: temporaryDirectory}), ['a.tmp', 'b.tmp', 'c.tmp', 'd.tmp', 'e.tmp']); 339 | } 340 | 341 | t.deepEqual(await runGlobby(t, temporary, {expandDirectories: ['a*', 'b*']}), ['tmp/a.tmp', 'tmp/b.tmp']); 342 | t.deepEqual(await runGlobby(t, temporary, { 343 | expandDirectories: { 344 | files: ['a', 'b'], 345 | extensions: ['tmp'], 346 | }, 347 | }), ['tmp/a.tmp', 'tmp/b.tmp']); 348 | t.deepEqual(await runGlobby(t, temporary, { 349 | expandDirectories: { 350 | files: ['a', 'b'], 351 | extensions: ['tmp'], 352 | }, 353 | ignore: ['**/b.tmp'], 354 | }), ['tmp/a.tmp']); 355 | t.deepEqual(await runGlobby(t, temporary, { 356 | expandDirectories: { 357 | extensions: ['tmp'], 358 | }, 359 | }), ['tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp']); 360 | }); 361 | 362 | test('fs option preserves context during directory expansion', async t => { 363 | const fsImplementation = createContextAwareFs(); 364 | const result = await runGlobby(t, temporary, {fs: fsImplementation}); 365 | t.deepEqual(result, ['tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp']); 366 | }); 367 | 368 | test('expandDirectories:true and onlyFiles:true option', async t => { 369 | t.deepEqual(await runGlobby(t, temporary, {onlyFiles: true}), ['tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp']); 370 | }); 371 | 372 | test.failing('expandDirectories:true and onlyFiles:false option', async t => { 373 | // Node-glob('tmp/**') => ['tmp', 'tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp'] 374 | // Fast-glob('tmp/**') => ['tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp'] 375 | // See https://github.com/mrmlnc/fast-glob/issues/47 376 | t.deepEqual(await runGlobby(t, temporary, {onlyFiles: false}), ['tmp', 'tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp']); 377 | }); 378 | 379 | test('expandDirectories and ignores option', async t => { 380 | t.deepEqual(await runGlobby(t, 'tmp', { 381 | ignore: ['tmp'], 382 | }), []); 383 | 384 | t.deepEqual(await runGlobby(t, 'tmp/**', { 385 | expandDirectories: false, 386 | ignore: ['tmp'], 387 | }), ['tmp/a.tmp', 'tmp/b.tmp', 'tmp/c.tmp', 'tmp/d.tmp', 'tmp/e.tmp']); 388 | }); 389 | 390 | test('ignore option with trailing slashes on directories (issue #160)', async t => { 391 | const temporaryCwd = temporaryDirectory(); 392 | const ignoreFirst = path.join(temporaryCwd, 'ignore-first'); 393 | const ignoreSecond = path.join(temporaryCwd, 'ignore-second'); 394 | const keepThis = path.join(temporaryCwd, 'keep-this.txt'); 395 | 396 | fs.mkdirSync(ignoreFirst); 397 | fs.mkdirSync(ignoreSecond); 398 | fs.writeFileSync(path.join(ignoreFirst, 'file.txt'), '', 'utf8'); 399 | fs.writeFileSync(path.join(ignoreSecond, 'file.txt'), '', 'utf8'); 400 | fs.writeFileSync(keepThis, '', 'utf8'); 401 | 402 | try { 403 | // Test with trailing slash on first directory 404 | const result1 = await runGlobby(t, '**/*', { 405 | cwd: temporaryCwd, 406 | ignore: ['ignore-first/', 'ignore-second'], 407 | }); 408 | t.false(result1.some(file => file.includes('ignore-first')), 'ignore-first/ with trailing slash should be ignored'); 409 | t.false(result1.some(file => file.includes('ignore-second')), 'ignore-second without trailing slash should be ignored'); 410 | t.true(result1.includes('keep-this.txt'), 'keep-this.txt should not be ignored'); 411 | 412 | // Test with trailing slashes on both directories 413 | const result2 = await runGlobby(t, '**/*', { 414 | cwd: temporaryCwd, 415 | ignore: ['ignore-first/', 'ignore-second/'], 416 | }); 417 | t.false(result2.some(file => file.includes('ignore-first')), 'ignore-first/ should be ignored'); 418 | t.false(result2.some(file => file.includes('ignore-second')), 'ignore-second/ should be ignored'); 419 | t.true(result2.includes('keep-this.txt'), 'keep-this.txt should not be ignored'); 420 | } finally { 421 | fs.rmSync(temporaryCwd, {recursive: true, force: true}); 422 | } 423 | }); 424 | 425 | test('absolute:true, expandDirectories:false, onlyFiles:false, gitignore:true and top level folder', async t => { 426 | const result = await runGlobby(t, '.', { 427 | absolute: true, 428 | cwd: path.resolve(temporary), 429 | expandDirectories: false, 430 | gitignore: true, 431 | onlyFiles: false, 432 | }); 433 | 434 | t.is(result.length, 1); 435 | t.truthy(result[0].endsWith(temporary)); 436 | }); 437 | 438 | test.serial.failing('relative paths and ignores option', async t => { 439 | process.chdir(temporary); 440 | try { 441 | for (const temporaryCwd of getPathValues(process.cwd())) { 442 | // eslint-disable-next-line no-await-in-loop 443 | t.deepEqual(await runGlobby(t, '../tmp', { 444 | cwd: temporaryCwd, 445 | ignore: ['tmp'], 446 | }), []); 447 | } 448 | } finally { 449 | process.chdir(cwd); 450 | } 451 | }); 452 | 453 | test.serial('parent directory patterns with ** ignore patterns (issue #90)', async t => { 454 | // Create a test directory structure: parent/child/node_modules 455 | const temporaryParent = temporaryDirectory(); 456 | const temporaryChild = path.join(temporaryParent, 'child'); 457 | const nodeModulesDir = path.join(temporaryParent, 'node_modules', 'foo'); 458 | 459 | fs.mkdirSync(nodeModulesDir, {recursive: true}); 460 | fs.mkdirSync(temporaryChild, {recursive: true}); 461 | 462 | const testFile = path.join(temporaryParent, 'test.js'); 463 | const nodeModulesFile = path.join(nodeModulesDir, 'index.js'); 464 | const childFile = path.join(temporaryChild, 'child.js'); 465 | 466 | fs.writeFileSync(testFile, '', 'utf8'); 467 | fs.writeFileSync(nodeModulesFile, '', 'utf8'); 468 | fs.writeFileSync(childFile, '', 'utf8'); 469 | 470 | try { 471 | // Test with ignore option 472 | const result1 = await runGlobby(t, ['..'], { 473 | cwd: temporaryChild, 474 | ignore: ['**/node_modules/**'], 475 | }); 476 | t.false(result1.some(p => p.includes('node_modules')), 'ignore option should exclude node_modules'); 477 | 478 | // Test with negation pattern 479 | const result2 = await runGlobby(t, ['..', '!**/node_modules/**'], { 480 | cwd: temporaryChild, 481 | }); 482 | t.false(result2.some(p => p.includes('node_modules')), 'negation pattern should exclude node_modules'); 483 | 484 | // Both should include the non-node_modules files 485 | t.true(result1.some(p => p.endsWith('test.js')), 'should include test.js'); 486 | t.true(result1.some(p => p.endsWith('child.js')), 'should include child.js'); 487 | } finally { 488 | fs.rmSync(temporaryParent, {recursive: true, force: true}); 489 | } 490 | }); 491 | 492 | test.serial('parent directory patterns - multiple levels (../../)', async t => { 493 | const temporaryGrandparent = temporaryDirectory(); 494 | const temporaryParent = path.join(temporaryGrandparent, 'parent'); 495 | const temporaryChild = path.join(temporaryParent, 'child'); 496 | const distDir = path.join(temporaryGrandparent, 'dist'); 497 | 498 | fs.mkdirSync(distDir, {recursive: true}); 499 | fs.mkdirSync(temporaryChild, {recursive: true}); 500 | 501 | const rootFile = path.join(temporaryGrandparent, 'root.js'); 502 | const distFile = path.join(distDir, 'bundle.js'); 503 | 504 | fs.writeFileSync(rootFile, '', 'utf8'); 505 | fs.writeFileSync(distFile, '', 'utf8'); 506 | 507 | try { 508 | const result = await runGlobby(t, ['../..'], { 509 | cwd: temporaryChild, 510 | ignore: ['**/dist/**'], 511 | }); 512 | 513 | t.false(result.some(p => p.includes('dist')), 'should exclude dist directory'); 514 | t.true(result.some(p => p.endsWith('root.js')), 'should include root.js'); 515 | } finally { 516 | fs.rmSync(temporaryGrandparent, {recursive: true, force: true}); 517 | } 518 | }); 519 | 520 | test.serial('parent directory patterns - already prefixed ignore patterns', async t => { 521 | const temporaryParent = temporaryDirectory(); 522 | const temporaryChild = path.join(temporaryParent, 'child'); 523 | const buildDir = path.join(temporaryParent, 'build'); 524 | 525 | fs.mkdirSync(buildDir, {recursive: true}); 526 | fs.mkdirSync(temporaryChild, {recursive: true}); 527 | 528 | const buildFile = path.join(buildDir, 'output.js'); 529 | const testFile = path.join(temporaryParent, 'test.js'); 530 | 531 | fs.writeFileSync(buildFile, '', 'utf8'); 532 | fs.writeFileSync(testFile, '', 'utf8'); 533 | 534 | try { 535 | const result = await runGlobby(t, ['..'], { 536 | cwd: temporaryChild, 537 | ignore: ['../**/build/**'], 538 | }); 539 | 540 | t.false(result.some(p => p.includes('build')), 'should exclude build with pre-prefixed ignore'); 541 | t.true(result.some(p => p.endsWith('test.js')), 'should include test.js'); 542 | } finally { 543 | fs.rmSync(temporaryParent, {recursive: true, force: true}); 544 | } 545 | }); 546 | 547 | test.serial('parent directory patterns - multiple ignore patterns', async t => { 548 | const temporaryParent = temporaryDirectory(); 549 | const temporaryChild = path.join(temporaryParent, 'child'); 550 | const nodeModulesDir = path.join(temporaryParent, 'node_modules'); 551 | const distDir = path.join(temporaryParent, 'dist'); 552 | const buildDir = path.join(temporaryParent, 'build'); 553 | 554 | fs.mkdirSync(nodeModulesDir, {recursive: true}); 555 | fs.mkdirSync(distDir, {recursive: true}); 556 | fs.mkdirSync(buildDir, {recursive: true}); 557 | fs.mkdirSync(temporaryChild, {recursive: true}); 558 | 559 | const nodeModulesFile = path.join(nodeModulesDir, 'pkg.js'); 560 | const distFile = path.join(distDir, 'bundle.js'); 561 | const buildFile = path.join(buildDir, 'output.js'); 562 | const sourceFile = path.join(temporaryParent, 'source.js'); 563 | 564 | fs.writeFileSync(nodeModulesFile, '', 'utf8'); 565 | fs.writeFileSync(distFile, '', 'utf8'); 566 | fs.writeFileSync(buildFile, '', 'utf8'); 567 | fs.writeFileSync(sourceFile, '', 'utf8'); 568 | 569 | try { 570 | const result = await runGlobby(t, ['..'], { 571 | cwd: temporaryChild, 572 | ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'], 573 | }); 574 | 575 | t.false(result.some(p => p.includes('node_modules')), 'should exclude node_modules'); 576 | t.false(result.some(p => p.includes('dist')), 'should exclude dist'); 577 | t.false(result.some(p => p.includes('build')), 'should exclude build'); 578 | t.true(result.some(p => p.endsWith('source.js')), 'should include source.js'); 579 | } finally { 580 | fs.rmSync(temporaryParent, {recursive: true, force: true}); 581 | } 582 | }); 583 | 584 | test.serial('parent directory patterns - mixed patterns should not adjust', async t => { 585 | const temporaryParent = temporaryDirectory(); 586 | const temporaryChild = path.join(temporaryParent, 'child'); 587 | const nodeModulesDir = path.join(temporaryParent, 'node_modules'); 588 | const srcDir = path.join(temporaryChild, 'src'); 589 | 590 | fs.mkdirSync(nodeModulesDir, {recursive: true}); 591 | fs.mkdirSync(srcDir, {recursive: true}); 592 | 593 | const nodeModulesFile = path.join(nodeModulesDir, 'pkg.js'); 594 | const srcFile = path.join(srcDir, 'index.js'); 595 | 596 | fs.writeFileSync(nodeModulesFile, '', 'utf8'); 597 | fs.writeFileSync(srcFile, '', 'utf8'); 598 | 599 | try { 600 | const result = await runGlobby(t, ['..', 'src'], { 601 | cwd: temporaryChild, 602 | ignore: ['**/node_modules/**'], 603 | }); 604 | 605 | t.true(result.some(p => p.includes('node_modules')), 'should include node_modules when patterns have mixed bases'); 606 | t.true(result.some(p => p.includes('src')), 'should include src files'); 607 | } finally { 608 | fs.rmSync(temporaryParent, {recursive: true, force: true}); 609 | } 610 | }); 611 | 612 | test.serial('parent directory patterns - with same prefix patterns', async t => { 613 | const temporaryGrandparent = temporaryDirectory(); 614 | const temporaryParent = path.join(temporaryGrandparent, 'parent'); 615 | const temporaryChild = path.join(temporaryParent, 'child'); 616 | const nodeModulesDir = path.join(temporaryGrandparent, 'node_modules'); 617 | const libDir = path.join(temporaryGrandparent, 'lib'); 618 | 619 | fs.mkdirSync(nodeModulesDir, {recursive: true}); 620 | fs.mkdirSync(libDir, {recursive: true}); 621 | fs.mkdirSync(temporaryChild, {recursive: true}); 622 | 623 | const nodeModulesFile = path.join(nodeModulesDir, 'pkg.js'); 624 | const libFile = path.join(libDir, 'helper.js'); 625 | const rootFile = path.join(temporaryGrandparent, 'index.js'); 626 | 627 | fs.writeFileSync(nodeModulesFile, '', 'utf8'); 628 | fs.writeFileSync(libFile, '', 'utf8'); 629 | fs.writeFileSync(rootFile, '', 'utf8'); 630 | 631 | try { 632 | const result = await runGlobby(t, ['../../lib/**', '../../*.js'], { 633 | cwd: temporaryChild, 634 | ignore: ['**/node_modules/**'], 635 | }); 636 | 637 | t.false(result.some(p => p.includes('node_modules')), 'should exclude node_modules with same prefix'); 638 | t.true(result.some(p => p.endsWith('helper.js')), 'should include lib files'); 639 | t.true(result.some(p => p.endsWith('index.js')), 'should include root js files'); 640 | } finally { 641 | fs.rmSync(temporaryGrandparent, {recursive: true, force: true}); 642 | } 643 | }); 644 | 645 | // Rejected for being an invalid pattern 646 | for (const value of invalidPatterns) { 647 | const valueString = format(value); 648 | const message = 'Patterns must be a string or an array of strings'; 649 | 650 | test(`throws for invalid patterns input: ${valueString}`, async t => { 651 | await t.throwsAsync(globby(value), {instanceOf: TypeError, message}); 652 | t.throws(() => globbySync(value), {instanceOf: TypeError, message}); 653 | t.throws(() => globbyStream(value), {instanceOf: TypeError, message}); 654 | t.throws(() => isDynamicPattern(value), {instanceOf: TypeError, message}); 655 | }); 656 | } 657 | 658 | test('gitignore option defaults to false - async', async t => { 659 | const actual = await runGlobby(t, '*', {onlyFiles: false}); 660 | t.true(actual.includes('node_modules')); 661 | }); 662 | 663 | test('respects gitignore option true', async t => { 664 | const actual = await runGlobby(t, '*', {gitignore: true, onlyFiles: false}); 665 | t.false(actual.includes('node_modules')); 666 | }); 667 | 668 | test('respects gitignore option false', async t => { 669 | const actual = await runGlobby(t, '*', {gitignore: false, onlyFiles: false}); 670 | t.true(actual.includes('node_modules')); 671 | }); 672 | 673 | test('gitignore option with stats option', async t => { 674 | const result = await runGlobby(t, '*', {gitignore: true, stats: true}); 675 | const actual = result.map(x => x.path); 676 | t.false(actual.includes('node_modules')); 677 | }); 678 | 679 | test('gitignore option with absolute option', async t => { 680 | const result = await runGlobby(t, '*', {gitignore: true, absolute: true}); 681 | t.false(result.includes('node_modules')); 682 | }); 683 | 684 | test('gitignore option and objectMode option', async t => { 685 | const result = await runGlobby(t, 'fixtures/gitignore/*', {gitignore: true, objectMode: true}); 686 | t.is(result.length, 1); 687 | t.truthy(result[0].path); 688 | }); 689 | 690 | test('gitignore option and suppressErrors option', async t => { 691 | const temporary = temporaryDirectory(); 692 | fs.mkdirSync(path.join(temporary, 'foo')); 693 | fs.writeFileSync(path.join(temporary, '.gitignore'), 'baz', 'utf8'); 694 | fs.writeFileSync(path.join(temporary, 'bar'), '', 'utf8'); 695 | fs.writeFileSync(path.join(temporary, 'baz'), '', 'utf8'); 696 | // Block access to "foo", which should be silently ignored. 697 | fs.chmodSync(path.join(temporary, 'foo'), 0o000); 698 | const result = await runGlobby(t, '**/*', {cwd: temporary, gitignore: true, suppressErrors: true}); 699 | t.is(result.length, 1); 700 | t.truthy(result.includes('bar')); 701 | }); 702 | 703 | test.serial('gitignore option loads parent gitignore files from git root', async t => { 704 | const repository = createTemporaryGitRepository(); 705 | const childDirectory = path.join(repository, 'packages/app'); 706 | 707 | fs.mkdirSync(childDirectory, {recursive: true}); 708 | 709 | fs.writeFileSync(path.join(repository, '.gitignore'), 'root-ignored.js\n', 'utf8'); 710 | fs.writeFileSync(path.join(childDirectory, '.gitignore'), 'child-ignored.js\n', 'utf8'); 711 | fs.writeFileSync(path.join(childDirectory, 'root-ignored.js'), '', 'utf8'); 712 | fs.writeFileSync(path.join(childDirectory, 'child-ignored.js'), '', 'utf8'); 713 | fs.writeFileSync(path.join(childDirectory, 'kept.js'), '', 'utf8'); 714 | 715 | const result = await runGlobby(t, '*.js', {cwd: childDirectory, gitignore: true}); 716 | t.deepEqual(result.sort(), ['kept.js']); 717 | }); 718 | 719 | test('gitignore option works with promises-only fs when finding parent gitignores', async t => { 720 | const repository = createTemporaryGitRepository(); 721 | const childDirectory = path.join(repository, 'packages/app'); 722 | 723 | fs.mkdirSync(childDirectory, {recursive: true}); 724 | 725 | fs.writeFileSync(path.join(repository, '.gitignore'), 'root-ignored.js\n', 'utf8'); 726 | fs.writeFileSync(path.join(childDirectory, 'root-ignored.js'), '', 'utf8'); 727 | fs.writeFileSync(path.join(childDirectory, 'kept.js'), '', 'utf8'); 728 | 729 | const asyncOnlyFs = { 730 | promises: { 731 | readFile: fs.promises.readFile.bind(fs.promises), 732 | stat: fs.promises.stat.bind(fs.promises), 733 | }, 734 | }; 735 | 736 | const result = await globby('*.js', { 737 | cwd: childDirectory, 738 | gitignore: true, 739 | fs: asyncOnlyFs, 740 | }); 741 | 742 | t.deepEqual(result, ['kept.js']); 743 | }); 744 | 745 | test('gitignore option only loads parent gitignore files when inside a git repository', async t => { 746 | const repository = temporaryDirectory(); 747 | const childDirectory1 = path.join(repository, 'child1'); 748 | const childDirectory2 = path.join(repository, 'child2'); 749 | 750 | fs.mkdirSync(childDirectory1, {recursive: true}); 751 | fs.mkdirSync(childDirectory2, {recursive: true}); 752 | 753 | fs.writeFileSync(path.join(repository, '.gitignore'), 'ignored.js\n', 'utf8'); 754 | fs.writeFileSync(path.join(childDirectory1, 'ignored.js'), '', 'utf8'); 755 | 756 | // Test without git repository - parent gitignore should NOT be loaded 757 | const withoutGitRepository = await runGlobby(t, '*.js', {cwd: childDirectory1, gitignore: true}); 758 | t.true(withoutGitRepository.includes('ignored.js')); 759 | 760 | // Add .git directory to make it a git repository 761 | fs.mkdirSync(path.join(repository, '.git')); 762 | fs.writeFileSync(path.join(childDirectory2, 'ignored.js'), '', 'utf8'); 763 | 764 | // Test with git repository - parent gitignore SHOULD be loaded 765 | // Use a different child directory to avoid cache issues 766 | const withGitRepository = await runGlobby(t, '*.js', {cwd: childDirectory2, gitignore: true}); 767 | t.false(withGitRepository.includes('ignored.js')); 768 | }); 769 | 770 | test('gitignore option allows child gitignore files to override parent patterns', async t => { 771 | const repository = createTemporaryGitRepository(); 772 | const childDirectory = path.join(repository, 'packages/app'); 773 | 774 | fs.mkdirSync(childDirectory, {recursive: true}); 775 | 776 | fs.writeFileSync(path.join(repository, '.gitignore'), '*.js\n', 'utf8'); 777 | fs.writeFileSync(path.join(childDirectory, '.gitignore'), '!keep.js\n', 'utf8'); 778 | fs.writeFileSync(path.join(childDirectory, 'keep.js'), '', 'utf8'); 779 | fs.writeFileSync(path.join(childDirectory, 'drop.js'), '', 'utf8'); 780 | 781 | const result = await runGlobby(t, '*.js', {cwd: childDirectory, gitignore: true}); 782 | t.deepEqual(result, ['keep.js']); 783 | }); 784 | 785 | test('suppressErrors option with file patterns (issue #166)', async t => { 786 | const temporary = temporaryDirectory(); 787 | fs.writeFileSync(path.join(temporary, 'validFile.txt'), 'test content', 'utf8'); 788 | 789 | // Without suppressErrors, should throw when trying to treat file as directory 790 | await t.throwsAsync( 791 | globby(['validFile.txt', 'validFile.txt/**/*.txt'], {cwd: temporary}), 792 | {code: 'ENOTDIR'}, 793 | ); 794 | t.throws( 795 | () => globbySync(['validFile.txt', 'validFile.txt/**/*.txt'], {cwd: temporary}), 796 | {code: 'ENOTDIR'}, 797 | ); 798 | 799 | // With suppressErrors, should return the valid file and suppress the error 800 | const asyncResult = await runGlobby(t, ['validFile.txt', 'validFile.txt/**/*.txt'], { 801 | cwd: temporary, 802 | suppressErrors: true, 803 | }); 804 | t.deepEqual(asyncResult, ['validFile.txt']); 805 | }); 806 | 807 | test('nested gitignore with negation applies recursively to globby results (issue #255)', async t => { 808 | const cwd = path.join(PROJECT_ROOT, 'fixtures', 'gitignore-negation-nested'); 809 | const result = await runGlobby(t, '**/*.txt', {cwd, gitignore: true}); 810 | 811 | // Both y/a2.txt and y/z/a2.txt should be included despite root .gitignore having 'a*' 812 | // because y/.gitignore has '!a2.txt' which applies recursively 813 | t.true(result.includes('y/a2.txt')); 814 | t.true(result.includes('y/z/a2.txt')); 815 | 816 | // These should be excluded by 'a*' pattern 817 | t.false(result.includes('a1.txt')); 818 | t.false(result.includes('a2.txt')); 819 | t.false(result.includes('y/a1.txt')); 820 | t.false(result.includes('y/z/a1.txt')); 821 | }); 822 | 823 | test.serial('parent directory patterns work with gitignore option (issue #133)', async t => { 824 | const temporaryParent = temporaryDirectory(); 825 | const temporarySrc = path.join(temporaryParent, 'src'); 826 | const temporaryChild = path.join(temporaryParent, 'child'); 827 | 828 | fs.mkdirSync(temporarySrc, {recursive: true}); 829 | fs.mkdirSync(temporaryChild, {recursive: true}); 830 | 831 | const srcFile1 = path.join(temporarySrc, 'test1.ts'); 832 | const srcFile2 = path.join(temporarySrc, 'test2.ts'); 833 | 834 | fs.writeFileSync(srcFile1, 'content1', 'utf8'); 835 | fs.writeFileSync(srcFile2, 'content2', 'utf8'); 836 | 837 | // Add a .gitignore to ensure gitignore processing is active 838 | fs.writeFileSync(path.join(temporaryParent, '.gitignore'), 'node_modules\n', 'utf8'); 839 | 840 | try { 841 | // Test relative parent directory pattern with gitignore:true 842 | const relativeResult = await runGlobby(t, '../src/*.ts', { 843 | cwd: temporaryChild, 844 | gitignore: true, 845 | absolute: false, 846 | }); 847 | 848 | t.deepEqual(relativeResult.sort(), ['../src/test1.ts', '../src/test2.ts']); 849 | 850 | // Test absolute paths with gitignore:true 851 | const absoluteResult = await runGlobby(t, '../src/*.ts', { 852 | cwd: temporaryChild, 853 | gitignore: true, 854 | absolute: true, 855 | }); 856 | 857 | t.is(absoluteResult.length, 2); 858 | t.true(absoluteResult.every(p => path.isAbsolute(p))); 859 | t.true(absoluteResult.some(p => p.endsWith('test1.ts'))); 860 | t.true(absoluteResult.some(p => p.endsWith('test2.ts'))); 861 | 862 | // Verify it still works with gitignore:false for consistency 863 | const withoutGitignoreResult = await runGlobby(t, '../src/*.ts', { 864 | cwd: temporaryChild, 865 | gitignore: false, 866 | absolute: false, 867 | }); 868 | 869 | t.deepEqual(withoutGitignoreResult.sort(), ['../src/test1.ts', '../src/test2.ts']); 870 | } finally { 871 | fs.rmSync(temporaryParent, {recursive: true, force: true}); 872 | } 873 | }); 874 | 875 | test.serial('gitignore directory patterns stop fast-glob traversal', async t => { 876 | const temporaryCwd = temporaryDirectory(); 877 | const gitignorePath = path.join(temporaryCwd, '.gitignore'); 878 | const keepFile = path.join(temporaryCwd, 'keep.js'); 879 | const nodeModulesFile = path.join(temporaryCwd, 'node_modules/foo/index.js'); 880 | const nestedNodeModulesFile = path.join(temporaryCwd, 'packages/foo/node_modules/bar/index.js'); 881 | fs.writeFileSync(gitignorePath, 'node_modules/\n', 'utf8'); 882 | fs.mkdirSync(path.dirname(nodeModulesFile), {recursive: true}); 883 | fs.mkdirSync(path.dirname(nestedNodeModulesFile), {recursive: true}); 884 | fs.writeFileSync(nodeModulesFile, '', 'utf8'); 885 | fs.writeFileSync(nestedNodeModulesFile, '', 'utf8'); 886 | fs.writeFileSync(keepFile, '', 'utf8'); 887 | 888 | const restoreFs = blockNodeModulesTraversal(temporaryCwd); 889 | 890 | try { 891 | const result = await runGlobby(t, '**/*.js', { 892 | cwd: temporaryCwd, 893 | gitignore: true, 894 | }); 895 | t.deepEqual(result, ['keep.js']); 896 | } finally { 897 | restoreFs(); 898 | fs.rmSync(temporaryCwd, {recursive: true, force: true}); 899 | } 900 | }); 901 | 902 | test('respects ignoreFiles string option', async t => { 903 | const actual = await runGlobby(t, '*', {gitignore: false, ignoreFiles: '.gitignore', onlyFiles: false}); 904 | t.false(actual.includes('node_modules')); 905 | }); 906 | 907 | test('respects ignoreFiles array option', async t => { 908 | const actual = await runGlobby(t, '*', {gitignore: false, ignoreFiles: ['.gitignore'], onlyFiles: false}); 909 | t.false(actual.includes('node_modules')); 910 | }); 911 | 912 | test('glob dot files', async t => { 913 | const actual = await runGlobby(t, '*', {gitignore: false, ignoreFiles: '*gitignore', onlyFiles: false}); 914 | t.false(actual.includes('node_modules')); 915 | }); 916 | 917 | test('`{extension: false}` and `expandDirectories.extensions` option', async t => { 918 | for (const temporaryDirectory of getPathValues(temporary)) { 919 | t.deepEqual( 920 | // eslint-disable-next-line no-await-in-loop 921 | await runGlobby(t, '*', { 922 | cwd: temporaryDirectory, 923 | extension: false, 924 | expandDirectories: { 925 | extensions: [ 926 | 'md', 927 | 'tmp', 928 | ], 929 | }, 930 | }), 931 | [ 932 | 'a.tmp', 933 | 'b.tmp', 934 | 'c.tmp', 935 | 'd.tmp', 936 | 'e.tmp', 937 | ], 938 | ); 939 | } 940 | }); 941 | 942 | test('throws when specifying a file as cwd', async t => { 943 | for (const file of getPathValues(path.resolve('fixtures/gitignore/bar.js'))) { 944 | // eslint-disable-next-line no-await-in-loop 945 | await t.throwsAsync(globby('.', {cwd: file}), cwdDirectoryError); 946 | // eslint-disable-next-line no-await-in-loop 947 | await t.throwsAsync(globby('*', {cwd: file}), cwdDirectoryError); 948 | t.throws(() => globbySync('.', {cwd: file}), cwdDirectoryError); 949 | t.throws(() => globbySync('*', {cwd: file}), cwdDirectoryError); 950 | t.throws(() => globbyStream('.', {cwd: file}), cwdDirectoryError); 951 | t.throws(() => globbyStream('*', {cwd: file}), cwdDirectoryError); 952 | } 953 | }); 954 | 955 | test('throws when specifying a file as cwd - isDynamicPattern', t => { 956 | for (const file of getPathValues(path.resolve('fixtures/gitignore/bar.js'))) { 957 | t.throws(() => { 958 | isDynamicPattern('.', {cwd: file}); 959 | }, cwdDirectoryError); 960 | 961 | t.throws(() => { 962 | isDynamicPattern('*', {cwd: file}); 963 | }, cwdDirectoryError); 964 | } 965 | }); 966 | 967 | test('don\'t throw when specifying a non-existing cwd directory', async t => { 968 | for (const cwd of getPathValues('/unknown')) { 969 | // eslint-disable-next-line no-await-in-loop 970 | const actual = await runGlobby(t, '.', {cwd}); 971 | t.is(actual.length, 0); 972 | } 973 | }); 974 | 975 | test('unique when using objectMode option', async t => { 976 | const result = await runGlobby(t, ['a.tmp', '*.tmp'], {cwd, objectMode: true}); 977 | t.true(isUnique(result.map(({path}) => path))); 978 | }); 979 | 980 | test('stats option returns Entry objects with stats', async t => { 981 | const result = await runGlobby(t, '*.tmp', {cwd, stats: true}); 982 | t.true(result.length > 0); 983 | for (const entry of result) { 984 | t.truthy(entry.path); 985 | t.truthy(entry.name); 986 | // Note: stats property exists but is filtered out in stabilizeResult for testing 987 | } 988 | }); 989 | 990 | test('gitignore option and stats option', async t => { 991 | const result = await runGlobby(t, 'fixtures/gitignore/*', {gitignore: true, stats: true}); 992 | t.is(result.length, 1); 993 | t.truthy(result[0].path); 994 | }); 995 | 996 | test('unique when using stats option', async t => { 997 | const result = await runGlobby(t, ['a.tmp', '*.tmp'], {cwd, stats: true}); 998 | t.true(isUnique(result.map(({path}) => path))); 999 | }); 1000 | 1001 | // Known limitation: ** in parentheses doesn't work (fast-glob #484) 1002 | test.failing('** inside parentheses', async t => { 1003 | const testDir = temporaryDirectory(); 1004 | fs.mkdirSync(path.join(testDir, 'test/utils'), {recursive: true}); 1005 | fs.writeFileSync(path.join(testDir, 'test/utils/file.js'), ''); 1006 | 1007 | const result1 = await runGlobby(t, 'test(/utils/**)', {cwd: testDir}); 1008 | const result2 = await runGlobby(t, 'test/utils/**', {cwd: testDir}); 1009 | 1010 | // This fails because ** in parentheses returns empty array 1011 | t.deepEqual(result1, result2); 1012 | }); 1013 | 1014 | // Known limitation: patterns with quotes may not work (fast-glob #494) 1015 | test.failing('patterns with quotes in path segments', async t => { 1016 | const testDir = temporaryDirectory(); 1017 | const quotedDir = path.join(testDir, '"quoted"'); 1018 | fs.mkdirSync(quotedDir, {recursive: true}); 1019 | fs.writeFileSync(path.join(quotedDir, 'file.js'), ''); 1020 | 1021 | const result = await runGlobby(t, '"quoted"/**', {cwd: testDir}); 1022 | 1023 | // This fails because quoted paths don't match correctly 1024 | t.deepEqual(result, ['"quoted"/file.js']); 1025 | }); 1026 | 1027 | test('filter function manages path cache efficiently', async t => { 1028 | // This test verifies that the path cache is managed properly 1029 | // The seen Set should NOT be cleared as it's needed for deduplication 1030 | const temporary = temporaryDirectory(); 1031 | 1032 | // Create test files - some that will be ignored and some that won't 1033 | for (let i = 0; i < 50; i++) { 1034 | fs.writeFileSync(path.join(temporary, `file${i}.txt`), 'content'); 1035 | fs.writeFileSync(path.join(temporary, `ignored${i}.txt`), 'content'); 1036 | } 1037 | 1038 | // Create a gitignore to trigger path resolution 1039 | fs.writeFileSync(path.join(temporary, '.gitignore'), 'ignored*.txt\n'); 1040 | 1041 | // This should work correctly with path cache management 1042 | const result = await runGlobby(t, '*.txt', {cwd: temporary, gitignore: true}); 1043 | 1044 | // Should have files but not the ignored ones 1045 | const ignoredFiles = result.filter(f => f.startsWith('ignored')); 1046 | t.is(ignoredFiles.length, 0, 'No ignored files should be returned'); 1047 | t.is(result.length, 50, 'Should have exactly 50 non-ignored files'); 1048 | t.true(result.every(f => f.startsWith('file')), 'All results should be file*.txt'); 1049 | }); 1050 | 1051 | test('parent gitignore files are found and cached correctly', async t => { 1052 | const repository = createTemporaryGitRepository(); 1053 | const child1 = path.join(repository, 'packages/app1'); 1054 | const child2 = path.join(repository, 'packages/app2'); 1055 | 1056 | fs.mkdirSync(child1, {recursive: true}); 1057 | fs.mkdirSync(child2, {recursive: true}); 1058 | 1059 | // Create gitignore files 1060 | fs.writeFileSync(path.join(repository, '.gitignore'), 'root-ignored.js\n'); 1061 | fs.writeFileSync(path.join(child1, '.gitignore'), 'child1-ignored.js\n'); 1062 | fs.writeFileSync(path.join(child2, '.gitignore'), 'child2-ignored.js\n'); 1063 | 1064 | // Test files 1065 | fs.writeFileSync(path.join(child1, 'root-ignored.js'), ''); 1066 | fs.writeFileSync(path.join(child1, 'child1-ignored.js'), ''); 1067 | fs.writeFileSync(path.join(child1, 'kept.js'), ''); 1068 | 1069 | const result = await runGlobby(t, '*.js', {cwd: child1, gitignore: true}); 1070 | t.deepEqual(result.sort(), ['kept.js']); 1071 | 1072 | // Second call should use cache 1073 | const result2 = await runGlobby(t, '*.js', {cwd: child1, gitignore: true}); 1074 | t.deepEqual(result2.sort(), ['kept.js']); 1075 | }); 1076 | 1077 | test('filter function caches resolved paths for performance', async t => { 1078 | const temporary = temporaryDirectory(); 1079 | 1080 | // Create test files 1081 | for (let i = 0; i < 100; i++) { 1082 | fs.writeFileSync(path.join(temporary, `file${i}.txt`), 'content'); 1083 | } 1084 | 1085 | // Create gitignore 1086 | fs.writeFileSync(path.join(temporary, '.gitignore'), 'file5*.txt\n'); 1087 | 1088 | // This should use path caching for repeated path resolution 1089 | const result = await runGlobby(t, '*.txt', {cwd: temporary, gitignore: true}); 1090 | 1091 | // Verify files starting with file5 are ignored 1092 | const hasFile5x = result.some(file => file.startsWith('file5')); 1093 | t.false(hasFile5x); 1094 | 1095 | // Other files should be present 1096 | t.true(result.includes('file1.txt')); 1097 | t.true(result.includes('file60.txt')); 1098 | }); 1099 | 1100 | test('handles string ignore option - async', async t => { 1101 | const temporary = temporaryDirectory(); 1102 | // Create test files 1103 | for (const element of fixture) { 1104 | fs.writeFileSync(path.join(temporary, element), ''); 1105 | } 1106 | 1107 | const result = await runGlobby(t, '*.tmp', { 1108 | ignore: 'a.tmp', 1109 | cwd: temporary, 1110 | }); 1111 | 1112 | t.false(result.includes('a.tmp'), 'should exclude ignored file'); 1113 | t.true(result.includes('b.tmp'), 'should include non-ignored file'); 1114 | }); 1115 | 1116 | test('handles string ignore option - sync', t => { 1117 | const temporary = temporaryDirectory(); 1118 | // Create test files 1119 | for (const element of fixture) { 1120 | fs.writeFileSync(path.join(temporary, element), ''); 1121 | } 1122 | 1123 | const result = globbySync('*.tmp', { 1124 | ignore: 'a.tmp', 1125 | cwd: temporary, 1126 | }); 1127 | 1128 | t.false(result.includes('a.tmp'), 'should exclude ignored file'); 1129 | t.true(result.includes('b.tmp'), 'should include non-ignored file'); 1130 | }); 1131 | 1132 | test('handles string ignore with negative patterns', async t => { 1133 | const temporary = temporaryDirectory(); 1134 | // Create test files 1135 | for (const element of fixture) { 1136 | fs.writeFileSync(path.join(temporary, element), ''); 1137 | } 1138 | 1139 | const result = await runGlobby(t, ['*.tmp', '!b.tmp'], { 1140 | ignore: 'c.tmp', 1141 | cwd: temporary, 1142 | }); 1143 | 1144 | t.false(result.includes('b.tmp'), 'negative pattern should exclude file'); 1145 | t.false(result.includes('c.tmp'), 'ignore option should exclude file'); 1146 | t.true(result.includes('a.tmp'), 'should include non-ignored file'); 1147 | }); 1148 | 1149 | test('handles string ignore with directory expansion', async t => { 1150 | const temporary = temporaryDirectory(); 1151 | fs.mkdirSync(path.join(temporary, 'subdir'), {recursive: true}); 1152 | fs.writeFileSync(path.join(temporary, 'subdir', 'file.txt'), 'content'); 1153 | 1154 | const result = await runGlobby(t, 'subdir', { 1155 | ignore: '.git', 1156 | expandDirectories: true, 1157 | cwd: temporary, 1158 | }); 1159 | 1160 | t.true(Array.isArray(result), 'should return an array'); 1161 | t.true(result.some(file => file.includes('file.txt')), 'should find file in directory'); 1162 | }); 1163 | 1164 | test('handles custom fs with callback-style stat', async t => { 1165 | const temporary = temporaryDirectory(); 1166 | // Create test files 1167 | for (const element of fixture) { 1168 | fs.writeFileSync(path.join(temporary, element), ''); 1169 | } 1170 | 1171 | const customFs = { 1172 | stat(filePath, callback) { 1173 | // Simulate callback-style stat 1174 | process.nextTick(() => { 1175 | callback(null, { 1176 | isDirectory() { 1177 | return false; 1178 | }, 1179 | }); 1180 | }); 1181 | }, 1182 | }; 1183 | 1184 | // This used to fail with "callback must be a function" 1185 | const result = await runGlobby(t, '*.tmp', { 1186 | fs: customFs, 1187 | expandDirectories: false, 1188 | cwd: temporary, 1189 | }); 1190 | 1191 | t.true(Array.isArray(result), 'should handle callback-style fs.stat'); 1192 | }); 1193 | 1194 | test('handles custom fs with callback-style readFile', async t => { 1195 | const temporary = temporaryDirectory(); 1196 | // Create test files 1197 | for (const element of fixture) { 1198 | fs.writeFileSync(path.join(temporary, element), ''); 1199 | } 1200 | 1201 | const customFs = { 1202 | readFile(filePath, encoding, callback) { 1203 | // Handle both (path, callback) and (path, encoding, callback) 1204 | if (typeof encoding === 'function') { 1205 | callback = encoding; 1206 | encoding = undefined; 1207 | } 1208 | 1209 | // Simulate callback-style readFile 1210 | process.nextTick(() => { 1211 | if (filePath.endsWith('.gitignore')) { 1212 | // Return string when encoding is specified 1213 | const content = encoding === 'utf8' ? 'a.tmp' : Buffer.from('a.tmp'); 1214 | callback(null, content); 1215 | } else { 1216 | callback(new Error('File not found')); 1217 | } 1218 | }); 1219 | }, 1220 | }; 1221 | 1222 | // Create a .gitignore file 1223 | fs.writeFileSync(path.join(temporary, '.gitignore'), 'dummy'); 1224 | 1225 | // This used to fail with "callback must be a function" 1226 | await t.notThrowsAsync( 1227 | runGlobby(t, '*.tmp', { 1228 | gitignore: true, 1229 | fs: customFs, 1230 | expandDirectories: false, 1231 | cwd: temporary, 1232 | }), 1233 | 'should handle callback-style fs.readFile', 1234 | ); 1235 | }); 1236 | 1237 | test('integration test with string ignore and custom fs', async t => { 1238 | const temporary = temporaryDirectory(); 1239 | // Create test files 1240 | for (const element of fixture) { 1241 | fs.writeFileSync(path.join(temporary, element), ''); 1242 | } 1243 | 1244 | const customFs = { 1245 | promises: { 1246 | stat: async () => ({ 1247 | isDirectory() { 1248 | return false; 1249 | }, 1250 | }), 1251 | async readFile(path, encoding) { 1252 | // Return string when encoding is specified (as ignore.js expects) 1253 | return encoding === 'utf8' ? '' : Buffer.from(''); 1254 | }, 1255 | }, 1256 | }; 1257 | 1258 | // Combines string ignore (fix #1) with custom fs (fixes #2-3) 1259 | await t.notThrowsAsync( 1260 | runGlobby(t, '*.tmp', { 1261 | ignore: 'a.tmp', 1262 | gitignore: true, 1263 | fs: customFs, 1264 | expandDirectories: false, 1265 | cwd: temporary, 1266 | }), 1267 | 'should handle all fixes together', 1268 | ); 1269 | }); 1270 | --------------------------------------------------------------------------------