Skip to content

Commit 2c83cc9

Browse files
author
g.d.panov
committed
fix(@angular/cli): batch Prettier invocations during migration formatting
`formatFiles` passed every changed file to Prettier as a single `execFile` argument list. On large repositories (thousands of changed files) the combined command line exceeds the OS limit and the spawn is rejected with `spawn E2BIG` (POSIX) or `spawn ENAMETOOLONG` (Windows). The error is caught and only logged, so `ng update` migrations print `WARNING: Formatting of files failed ...` and silently skip formatting on exactly the large repos the step targets. Split the file list into batches that stay under a conservative command-line length budget and invoke Prettier once per batch. Fixes #33597
1 parent a2346bf commit 2c83cc9

2 files changed

Lines changed: 115 additions & 6 deletions

File tree

packages/angular/cli/src/utilities/prettier.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,49 @@ import { promisify } from 'node:util';
1515
const execFileAsync = promisify(execFile);
1616
let prettierCliPath: string | null | undefined;
1717

18+
/**
19+
* Conservative upper bound for the length of a single spawned command line.
20+
* Windows caps `CreateProcess` command lines at 32,767 characters; POSIX `ARG_MAX`
21+
* is larger. Staying below this budget avoids `E2BIG`/`ENAMETOOLONG` when a large
22+
* migration changes thousands of files.
23+
*/
24+
const MAX_COMMAND_LINE_LENGTH = 32_000;
25+
26+
/**
27+
* Groups files into batches whose combined argument length (including `baseLength`
28+
* for the fixed leading arguments) stays under `maxLength`. A file longer on its own
29+
* than the budget still gets its own batch, so files are never dropped.
30+
*/
31+
export function batchFilesByArgumentLength(
32+
files: Iterable<string>,
33+
baseLength: number,
34+
maxLength: number,
35+
): string[][] {
36+
const batches: string[][] = [];
37+
let batch: string[] = [];
38+
let length = baseLength;
39+
40+
for (const file of files) {
41+
// Account for the separator/terminator between arguments.
42+
const fileLength = file.length + 1;
43+
44+
if (batch.length > 0 && length + fileLength > maxLength) {
45+
batches.push(batch);
46+
batch = [];
47+
length = baseLength;
48+
}
49+
50+
batch.push(file);
51+
length += fileLength;
52+
}
53+
54+
if (batch.length > 0) {
55+
batches.push(batch);
56+
}
57+
58+
return batches;
59+
}
60+
1861
/**
1962
* Formats files using Prettier.
2063
* @param cwd The current working directory.
@@ -42,12 +85,20 @@ export async function formatFiles(cwd: string, files: Set<string>): Promise<void
4285
return;
4386
}
4487

45-
await execFileAsync(
46-
process.execPath,
47-
[prettierCliPath, '--write', '--no-error-on-unmatched-pattern', '--ignore-unknown', ...files],
48-
{
88+
const baseArgs = [
89+
prettierCliPath,
90+
'--write',
91+
'--no-error-on-unmatched-pattern',
92+
'--ignore-unknown',
93+
];
94+
const baseLength = baseArgs.reduce((total, arg) => total + arg.length + 1, 0);
95+
96+
// Spawn Prettier once per batch so repositories with many changed files do not
97+
// overflow the OS command-line length limit.
98+
for (const batch of batchFilesByArgumentLength(files, baseLength, MAX_COMMAND_LINE_LENGTH)) {
99+
await execFileAsync(process.execPath, [...baseArgs, ...batch], {
49100
cwd,
50101
shell: false,
51-
},
52-
);
102+
});
103+
}
53104
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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 { batchFilesByArgumentLength } from './prettier';
10+
11+
describe('batchFilesByArgumentLength', () => {
12+
it('returns no batches when there are no files', () => {
13+
expect(batchFilesByArgumentLength([], 10, 100)).toEqual([]);
14+
});
15+
16+
it('keeps files that fit the budget in a single batch', () => {
17+
const files = ['a.ts', 'b.ts', 'c.ts'];
18+
19+
expect(batchFilesByArgumentLength(files, 0, 1000)).toEqual([files]);
20+
});
21+
22+
it('splits files into multiple batches when the budget is exceeded', () => {
23+
// Each file contributes `length + 1` => 5; a budget of 12 fits two files per batch.
24+
expect(batchFilesByArgumentLength(['aaaa', 'bbbb', 'cccc', 'dddd'], 0, 12)).toEqual([
25+
['aaaa', 'bbbb'],
26+
['cccc', 'dddd'],
27+
]);
28+
});
29+
30+
it('reserves the base argument length in every batch', () => {
31+
// base 8, each file => 5: 8 + 5 fits, 8 + 5 + 5 > 15 so the second file starts a new batch.
32+
expect(batchFilesByArgumentLength(['aaaa', 'bbbb'], 8, 15)).toEqual([['aaaa'], ['bbbb']]);
33+
});
34+
35+
it('never drops a file that is longer than the budget on its own', () => {
36+
const files = ['short', 'a-very-long-single-file-name-well-over-budget', 'tiny'];
37+
38+
const batches = batchFilesByArgumentLength(files, 0, 10);
39+
40+
expect(batches.flat()).toEqual(files);
41+
expect(batches).toContain(['a-very-long-single-file-name-well-over-budget']);
42+
});
43+
44+
it('keeps multi-file batches within the budget for a large file set', () => {
45+
const files = Array.from({ length: 5000 }, (_, index) => `path/to/file-${index}.component.ts`);
46+
const baseLength = 40;
47+
const maxLength = 2000;
48+
49+
for (const batch of batchFilesByArgumentLength(files, baseLength, maxLength)) {
50+
const length = batch.reduce((total, file) => total + file.length + 1, baseLength);
51+
52+
// A batch may exceed the budget only when it is a single unavoidable oversized file.
53+
if (batch.length > 1) {
54+
expect(length).toBeLessThanOrEqual(maxLength);
55+
}
56+
}
57+
});
58+
});

0 commit comments

Comments
 (0)