Skip to content

Commit 65f302c

Browse files
authored
fix(@angular/build): avoid expanding directory patterns in asset globs
Previously, when an asset entry specified a directory pattern in `glob` (such as `glob: 'sub/'` or `glob: 'sub'`), its behavior differed depending on whether `ignore` was present: without `ignore`, the static single-file fast-path returned no files, whereas with `ignore`, `tinyglobby` ran with its default `expandDirectories: true` option and recursively matched all files in the directory.
1 parent f13fabf commit 65f302c

4 files changed

Lines changed: 151 additions & 40 deletions

File tree

‎packages/angular/build/src/builders/library/pipeline/assets.ts‎

Lines changed: 9 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { lstatSync, statSync } from 'node:fs';
1010
import path from 'node:path';
1111
import picomatch from 'picomatch';
1212
import type { NormalizedAssetsPattern } from '../../../utils';
13-
import { DEFAULT_ASSET_IGNORE, resolveAssets } from '../../../utils/resolve-assets';
13+
import { normalizeAssetIgnorePatterns, resolveAssets } from '../../../utils/resolve-assets';
1414
import { type DiskOutputFile, createDiskOutputFile } from './utils';
1515

1616
/**
@@ -38,30 +38,6 @@ export interface AssetMatcher {
3838
isMatch: picomatch.Matcher;
3939
}
4040

41-
/**
42-
* Normalizes an ignore glob pattern for `picomatch` to match `tinyglobby` directory-exclusion
43-
* semantics by stripping trailing slashes, normalizing POSIX segments, and expanding patterns
44-
* with `/**` (returning both `normalized` and `${normalized}/**` for single-wildcard suffixes
45-
* because `picomatch` does not make trailing `/**` optional after `*`).
46-
*
47-
* @param pattern The raw ignore glob pattern string.
48-
* @returns The normalized POSIX glob patterns.
49-
*/
50-
function normalizeGlobPattern(pattern: string): string[] {
51-
const trimmed = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
52-
const normalized = path.posix.normalize(trimmed);
53-
54-
if (!normalized.endsWith('*')) {
55-
return [`${normalized}/**`];
56-
}
57-
58-
if (normalized.endsWith('/**')) {
59-
return [normalized];
60-
}
61-
62-
return [normalized, `${normalized}/**`];
63-
}
64-
6541
/**
6642
* Resolves the candidate POSIX source file path within `matcher` that would map to `outputPath`,
6743
* or `undefined` if `outputPath` is outside `matcher.asset.output` or does not match `matcher`.
@@ -96,14 +72,14 @@ function isMatchingAssetFile(file: string, followSymlinks: boolean | undefined):
9672
export function createAssetMatchers(assets: NormalizedAssetsPattern[]): readonly AssetMatcher[] {
9773
return assets.map((asset) => {
9874
const { glob: rawGlob, ignore: rawIgnore, input } = asset;
99-
const ignore = [...DEFAULT_ASSET_IGNORE, ...(rawIgnore ?? [])]
100-
.filter((pattern) => pattern.length > 0)
101-
.flatMap(normalizeGlobPattern);
102-
const isMatch = picomatch(rawGlob ? path.posix.normalize(rawGlob) : [], {
103-
dot: true,
104-
windows: false,
105-
ignore,
106-
});
75+
const isMatch = picomatch(
76+
rawGlob && !rawGlob.endsWith('/') ? path.posix.normalize(rawGlob) : [],
77+
{
78+
dot: true,
79+
windows: false,
80+
ignore: normalizeAssetIgnorePatterns(rawIgnore),
81+
},
82+
);
10783
const prefix = input.endsWith('/') ? input : `${input}/`;
10884

10985
return { asset, prefix, isMatch };

‎packages/angular/build/src/builders/library/pipeline/assets_spec.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,21 @@ describe('library pipeline assets', () => {
8484
expect(matchers).toHaveSize(1);
8585
expect(matchers[0].isMatch('any.txt')).toBeFalse();
8686
});
87+
88+
it('should not match files when glob has a trailing slash', () => {
89+
const matchers = createAssetMatchers([
90+
{
91+
glob: 'sub/',
92+
input: `${tempRoot}/assets`,
93+
output: 'assets',
94+
ignore: ['ignored.txt'],
95+
},
96+
]);
97+
98+
expect(matchers).toHaveSize(1);
99+
expect(matchers[0].isMatch('sub')).toBeFalse();
100+
expect(matchers[0].isMatch('sub/file.txt')).toBeFalse();
101+
});
87102
});
88103

89104
describe('collectAssetsToEmit', () => {

‎packages/angular/build/src/utils/normalize-asset-patterns_spec.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,63 @@ describe('resolveAssets', () => {
166166
resolveAssets([{ glob: '../*.txt', input: 'src', output: '.' }], tempRoot),
167167
).toBeRejectedWithError('An asset cannot be written to a location outside of the output path.');
168168
});
169+
170+
it('should not expand directory patterns in glob whether or not ignore is present', async () => {
171+
await mkdir(join(tempRoot, 'src/assets/sub'), { recursive: true });
172+
await writeFile(join(tempRoot, 'src/assets/sub/file.txt'), 'content');
173+
await writeFile(join(tempRoot, 'src/assets/sub/ignored.txt'), 'ignored');
174+
await writeFile(join(tempRoot, 'src/assets/root.txt'), 'root');
175+
176+
const resolved = await resolveAssets(
177+
[
178+
{ glob: 'sub/', input: 'src/assets', output: 'assets' },
179+
{ glob: 'sub/', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] },
180+
{ glob: 'sub', input: 'src/assets', output: 'assets' },
181+
{ glob: 'sub', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] },
182+
{ glob: 'root.txt/', input: 'src/assets', output: 'assets' },
183+
{ glob: 'root.txt/', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] },
184+
],
185+
tempRoot,
186+
);
187+
188+
expect(resolved).toHaveSize(0);
189+
});
190+
191+
it('should expand directory patterns in ignore while keeping non-ignored files', async () => {
192+
await mkdir(join(tempRoot, 'src/assets/sub-a/nested'), { recursive: true });
193+
await mkdir(join(tempRoot, 'src/assets/sub-b/nested'), { recursive: true });
194+
await writeFile(join(tempRoot, 'src/assets/sub-a/nested/a.txt'), 'a');
195+
await writeFile(join(tempRoot, 'src/assets/sub-b/nested/b.txt'), 'b');
196+
await writeFile(join(tempRoot, 'src/assets/keep.txt'), 'keep');
197+
198+
const resolved = await resolveAssets(
199+
[{ glob: '**/*', input: 'src/assets', output: 'assets', ignore: ['sub-a/', 'sub-b'] }],
200+
tempRoot,
201+
);
202+
203+
expect(resolved).toEqual([
204+
{
205+
source: join(tempRoot, 'src/assets/keep.txt'),
206+
destination: join('assets', 'keep.txt'),
207+
},
208+
]);
209+
});
210+
211+
it('should ignore default ignored files even when specified as a static file glob without ignore', async () => {
212+
await mkdir(join(tempRoot, 'src/assets'), { recursive: true });
213+
await writeFile(join(tempRoot, 'src/assets/.gitkeep'), '');
214+
await writeFile(join(tempRoot, 'src/assets/.DS_Store'), '');
215+
await writeFile(join(tempRoot, 'src/assets/Thumbs.db'), '');
216+
217+
const resolved = await resolveAssets(
218+
[
219+
{ glob: '.gitkeep', input: 'src/assets', output: 'assets' },
220+
{ glob: '.DS_Store', input: 'src/assets', output: 'assets' },
221+
{ glob: 'Thumbs.db', input: 'src/assets', output: 'assets' },
222+
],
223+
tempRoot,
224+
);
225+
226+
expect(resolved).toHaveSize(0);
227+
});
169228
});

‎packages/angular/build/src/utils/resolve-assets.ts‎

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,64 @@ import { glob, isDynamicPattern } from 'tinyglobby';
1212
import { MAX_CONCURRENT_READS, mapConcurrent } from './concurrency';
1313
import { isSubDirectory } from './path';
1414

15+
const DEFAULT_ASSET_IGNORE_FILES: ReadonlySet<string> = new Set([
16+
'.gitkeep',
17+
'.DS_Store',
18+
'Thumbs.db',
19+
]);
20+
const DEFAULT_ASSET_IGNORE_PATTERNS: readonly string[] = Array.from(
21+
DEFAULT_ASSET_IGNORE_FILES,
22+
(file) => `**/${file}`,
23+
);
24+
25+
/**
26+
* Normalizes an ignore glob pattern to match directory-exclusion semantics by stripping
27+
* trailing slashes, normalizing POSIX segments, and expanding patterns with `/**`
28+
* (returning both `normalized` and `${normalized}/**` for single-wildcard suffixes
29+
* because `picomatch` does not make trailing `/**` optional after `*`).
30+
*
31+
* @param pattern The raw ignore glob pattern string.
32+
* @returns The normalized POSIX glob patterns.
33+
*/
34+
function normalizeIgnorePattern(pattern: string): string[] {
35+
if (!pattern) {
36+
return [];
37+
}
38+
39+
const posixNormalized = path.posix.normalize(pattern);
40+
const normalized =
41+
posixNormalized.length > 1 && posixNormalized.endsWith('/')
42+
? posixNormalized.slice(0, -1)
43+
: posixNormalized;
44+
45+
if (!normalized.endsWith('*')) {
46+
return [`${normalized}/**`];
47+
}
48+
49+
if (normalized === '**' || normalized.endsWith('/**')) {
50+
return [normalized];
51+
}
52+
53+
return [normalized, `${normalized}/**`];
54+
}
55+
1556
/**
16-
* Default glob ignore patterns for assets.
57+
* Combines default asset ignore patterns with user-provided ignore patterns and normalizes
58+
* them so directory patterns (such as `'sub'` or `'sub/'`) exclude all nested files.
59+
*
60+
* @param ignore Optional user-provided ignore glob patterns.
61+
* @returns The normalized ignore glob patterns.
1762
*/
18-
export const DEFAULT_ASSET_IGNORE = ['**/.gitkeep', '**/.DS_Store', '**/Thumbs.db'] as const;
63+
export function normalizeAssetIgnorePatterns(ignore?: readonly string[]): string[] {
64+
if (!ignore?.length) {
65+
return [...DEFAULT_ASSET_IGNORE_PATTERNS];
66+
}
67+
68+
return [
69+
...DEFAULT_ASSET_IGNORE_PATTERNS,
70+
...ignore.flatMap((pattern) => normalizeIgnorePattern(pattern)),
71+
];
72+
}
1973

2074
export async function resolveAssets(
2175
entries: {
@@ -33,22 +87,28 @@ export async function resolveAssets(
3387
throw new Error(`The ${entry.input} asset path must be within the workspace root.`);
3488
}
3589

90+
if (!entry.glob || entry.glob.endsWith('/')) {
91+
return [];
92+
}
93+
3694
const cwd = path.resolve(root, entry.input);
3795

3896
if (!entry.ignore?.length && !isDynamicPattern(entry.glob)) {
3997
const src = path.join(cwd, entry.glob);
4098
if (isSubDirectory(cwd, src)) {
99+
if (DEFAULT_ASSET_IGNORE_FILES.has(path.basename(src))) {
100+
return [];
101+
}
102+
41103
try {
42104
const stats = await (entry.followSymlinks ? stat(src) : lstat(src));
43105
if (stats.isFile()) {
44-
const filePath = entry.flatten ? path.basename(entry.glob) : entry.glob;
106+
const filePath = entry.flatten ? path.basename(src) : path.relative(cwd, src);
45107

46108
return [{ source: src, destination: path.join(entry.output, filePath) }];
47109
}
48110

49-
if (!stats.isSymbolicLink()) {
50-
return [];
51-
}
111+
return [];
52112
} catch {
53113
// File does not exist or cannot be accessed.
54114
return [];
@@ -59,7 +119,8 @@ export async function resolveAssets(
59119
const files = await glob(entry.glob, {
60120
cwd,
61121
dot: true,
62-
ignore: entry.ignore ? [...DEFAULT_ASSET_IGNORE, ...entry.ignore] : DEFAULT_ASSET_IGNORE,
122+
expandDirectories: false,
123+
ignore: normalizeAssetIgnorePatterns(entry.ignore),
63124
followSymbolicLinks: entry.followSymlinks ?? false,
64125
});
65126

0 commit comments

Comments
 (0)