Skip to content

Commit 72e9916

Browse files
committed
Fix negation patterns with absolute filesystem paths
Fixes #275
1 parent 70c011b commit 72e9916

File tree

3 files changed

+369
-10
lines changed

3 files changed

+369
-10
lines changed

index.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414
bindFsMethod,
1515
promisifyFsMethod,
1616
isNegativePattern,
17-
normalizeAbsolutePatternToRelative,
17+
getStaticAbsolutePathPrefix,
18+
normalizeNegativePattern,
1819
normalizeDirectoryPatternForFastGlob,
1920
adjustIgnorePatternsForParentDirectories,
2021
convertPatternsForFastGlob,
@@ -301,9 +302,28 @@ const convertNegativePatterns = (patterns, options) => {
301302
patterns = ['**/*', ...patterns];
302303
}
303304

304-
patterns = patterns.map(pattern => isNegativePattern(pattern)
305-
? `!${normalizeAbsolutePatternToRelative(pattern.slice(1))}`
306-
: pattern);
305+
const positiveAbsolutePathPrefixes = [];
306+
let hasRelativePositivePattern = false;
307+
const normalizedPatterns = [];
308+
309+
for (const pattern of patterns) {
310+
if (isNegativePattern(pattern)) {
311+
normalizedPatterns.push(`!${normalizeNegativePattern(pattern.slice(1), positiveAbsolutePathPrefixes, hasRelativePositivePattern)}`);
312+
continue;
313+
}
314+
315+
normalizedPatterns.push(pattern);
316+
317+
const staticAbsolutePathPrefix = getStaticAbsolutePathPrefix(pattern);
318+
if (staticAbsolutePathPrefix === undefined) {
319+
hasRelativePositivePattern = true;
320+
continue;
321+
}
322+
323+
positiveAbsolutePathPrefixes.push(staticAbsolutePathPrefix);
324+
}
325+
326+
patterns = normalizedPatterns;
307327

308328
const tasks = [];
309329

tests/globby.js

Lines changed: 259 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import {
1111
globbyStream,
1212
isDynamicPattern,
1313
} from '../index.js';
14-
import {normalizeDirectoryPatternForFastGlob, normalizeAbsolutePatternToRelative} from '../utilities.js';
14+
import {
15+
normalizeDirectoryPatternForFastGlob,
16+
normalizeAbsolutePatternToRelative,
17+
getStaticAbsolutePathPrefix,
18+
normalizeNegativePattern,
19+
} from '../utilities.js';
1520
import {
1621
PROJECT_ROOT,
1722
createContextAwareFs,
@@ -218,16 +223,63 @@ test('normalizeDirectoryPatternForFastGlob handles recursive directory patterns'
218223
t.is(normalizeDirectoryPatternForFastGlob(''), '', 'empty string should remain empty');
219224
});
220225

221-
test('normalizeAbsolutePatternToRelative strips leading slash', t => {
226+
test('normalizeAbsolutePatternToRelative strips leading slash for anchored globs', t => {
227+
// Single-segment patterns are normalized (root-anchored globs)
222228
t.is(normalizeAbsolutePatternToRelative('/**'), '**');
223229
t.is(normalizeAbsolutePatternToRelative('/foo'), 'foo');
224-
t.is(normalizeAbsolutePatternToRelative('/foo/**'), 'foo/**');
225230
t.is(normalizeAbsolutePatternToRelative('/*.txt'), '*.txt');
231+
232+
// Multi-segment patterns with glob in first segment are normalized
233+
t.is(normalizeAbsolutePatternToRelative('/{src,dist}/**'), '{src,dist}/**');
234+
t.is(normalizeAbsolutePatternToRelative('/@(src|dist)/**'), '@(src|dist)/**');
235+
t.is(normalizeAbsolutePatternToRelative('/*/foo'), '*/foo');
236+
237+
// Multi-segment patterns with non-glob first segment are real absolute paths - preserved
238+
t.is(normalizeAbsolutePatternToRelative('/foo/**'), '/foo/**');
239+
t.is(normalizeAbsolutePatternToRelative('/Users/foo/bar'), '/Users/foo/bar');
240+
t.is(normalizeAbsolutePatternToRelative('/home/user/project/_*'), '/home/user/project/_*');
241+
242+
// Non-absolute patterns are unchanged
226243
t.is(normalizeAbsolutePatternToRelative('foo'), 'foo', 'relative patterns unchanged');
227244
t.is(normalizeAbsolutePatternToRelative('**'), '**', 'globstar unchanged');
228245
t.is(normalizeAbsolutePatternToRelative(''), '', 'empty string unchanged');
229246
});
230247

248+
test('getStaticAbsolutePathPrefix returns leading static absolute segments', t => {
249+
t.is(getStaticAbsolutePathPrefix('/tmp/project/**/*.js'), '/tmp/project');
250+
t.is(getStaticAbsolutePathPrefix('/tmp*/project/**/*.js'), undefined, 'glob in first segment');
251+
t.is(getStaticAbsolutePathPrefix('relative/**/*.js'), undefined, 'relative pattern');
252+
t.is(getStaticAbsolutePathPrefix('/tmp'), '/tmp', 'single static segment');
253+
t.is(getStaticAbsolutePathPrefix('/'), undefined, 'root only');
254+
t.is(getStaticAbsolutePathPrefix('/tmp/project'), '/tmp/project', 'fully static path');
255+
});
256+
257+
test('normalizeNegativePattern handles root-anchored and absolute filesystem patterns', t => {
258+
// Dynamic root-anchored: normalized to relative
259+
t.is(normalizeNegativePattern('/**'), '**');
260+
t.is(normalizeNegativePattern('/{src,dist}/**'), '{src,dist}/**');
261+
262+
// Single-segment literal: treated as root-anchored (no real filesystem path has just one segment)
263+
t.is(normalizeNegativePattern('/src'), 'src');
264+
265+
// Multi-segment literal without matching positive prefix: strip to cwd-relative
266+
t.is(normalizeNegativePattern('/src/**'), 'src/**');
267+
t.is(normalizeNegativePattern('/tmp/project/_*', ['/Users/someone']), 'tmp/project/_*');
268+
269+
// Multi-segment literal with matching positive prefix: preserve as absolute
270+
t.is(normalizeNegativePattern('/tmp/project/_*', ['/tmp/project']), '/tmp/project/_*');
271+
272+
// Mixed positive pattern styles should keep root-anchored literals cwd-relative.
273+
t.is(normalizeNegativePattern('/tmp/project/_*', ['/tmp/project'], true), 'tmp/project/_*');
274+
275+
// Ancestor positive prefixes should not force absolute behavior.
276+
t.is(normalizeNegativePattern('/tmp/project/src/**', ['/tmp/project']), 'tmp/project/src/**', 'ancestor positive prefixes should not force absolute negations');
277+
278+
// Non-absolute: pass through unchanged
279+
t.is(normalizeNegativePattern('foo/bar'), 'foo/bar');
280+
t.is(normalizeNegativePattern('**'), '**');
281+
});
282+
231283
test('glob', async t => {
232284
const result = await runGlobby(t, '*.tmp');
233285
t.deepEqual(result.sort(), ['a.tmp', 'b.tmp', 'c.tmp', 'd.tmp', 'e.tmp']);
@@ -272,6 +324,210 @@ test('negation pattern with absolute path is normalized to relative', async t =>
272324
t.deepEqual(result, []);
273325
});
274326

327+
test('negation with absolute filesystem paths (issue #275)', async t => {
328+
const temporaryCwd = temporaryDirectory();
329+
330+
fs.writeFileSync(path.join(temporaryCwd, 'app.scss'), '', 'utf8');
331+
fs.writeFileSync(path.join(temporaryCwd, '_partial.scss'), '', 'utf8');
332+
fs.writeFileSync(path.join(temporaryCwd, 'b.scss'), '', 'utf8');
333+
334+
try {
335+
// Single absolute negation
336+
const result = await runGlobby(t, [
337+
`${temporaryCwd}/*.scss`,
338+
`!${temporaryCwd}/_*`,
339+
]);
340+
341+
t.deepEqual(result.map(filePath => path.basename(filePath)).sort(), ['app.scss', 'b.scss']);
342+
343+
// Multiple absolute negations
344+
const result2 = await runGlobby(t, [
345+
`${temporaryCwd}/*.scss`,
346+
`!${temporaryCwd}/_*`,
347+
`!${temporaryCwd}/b*`,
348+
]);
349+
350+
t.deepEqual(result2.map(filePath => path.basename(filePath)), ['app.scss']);
351+
} finally {
352+
fs.rmSync(temporaryCwd, {recursive: true, force: true});
353+
}
354+
});
355+
356+
test('negation-only root-anchored extglob excludes directories from cwd root', async t => {
357+
const temporaryCwd = temporaryDirectory();
358+
359+
fs.mkdirSync(path.join(temporaryCwd, 'src'), {recursive: true});
360+
fs.mkdirSync(path.join(temporaryCwd, 'dist'), {recursive: true});
361+
fs.mkdirSync(path.join(temporaryCwd, 'other'), {recursive: true});
362+
363+
fs.writeFileSync(path.join(temporaryCwd, 'src', 'a.js'), '', 'utf8');
364+
fs.writeFileSync(path.join(temporaryCwd, 'dist', 'b.js'), '', 'utf8');
365+
fs.writeFileSync(path.join(temporaryCwd, 'other', 'c.js'), '', 'utf8');
366+
367+
try {
368+
const result = await runGlobby(t, ['!/@(src|dist)/**'], {cwd: temporaryCwd});
369+
t.deepEqual(result, ['other/c.js']);
370+
} finally {
371+
fs.rmSync(temporaryCwd, {recursive: true, force: true});
372+
}
373+
});
374+
375+
test('negation-only root-anchored literal excludes directories from cwd root', async t => {
376+
const temporaryCwd = temporaryDirectory();
377+
378+
fs.mkdirSync(path.join(temporaryCwd, 'src'), {recursive: true});
379+
fs.mkdirSync(path.join(temporaryCwd, 'other'), {recursive: true});
380+
381+
fs.writeFileSync(path.join(temporaryCwd, 'src', 'a.js'), '', 'utf8');
382+
fs.writeFileSync(path.join(temporaryCwd, 'other', 'c.js'), '', 'utf8');
383+
384+
try {
385+
const result = await runGlobby(t, ['!/src/**'], {cwd: temporaryCwd});
386+
t.deepEqual(result, ['other/c.js']);
387+
} finally {
388+
fs.rmSync(temporaryCwd, {recursive: true, force: true});
389+
}
390+
});
391+
392+
test('root-anchored literal negation works with mixed relative and absolute positives', async t => {
393+
const temporaryCwd = temporaryDirectory();
394+
395+
fs.mkdirSync(path.join(temporaryCwd, 'src'), {recursive: true});
396+
fs.mkdirSync(path.join(temporaryCwd, 'other'), {recursive: true});
397+
398+
fs.writeFileSync(path.join(temporaryCwd, 'src', 'a.js'), '', 'utf8');
399+
fs.writeFileSync(path.join(temporaryCwd, 'other', 'c.js'), '', 'utf8');
400+
401+
try {
402+
const result = await runGlobby(t, [
403+
'src/**/*.js',
404+
`${temporaryCwd}/other/**/*.js`,
405+
'!/src/**',
406+
], {cwd: temporaryCwd});
407+
408+
t.deepEqual(result.map(filePath => path.basename(filePath)).sort(), ['c.js']);
409+
} finally {
410+
fs.rmSync(temporaryCwd, {recursive: true, force: true});
411+
}
412+
});
413+
414+
test('root-anchored literal negation stays cwd-relative when absolute positive has unknown prefix', async t => {
415+
const temporaryCwd = temporaryDirectory();
416+
417+
fs.mkdirSync(path.join(temporaryCwd, 'src'), {recursive: true});
418+
fs.writeFileSync(path.join(temporaryCwd, 'src', 'a.js'), '', 'utf8');
419+
420+
try {
421+
const result = await runGlobby(t, [
422+
'src/**/*.js',
423+
'/tmp*/nomatch/**/*.js',
424+
'!/src/**',
425+
], {cwd: temporaryCwd});
426+
427+
t.deepEqual(result, []);
428+
} finally {
429+
fs.rmSync(temporaryCwd, {recursive: true, force: true});
430+
}
431+
});
432+
433+
test('root-anchored literal negation does not depend on later absolute positive patterns', async t => {
434+
const temporaryCwd = temporaryDirectory();
435+
const rootSegment = temporaryCwd.split('/').find(Boolean);
436+
const rootAnchoredPattern = `!/${rootSegment}/**`;
437+
438+
fs.mkdirSync(path.join(temporaryCwd, 'other'), {recursive: true});
439+
fs.writeFileSync(path.join(temporaryCwd, 'z.js'), '', 'utf8');
440+
fs.writeFileSync(path.join(temporaryCwd, 'other', 'c.js'), '', 'utf8');
441+
442+
try {
443+
const result = await runGlobby(t, [
444+
'**/*.js',
445+
rootAnchoredPattern,
446+
`${temporaryCwd}/other/**/*.js`,
447+
], {cwd: temporaryCwd});
448+
449+
t.true(result.map(filePath => path.basename(filePath)).includes('z.js'));
450+
} finally {
451+
fs.rmSync(temporaryCwd, {recursive: true, force: true});
452+
}
453+
});
454+
455+
test('root-anchored literal negation stays cwd-relative with ancestor absolute positive', async t => {
456+
const temporaryCwd = temporaryDirectory();
457+
if (path.sep === '\\') {
458+
t.pass();
459+
return;
460+
}
461+
462+
const rootSegment = temporaryCwd.split('/').find(Boolean);
463+
const rootAnchoredPattern = `!/${rootSegment}/**`;
464+
465+
fs.mkdirSync(path.join(temporaryCwd, 'src'), {recursive: true});
466+
fs.mkdirSync(path.join(temporaryCwd, 'other'), {recursive: true});
467+
fs.writeFileSync(path.join(temporaryCwd, 'src', 'a.js'), '', 'utf8');
468+
fs.writeFileSync(path.join(temporaryCwd, 'other', 'c.js'), '', 'utf8');
469+
470+
try {
471+
const result = await runGlobby(t, [
472+
'src/**/*.js',
473+
`${temporaryCwd}/other/**/*.js`,
474+
rootAnchoredPattern,
475+
], {cwd: temporaryCwd});
476+
477+
t.deepEqual(result.map(filePath => path.basename(filePath)).sort(), ['a.js', 'c.js']);
478+
} finally {
479+
fs.rmSync(temporaryCwd, {recursive: true, force: true});
480+
}
481+
});
482+
483+
test('root-anchored literal negation stays cwd-relative with mixed absolute and relative positives', async t => {
484+
const temporaryCwd = temporaryDirectory();
485+
if (path.sep === '\\') {
486+
t.pass();
487+
return;
488+
}
489+
490+
fs.mkdirSync(path.join(temporaryCwd, 'tmp', 'project'), {recursive: true});
491+
fs.writeFileSync(path.join(temporaryCwd, 'tmp', 'project', '_partial.js'), '', 'utf8');
492+
fs.writeFileSync(path.join(temporaryCwd, 'tmp', 'project', 'app.js'), '', 'utf8');
493+
494+
try {
495+
const result = await runGlobby(t, [
496+
'tmp/project/**/*.js',
497+
'/tmp/**/*.nomatch',
498+
'!/tmp/project/_*',
499+
], {cwd: temporaryCwd});
500+
501+
t.deepEqual(result, ['tmp/project/app.js']);
502+
} finally {
503+
fs.rmSync(temporaryCwd, {recursive: true, force: true});
504+
}
505+
});
506+
507+
test('root-anchored literal negation stays cwd-relative when absolute positive shares exact prefix', async t => {
508+
const temporaryCwd = temporaryDirectory();
509+
if (path.sep === '\\') {
510+
t.pass();
511+
return;
512+
}
513+
514+
fs.mkdirSync(path.join(temporaryCwd, 'tmp', 'project'), {recursive: true});
515+
fs.writeFileSync(path.join(temporaryCwd, 'tmp', 'project', '_partial.js'), '', 'utf8');
516+
fs.writeFileSync(path.join(temporaryCwd, 'tmp', 'project', 'app.js'), '', 'utf8');
517+
518+
try {
519+
const result = await runGlobby(t, [
520+
'tmp/project/**/*.js',
521+
'/tmp/project/**/*.nomatch',
522+
'!/tmp/project/_*',
523+
], {cwd: temporaryCwd});
524+
525+
t.deepEqual(result, ['tmp/project/app.js']);
526+
} finally {
527+
fs.rmSync(temporaryCwd, {recursive: true, force: true});
528+
}
529+
});
530+
275531
test('expandNegationOnlyPatterns: false returns empty array for negation-only patterns', async t => {
276532
const result = await runGlobby(t, ['!a.tmp', '!b.tmp'], {cwd: temporary, expandNegationOnlyPatterns: false});
277533
t.deepEqual(result, []);

0 commit comments

Comments
 (0)