Skip to content

Commit a38958c

Browse files
authored
fix(@angular/build): align library watch asset matching with initial build and deduplicate outputs
- Respect `asset.followSymlinks` in watch mode by using `lstatSync` when `!asset.followSymlinks` and `statSync` when `asset.followSymlinks` is `true`. - Normalize `asset.glob` and `asset.ignore` patterns in `createAssetMatchers` to match `tinyglobby` semantics (stripping trailing slashes, expanding directory patterns with `/**`, normalizing POSIX paths, skipping empty strings, and passing `windows: false`). - Deduplicate collected and resolved assets by destination `path` so later asset configurations override earlier ones without concurrent `fs.cp` operations to the same output destination. - Queue nested emitted assets for deletion when a deleted parent directory is reported in watch mode.
1 parent 5691298 commit a38958c

2 files changed

Lines changed: 246 additions & 19 deletions

File tree

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

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { statSync } from 'node:fs';
9+
import { lstatSync, statSync } from 'node:fs';
1010
import path from 'node:path';
1111
import picomatch from 'picomatch';
1212
import type { NormalizedAssetsPattern } from '../../../utils';
@@ -38,6 +38,22 @@ export interface AssetMatcher {
3838
isMatch: picomatch.Matcher;
3939
}
4040

41+
/**
42+
* Normalizes a glob pattern to match `tinyglobby` semantics by stripping trailing slashes,
43+
* expanding directory patterns with `/**`, and normalizing POSIX segments.
44+
*
45+
* @param pattern The raw glob pattern string.
46+
* @returns The normalized POSIX glob pattern.
47+
*/
48+
function normalizeGlobPattern(pattern: string): string {
49+
let result = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
50+
if (pattern.endsWith('/') || !picomatch.scan(result).isGlob) {
51+
result += '/**';
52+
}
53+
54+
return path.posix.normalize(result);
55+
}
56+
4157
/**
4258
* Compiles picomatch matchers and POSIX input prefixes for each asset configuration.
4359
*
@@ -46,9 +62,13 @@ export interface AssetMatcher {
4662
*/
4763
export function createAssetMatchers(assets: NormalizedAssetsPattern[]): readonly AssetMatcher[] {
4864
return assets.map((asset) => {
49-
const isMatch = picomatch(asset.glob, {
65+
const ignore = [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])]
66+
.filter((pattern) => pattern.length > 0)
67+
.map(normalizeGlobPattern);
68+
const isMatch = picomatch(asset.glob ? path.posix.normalize(asset.glob) : [], {
5069
dot: true,
51-
ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])],
70+
windows: false,
71+
ignore,
5272
});
5373
const prefix = asset.input.endsWith('/') ? asset.input : `${asset.input}/`;
5474

@@ -79,21 +99,32 @@ export async function collectAssetsToEmit(
7999

80100
if (modifiedFiles) {
81101
const matchers = assetMatchers ?? createAssetMatchers(assets);
82-
const filesToEmit: DiskOutputFile[] = [];
83-
const filesToDelete: string[] = [];
102+
const filesToEmit = new Map<string, { file: DiskOutputFile; matcherIndex: number }>();
103+
const filesToDelete = new Set<string>();
84104

85105
for (const file of modifiedFiles) {
86-
let isFile: boolean | undefined;
87-
88-
if (emittedAssets.has(file)) {
89-
isFile = statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
90-
if (!isFile) {
91-
filesToDelete.push(file);
106+
let isFileStat: boolean | undefined;
107+
let isFileLstat: boolean | undefined;
108+
109+
if (!emittedAssets.has(file) && emittedAssets.size > 0) {
110+
const dirPrefix = file.endsWith('/') ? file : `${file}/`;
111+
let isDeletedDir: boolean | undefined;
112+
for (const emittedFile of emittedAssets.keys()) {
113+
if (emittedFile.startsWith(dirPrefix)) {
114+
if (!(isDeletedDir ??= !statSync(file, { throwIfNoEntry: false })?.isDirectory())) {
115+
break;
116+
}
117+
filesToDelete.add(emittedFile);
118+
}
119+
}
120+
if (isDeletedDir !== undefined) {
92121
continue;
93122
}
94123
}
95124

96-
for (const { asset, prefix, isMatch } of matchers) {
125+
let matched = false;
126+
for (let matcherIndex = 0; matcherIndex < matchers.length; matcherIndex++) {
127+
const { asset, prefix, isMatch } = matchers[matcherIndex];
97128
if (!file.startsWith(prefix)) {
98129
continue;
99130
}
@@ -103,24 +134,41 @@ export async function collectAssetsToEmit(
103134
continue;
104135
}
105136

106-
isFile ??= statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
137+
const isFile = asset.followSymlinks
138+
? (isFileStat ??= statSync(file, { throwIfNoEntry: false })?.isFile() ?? false)
139+
: (isFileLstat ??= lstatSync(file, { throwIfNoEntry: false })?.isFile() ?? false);
107140
if (!isFile) {
108-
break;
141+
continue;
109142
}
110143

111-
filesToEmit.push(createDiskOutputFile(file, path.posix.join(asset.output, relative)));
144+
matched = true;
145+
const outputFile = createDiskOutputFile(file, path.posix.join(asset.output, relative));
146+
const existing = filesToEmit.get(outputFile.path);
147+
if (!existing || matcherIndex >= existing.matcherIndex) {
148+
filesToEmit.set(outputFile.path, { file: outputFile, matcherIndex });
149+
}
150+
}
151+
152+
if (emittedAssets.has(file) && !matched) {
153+
filesToDelete.add(file);
112154
}
113155
}
114156

115-
return { filesToEmit, filesToDelete };
157+
return {
158+
filesToEmit: Array.from(filesToEmit.values(), ({ file }) => file),
159+
filesToDelete: Array.from(filesToDelete),
160+
};
116161
}
117162

118163
const resolvedAssets = await resolveAssets(assets, workspaceRoot);
164+
const filesToEmit = new Map<string, DiskOutputFile>();
165+
for (const { source, destination } of resolvedAssets) {
166+
const outputFile = createDiskOutputFile(source, destination);
167+
filesToEmit.set(outputFile.path, outputFile);
168+
}
119169

120170
return {
121-
filesToEmit: resolvedAssets.map(({ source, destination }) =>
122-
createDiskOutputFile(source, destination),
123-
),
171+
filesToEmit: Array.from(filesToEmit.values()),
124172
filesToDelete: [],
125173
};
126174
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import assert from 'node:assert';
10+
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
11+
import path from 'node:path';
12+
import { toPosixPath } from '../../../utils/path';
13+
import { collectAssetsToEmit, createAssetMatchers } from './assets';
14+
15+
describe('library pipeline assets', () => {
16+
let tempRoot: string;
17+
18+
beforeEach(async () => {
19+
const baseTmpDir = process.env['TEST_TMPDIR'];
20+
assert(baseTmpDir, 'TEST_TMPDIR is not set');
21+
tempRoot = toPosixPath(await mkdtemp(path.join(baseTmpDir, 'angular-cli-assets-spec-')));
22+
});
23+
24+
afterEach(async () => {
25+
await rm(tempRoot, { recursive: true, force: true });
26+
});
27+
28+
describe('createAssetMatchers', () => {
29+
it('should normalize patterns, expand directories in ignore, and skip empty strings', () => {
30+
const matchers = createAssetMatchers([
31+
{
32+
glob: './**/*.txt',
33+
input: `${tempRoot}/assets`,
34+
output: 'assets',
35+
ignore: ['', 'ignored-dir', './ignored-dir-2/', 'specific.txt'],
36+
},
37+
]);
38+
39+
expect(matchers).toHaveSize(1);
40+
const [matcher] = matchers;
41+
42+
expect(matcher.isMatch('keep.txt')).toBeTrue();
43+
expect(matcher.isMatch('nested/keep.txt')).toBeTrue();
44+
expect(matcher.isMatch('.hidden.txt')).toBeTrue();
45+
expect(matcher.isMatch('specific.txt')).toBeFalse();
46+
expect(matcher.isMatch('ignored-dir/file.txt')).toBeFalse();
47+
expect(matcher.isMatch('ignored-dir/sub/file.txt')).toBeFalse();
48+
expect(matcher.isMatch('ignored-dir-2/file.txt')).toBeFalse();
49+
expect(matcher.isMatch('.gitkeep')).toBeFalse();
50+
expect(matcher.isMatch('nested/.DS_Store')).toBeFalse();
51+
});
52+
53+
it('should not throw when glob is an empty string', () => {
54+
const matchers = createAssetMatchers([
55+
{
56+
glob: '',
57+
input: `${tempRoot}/assets`,
58+
output: 'assets',
59+
},
60+
]);
61+
62+
expect(matchers).toHaveSize(1);
63+
expect(matchers[0].isMatch('any.txt')).toBeFalse();
64+
});
65+
});
66+
67+
describe('collectAssetsToEmit', () => {
68+
it('should deduplicate resolved assets by destination path with later patterns overriding earlier ones', async () => {
69+
const defaultDir = `${tempRoot}/lib`;
70+
const customDir = `${tempRoot}/docs`;
71+
await mkdir(defaultDir, { recursive: true });
72+
await mkdir(customDir, { recursive: true });
73+
74+
const defaultReadme = `${defaultDir}/README.md`;
75+
const customReadme = `${customDir}/README.md`;
76+
await writeFile(defaultReadme, '# Default');
77+
await writeFile(customReadme, '# Custom');
78+
79+
const assets = [
80+
{ glob: 'README.md', input: defaultDir, output: '.' },
81+
{ glob: 'README.md', input: customDir, output: '.' },
82+
];
83+
84+
const initialResult = await collectAssetsToEmit(assets, tempRoot, new Map());
85+
expect(initialResult.filesToEmit).toEqual([
86+
{
87+
origin: 'disk',
88+
source: customReadme,
89+
path: 'README.md',
90+
},
91+
]);
92+
93+
// Watch mode: even if customReadme appears before defaultReadme in modifiedFiles,
94+
// the later asset pattern (customReadme) should still take precedence.
95+
const watchResult = await collectAssetsToEmit(
96+
assets,
97+
tempRoot,
98+
new Map(),
99+
new Set([customReadme, defaultReadme]),
100+
);
101+
expect(watchResult.filesToEmit).toEqual([
102+
{
103+
origin: 'disk',
104+
source: customReadme,
105+
path: 'README.md',
106+
},
107+
]);
108+
});
109+
110+
it('should respect followSymlinks in watch mode', async () => {
111+
const assetsDir = `${tempRoot}/assets`;
112+
await mkdir(assetsDir, { recursive: true });
113+
114+
const targetFile = `${tempRoot}/external.txt`;
115+
const symlinkFile = `${assetsDir}/linked.txt`;
116+
await writeFile(targetFile, 'external');
117+
await symlink(targetFile, symlinkFile);
118+
119+
const assetsNoFollow = [
120+
{ glob: '**/*.txt', input: assetsDir, output: 'out', followSymlinks: false },
121+
];
122+
const noFollowResult = await collectAssetsToEmit(
123+
assetsNoFollow,
124+
tempRoot,
125+
new Map(),
126+
new Set([symlinkFile]),
127+
);
128+
expect(noFollowResult.filesToEmit).toEqual([]);
129+
130+
const assetsFollow = [
131+
{ glob: '**/*.txt', input: assetsDir, output: 'out', followSymlinks: true },
132+
];
133+
const followResult = await collectAssetsToEmit(
134+
assetsFollow,
135+
tempRoot,
136+
new Map(),
137+
new Set([symlinkFile]),
138+
);
139+
expect(followResult.filesToEmit).toEqual([
140+
{
141+
origin: 'disk',
142+
source: symlinkFile,
143+
path: 'out/linked.txt',
144+
},
145+
]);
146+
});
147+
148+
it('should queue nested emitted assets for deletion when a deleted directory is reported in watch mode', async () => {
149+
const assetsDir = `${tempRoot}/assets`;
150+
const subDir = `${assetsDir}/sub`;
151+
const keepDir = `${assetsDir}/keep-dir`;
152+
await mkdir(keepDir, { recursive: true });
153+
154+
const nestedFileA = `${subDir}/a.txt`;
155+
const nestedFileB = `${subDir}/deep/b.txt`;
156+
const keepFile = `${keepDir}/keep.txt`;
157+
await writeFile(keepFile, 'keep');
158+
159+
const emittedAssets = new Map<string, Set<string>>([
160+
[nestedFileA, new Set(['out/sub/a.txt'])],
161+
[nestedFileB, new Set(['out/sub/deep/b.txt'])],
162+
[keepFile, new Set(['out/keep-dir/keep.txt'])],
163+
]);
164+
165+
const assets = [{ glob: '**/*.txt', input: assetsDir, output: 'out' }];
166+
167+
// subDir does not exist on disk; keepDir still exists on disk
168+
const result = await collectAssetsToEmit(
169+
assets,
170+
tempRoot,
171+
emittedAssets,
172+
new Set([subDir, keepDir]),
173+
);
174+
175+
expect(result.filesToEmit).toEqual([]);
176+
expect(result.filesToDelete).toEqual([nestedFileA, nestedFileB]);
177+
});
178+
});
179+
});

0 commit comments

Comments
 (0)