Skip to content

Commit bc363af

Browse files
committed
perf(@angular/cli): optimize package manager discovery with stat-based probing
This refactors the package manager discovery logic to use 'stat' instead of 'readdir', significantly improving performance in directories with many files. It also simplifies the internal host abstraction by removing the now unused 'readdir' method.
1 parent ccd1e81 commit bc363af

File tree

4 files changed

+38
-56
lines changed

4 files changed

+38
-56
lines changed

packages/angular/cli/src/package-managers/discovery.ts

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,6 @@ import {
2121
SUPPORTED_PACKAGE_MANAGERS,
2222
} from './package-manager-descriptor';
2323

24-
/**
25-
* A map from lockfile names to their corresponding package manager.
26-
* This is a performance optimization to avoid iterating over all possible
27-
* lockfiles in every directory.
28-
*/
29-
const LOCKFILE_TO_PACKAGE_MANAGER = new Map<string, PackageManagerName>();
30-
for (const [name, descriptor] of Object.entries(SUPPORTED_PACKAGE_MANAGERS)) {
31-
for (const lockfile of descriptor.lockfiles) {
32-
LOCKFILE_TO_PACKAGE_MANAGER.set(lockfile, name as PackageManagerName);
33-
}
34-
}
35-
3624
/**
3725
* Searches a directory for lockfiles and returns a set of package managers that correspond to them.
3826
* @param host A `Host` instance for interacting with the file system.
@@ -47,25 +35,32 @@ async function findLockfiles(
4735
): Promise<Set<PackageManagerName>> {
4836
logger?.debug(`Searching for lockfiles in '${directory}'...`);
4937

50-
try {
51-
const files = await host.readdir(directory);
52-
const foundPackageManagers = new Set<PackageManagerName>();
53-
54-
for (const file of files) {
55-
const packageManager = LOCKFILE_TO_PACKAGE_MANAGER.get(file);
56-
if (packageManager) {
57-
logger?.debug(` Found '${file}'.`);
58-
foundPackageManagers.add(packageManager);
59-
}
38+
const foundPackageManagers = new Set<PackageManagerName>();
39+
const checks: Promise<void>[] = [];
40+
41+
for (const [name, descriptor] of Object.entries(SUPPORTED_PACKAGE_MANAGERS)) {
42+
const manager = name as PackageManagerName;
43+
for (const lockfile of descriptor.lockfiles) {
44+
checks.push(
45+
(async () => {
46+
try {
47+
const path = join(directory, lockfile);
48+
const stats = await host.stat(path);
49+
if (stats.isFile()) {
50+
logger?.debug(` Found '${lockfile}'.`);
51+
foundPackageManagers.add(manager);
52+
}
53+
} catch {
54+
// File does not exist or cannot be accessed.
55+
}
56+
})(),
57+
);
6058
}
59+
}
6160

62-
return foundPackageManagers;
63-
} catch (e) {
64-
logger?.debug(` Failed to read directory: ${e}`);
61+
await Promise.all(checks);
6562

66-
// Ignore directories that don't exist or can't be read.
67-
return new Set();
68-
}
63+
return foundPackageManagers;
6964
}
7065

7166
/**

packages/angular/cli/src/package-managers/discovery_spec.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,6 @@ describe('discover', () => {
6464
expect(result).toBeNull();
6565
});
6666

67-
it('should handle file system errors during readdir gracefully', async () => {
68-
const host = new MockHost({});
69-
host.readdir = () => Promise.reject(new Error('Permission denied'));
70-
71-
const result = await discover(host, '/project');
72-
expect(result).toBeNull();
73-
});
74-
7567
it('should handle file system errors during stat gracefully', async () => {
7668
const host = new MockHost({ '/project': ['.git'] });
7769
host.stat = () => Promise.reject(new Error('Permission denied'));

packages/angular/cli/src/package-managers/host.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,6 @@ export interface Host {
3939
*/
4040
stat(path: string): Promise<Stats>;
4141

42-
/**
43-
* Reads the contents of a directory.
44-
* @param path The path to the directory.
45-
* @returns A promise that resolves to an array of file and directory names.
46-
*/
47-
readdir(path: string): Promise<string[]>;
48-
4942
/**
5043
* Reads the content of a file.
5144
* @param path The path to the file.
@@ -108,7 +101,6 @@ export interface Host {
108101
*/
109102
export const NodeJS_HOST: Host = {
110103
stat,
111-
readdir,
112104
mkdir,
113105
readFile: (path: string) => readFile(path, { encoding: 'utf8' }),
114106
copyFile: (src, dest) => copyFile(src, dest, constants.COPYFILE_FICLONE),

packages/angular/cli/src/package-managers/testing/mock-host.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@ export class MockHost implements Host {
1919
constructor(files: Record<string, string[] | true> = {}) {
2020
// Normalize paths to use forward slashes for consistency in tests.
2121
for (const [path, content] of Object.entries(files)) {
22-
this.fs.set(path.replace(/\\/g, '/'), content);
22+
const normalizedPath = path.replace(/\\/g, '/');
23+
this.fs.set(normalizedPath, content);
24+
25+
// If the content is an array (directory listing), create entries for the files in it.
26+
if (Array.isArray(content)) {
27+
for (const file of content) {
28+
const filePath = normalizedPath === '/' ? `/${file}` : `${normalizedPath}/${file}`;
29+
this.fs.set(filePath, []); // Use empty array to represent a file (not `true` which is a dir)
30+
}
31+
}
2332
}
2433
}
2534

@@ -34,17 +43,11 @@ export class MockHost implements Host {
3443
}
3544

3645
// A `true` value signifies a directory in our mock file system.
37-
return Promise.resolve({ isDirectory: () => content === true } as Stats);
38-
}
39-
40-
readdir(path: string): Promise<string[]> {
41-
const content = this.fs.get(path.replace(/\\/g, '/'));
42-
if (content === true || content === undefined) {
43-
// This should be a directory with a file list.
44-
return Promise.reject(new Error(`Directory not found or not a directory: ${path}`));
45-
}
46-
47-
return Promise.resolve(content);
46+
// Anything else is considered a file for the purpose of this mock.
47+
return Promise.resolve({
48+
isDirectory: () => content === true,
49+
isFile: () => content !== true,
50+
} as Stats);
4851
}
4952

5053
runCommand(): Promise<{ stdout: string; stderr: string }> {

0 commit comments

Comments
 (0)