Skip to content

Commit 6c0f73f

Browse files
committed
feat(@angular/build): share persistent build cache across git worktrees
This change automatically resolves the default persistent cache directory relative to the common git directory (the main repository root) instead of the worktree root when a Git worktree is detected. This prevents cache duplication across multiple checkouts/worktrees of the same repository. Closes #33321
1 parent 92da0d6 commit 6c0f73f

4 files changed

Lines changed: 281 additions & 6 deletions

File tree

packages/angular/build/src/utils/normalize-cache.ts

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

9-
import { join, resolve } from 'node:path';
9+
import { existsSync, readFileSync, statSync } from 'node:fs';
10+
import { dirname, isAbsolute, join, resolve } from 'node:path';
1011

1112
/** Version placeholder is replaced during the build process with actual package version */
1213
const VERSION = '0.0.0-PLACEHOLDER';
@@ -39,6 +40,46 @@ function hasCacheMetadata(value: unknown): value is { cli: { cache: CacheMetadat
3940
);
4041
}
4142

43+
function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): string {
44+
if (isAbsolute(cachePathSetting)) {
45+
return cachePathSetting;
46+
}
47+
48+
try {
49+
// Find the git directory, walking up from workspaceRoot if necessary
50+
let currentDir = workspaceRoot;
51+
while (true) {
52+
const gitPath = join(currentDir, '.git');
53+
if (existsSync(gitPath)) {
54+
const stat = statSync(gitPath);
55+
if (stat.isFile()) {
56+
// Could be a git worktree (or submodule)
57+
const content = readFileSync(gitPath, 'utf8');
58+
const match = /^gitdir:\s*(.+)$/m.exec(content);
59+
if (match) {
60+
const gitdir = resolve(currentDir, match[1].trim());
61+
const commondirPath = join(gitdir, 'commondir');
62+
if (existsSync(commondirPath)) {
63+
// It's a git worktree
64+
const commondir = readFileSync(commondirPath, 'utf8').trim();
65+
const commonGitDir = resolve(gitdir, commondir);
66+
67+
return resolve(dirname(commonGitDir), cachePathSetting);
68+
}
69+
}
70+
}
71+
}
72+
const parentDir = dirname(currentDir);
73+
if (parentDir === currentDir) {
74+
break;
75+
}
76+
currentDir = parentDir;
77+
}
78+
} catch {}
79+
80+
return resolve(workspaceRoot, cachePathSetting);
81+
}
82+
4283
export function normalizeCacheOptions(
4384
projectMetadata: unknown,
4485
worspaceRoot: string,
@@ -65,7 +106,7 @@ export function normalizeCacheOptions(
65106
}
66107
}
67108

68-
const cacheBasePath = resolve(worspaceRoot, path);
109+
const cacheBasePath = getCacheBasePath(worspaceRoot, path);
69110

70111
return {
71112
enabled: cacheEnabled,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
10+
import { tmpdir } from 'node:os';
11+
import { join, resolve } from 'node:path';
12+
import { normalizeCacheOptions } from './normalize-cache';
13+
14+
describe('normalizeCacheOptions', () => {
15+
let tempDir: string;
16+
17+
beforeEach(async () => {
18+
tempDir = await mkdtemp(join(tmpdir(), 'angular-cache-spec-'));
19+
});
20+
21+
afterEach(async () => {
22+
await rm(tempDir, { recursive: true, force: true });
23+
});
24+
25+
it('should resolve cache path relative to workspace root in a standard repository', async () => {
26+
const workspaceRoot = join(tempDir, 'project');
27+
await mkdir(join(workspaceRoot, '.git'), { recursive: true });
28+
29+
const options = normalizeCacheOptions({}, workspaceRoot);
30+
31+
expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache'));
32+
});
33+
34+
it('should resolve cache path relative to main repository root in a git worktree', async () => {
35+
const mainRepoRoot = join(tempDir, 'main-repo');
36+
const mainGitDir = join(mainRepoRoot, '.git');
37+
const worktreeRoot = join(tempDir, 'worktree');
38+
39+
// Create main repo structure
40+
await mkdir(mainGitDir, { recursive: true });
41+
42+
// Create worktree folder and .git file pointing to the main repo's worktree metadata folder
43+
const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1');
44+
await mkdir(worktreeMetadataDir, { recursive: true });
45+
await mkdir(worktreeRoot, { recursive: true });
46+
await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`);
47+
48+
// Create the commondir file in the worktree metadata folder pointing back to the main .git dir
49+
await writeFile(join(worktreeMetadataDir, 'commondir'), '../..');
50+
51+
const options = normalizeCacheOptions({}, worktreeRoot);
52+
53+
expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache'));
54+
});
55+
56+
it('should resolve cache path relative to workspace root in a git submodule', async () => {
57+
const mainRepoRoot = join(tempDir, 'main-repo');
58+
const submoduleRoot = join(mainRepoRoot, 'submodule');
59+
60+
// Create main repo structure and submodule metadata folder
61+
const submoduleGitDir = join(mainRepoRoot, '.git/modules/sub');
62+
await mkdir(submoduleGitDir, { recursive: true });
63+
await mkdir(submoduleRoot, { recursive: true });
64+
65+
// Create .git file in submodule pointing to the metadata folder
66+
await writeFile(join(submoduleRoot, '.git'), `gitdir: ../.git/modules/sub`);
67+
68+
// Submodules do NOT have a 'commondir' file.
69+
const options = normalizeCacheOptions({}, submoduleRoot);
70+
71+
expect(options.basePath).toBe(resolve(submoduleRoot, '.angular/cache'));
72+
});
73+
74+
it('should resolve cache path relative to workspace root when there is no git repository', async () => {
75+
const workspaceRoot = join(tempDir, 'project');
76+
await mkdir(workspaceRoot, { recursive: true });
77+
78+
const options = normalizeCacheOptions({}, workspaceRoot);
79+
80+
expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache'));
81+
});
82+
});

packages/angular/cli/src/commands/cache/utilities.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
*/
88

99
import { isJsonObject } from '@angular-devkit/core';
10-
import { resolve } from 'node:path';
10+
import { existsSync, readFileSync, statSync } from 'node:fs';
11+
import { dirname, isAbsolute, join, resolve } from 'node:path';
1112
import { Cache, Environment } from '../../../lib/config/workspace-schema';
1213
import { AngularWorkspace } from '../../utilities/config';
1314

@@ -23,13 +24,53 @@ export function updateCacheConfig<K extends keyof Cache>(
2324
return workspace.save();
2425
}
2526

27+
function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): string {
28+
if (isAbsolute(cachePathSetting)) {
29+
return cachePathSetting;
30+
}
31+
32+
try {
33+
// Find the git directory, walking up from workspaceRoot if necessary
34+
let currentDir = workspaceRoot;
35+
while (true) {
36+
const gitPath = join(currentDir, '.git');
37+
if (existsSync(gitPath)) {
38+
const stat = statSync(gitPath);
39+
if (stat.isFile()) {
40+
// Could be a git worktree (or submodule)
41+
const content = readFileSync(gitPath, 'utf8');
42+
const match = /^gitdir:\s*(.+)$/m.exec(content);
43+
if (match) {
44+
const gitdir = resolve(currentDir, match[1].trim());
45+
const commondirPath = join(gitdir, 'commondir');
46+
if (existsSync(commondirPath)) {
47+
// It's a git worktree
48+
const commondir = readFileSync(commondirPath, 'utf8').trim();
49+
const commonGitDir = resolve(gitdir, commondir);
50+
51+
return resolve(dirname(commonGitDir), cachePathSetting);
52+
}
53+
}
54+
}
55+
}
56+
const parentDir = dirname(currentDir);
57+
if (parentDir === currentDir) {
58+
break;
59+
}
60+
currentDir = parentDir;
61+
}
62+
} catch {}
63+
64+
return resolve(workspaceRoot, cachePathSetting);
65+
}
66+
2667
export function getCacheConfig(workspace: AngularWorkspace | undefined): Required<Cache> {
2768
if (!workspace) {
2869
throw new Error(`Cannot retrieve cache configuration as workspace is not defined.`);
2970
}
3071

3172
const defaultSettings: Required<Cache> = {
32-
path: resolve(workspace.basePath, '.angular/cache'),
73+
path: getCacheBasePath(workspace.basePath, '.angular/cache'),
3374
environment: Environment.Local,
3475
enabled: true,
3576
};
@@ -45,14 +86,14 @@ export function getCacheConfig(workspace: AngularWorkspace | undefined): Require
4586
}
4687

4788
const {
48-
path = defaultSettings.path,
89+
path = '.angular/cache',
4990
environment = defaultSettings.environment,
5091
enabled = defaultSettings.enabled,
5192
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5293
} = cacheSettings as Record<string, any>;
5394

5495
return {
55-
path: resolve(workspace.basePath, path),
96+
path: getCacheBasePath(workspace.basePath, path),
5697
environment,
5798
enabled,
5899
};
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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 { workspaces } from '@angular-devkit/core';
10+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
11+
import { tmpdir } from 'node:os';
12+
import { join, resolve } from 'node:path';
13+
import { AngularWorkspace } from '../../utilities/config';
14+
import { getCacheConfig } from './utilities';
15+
16+
describe('CLI cache config utilities', () => {
17+
let tempDir: string;
18+
19+
beforeEach(async () => {
20+
tempDir = await mkdtemp(join(tmpdir(), 'angular-cli-cache-spec-'));
21+
});
22+
23+
afterEach(async () => {
24+
await rm(tempDir, { recursive: true, force: true });
25+
});
26+
27+
function mockWorkspace(basePath: string, cliExtension?: unknown): AngularWorkspace {
28+
return {
29+
basePath,
30+
extensions: cliExtension ? { cli: cliExtension } : {},
31+
projects: {} as unknown as workspaces.ProjectDefinitionCollection,
32+
filePath: join(basePath, 'angular.json'),
33+
getCli: () => cliExtension,
34+
getProjectCli: () => undefined,
35+
save: () => Promise.resolve(),
36+
} as unknown as AngularWorkspace;
37+
}
38+
39+
it('should resolve default cache path relative to workspace basePath in a standard repository', async () => {
40+
const workspaceRoot = join(tempDir, 'project');
41+
await mkdir(join(workspaceRoot, '.git'), { recursive: true });
42+
43+
const config = getCacheConfig(mockWorkspace(workspaceRoot));
44+
45+
expect(config.path).toBe(resolve(workspaceRoot, '.angular/cache'));
46+
});
47+
48+
it('should resolve default cache path relative to main repository root in a git worktree', async () => {
49+
const mainRepoRoot = join(tempDir, 'main-repo');
50+
const mainGitDir = join(mainRepoRoot, '.git');
51+
const worktreeRoot = join(tempDir, 'worktree');
52+
53+
// Create main repo structure
54+
await mkdir(mainGitDir, { recursive: true });
55+
56+
// Create worktree folder and .git file pointing to the main repo's worktree metadata folder
57+
const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1');
58+
await mkdir(worktreeMetadataDir, { recursive: true });
59+
await mkdir(worktreeRoot, { recursive: true });
60+
await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`);
61+
62+
// Create the commondir file in the worktree metadata folder pointing back to the main .git dir
63+
await writeFile(join(worktreeMetadataDir, 'commondir'), '../..');
64+
65+
const config = getCacheConfig(mockWorkspace(worktreeRoot));
66+
67+
expect(config.path).toBe(resolve(mainRepoRoot, '.angular/cache'));
68+
});
69+
70+
it('should resolve custom relative cache path relative to main repository root in a git worktree', async () => {
71+
const mainRepoRoot = join(tempDir, 'main-repo');
72+
const mainGitDir = join(mainRepoRoot, '.git');
73+
const worktreeRoot = join(tempDir, 'worktree');
74+
75+
// Create main repo structure
76+
await mkdir(mainGitDir, { recursive: true });
77+
78+
// Create worktree folder and .git file pointing to the main repo's worktree metadata folder
79+
const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1');
80+
await mkdir(worktreeMetadataDir, { recursive: true });
81+
await mkdir(worktreeRoot, { recursive: true });
82+
await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`);
83+
84+
// Create the commondir file in the worktree metadata folder pointing back to the main .git dir
85+
await writeFile(join(worktreeMetadataDir, 'commondir'), '../..');
86+
87+
const config = getCacheConfig(
88+
mockWorkspace(worktreeRoot, { cache: { path: 'custom/cache-dir' } }),
89+
);
90+
91+
expect(config.path).toBe(resolve(mainRepoRoot, 'custom/cache-dir'));
92+
});
93+
94+
it('should resolve cache path relative to workspace basePath in a git submodule', async () => {
95+
const mainRepoRoot = join(tempDir, 'main-repo');
96+
const submoduleRoot = join(mainRepoRoot, 'submodule');
97+
98+
// Create main repo structure and submodule metadata folder
99+
const submoduleGitDir = join(mainRepoRoot, '.git/modules/sub');
100+
await mkdir(submoduleGitDir, { recursive: true });
101+
await mkdir(submoduleRoot, { recursive: true });
102+
103+
// Create .git file in submodule pointing to the metadata folder
104+
await writeFile(join(submoduleRoot, '.git'), `gitdir: ../.git/modules/sub`);
105+
106+
// Submodules do NOT have a 'commondir' file.
107+
const config = getCacheConfig(mockWorkspace(submoduleRoot));
108+
109+
expect(config.path).toBe(resolve(submoduleRoot, '.angular/cache'));
110+
});
111+
});

0 commit comments

Comments
 (0)