From 6c0f73f7070e682a856e6d1841bb2bc5bc153e00 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:34:15 -0400 Subject: [PATCH] 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 --- .../build/src/utils/normalize-cache.ts | 45 ++++++- .../build/src/utils/normalize-cache_spec.ts | 82 +++++++++++++ .../cli/src/commands/cache/utilities.ts | 49 +++++++- .../cli/src/commands/cache/utilities_spec.ts | 111 ++++++++++++++++++ 4 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 packages/angular/build/src/utils/normalize-cache_spec.ts create mode 100644 packages/angular/cli/src/commands/cache/utilities_spec.ts diff --git a/packages/angular/build/src/utils/normalize-cache.ts b/packages/angular/build/src/utils/normalize-cache.ts index f272f6a78e45..c2dc52514992 100644 --- a/packages/angular/build/src/utils/normalize-cache.ts +++ b/packages/angular/build/src/utils/normalize-cache.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import { join, resolve } from 'node:path'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; /** Version placeholder is replaced during the build process with actual package version */ const VERSION = '0.0.0-PLACEHOLDER'; @@ -39,6 +40,46 @@ function hasCacheMetadata(value: unknown): value is { cli: { cache: CacheMetadat ); } +function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): string { + if (isAbsolute(cachePathSetting)) { + return cachePathSetting; + } + + try { + // Find the git directory, walking up from workspaceRoot if necessary + let currentDir = workspaceRoot; + while (true) { + const gitPath = join(currentDir, '.git'); + if (existsSync(gitPath)) { + const stat = statSync(gitPath); + if (stat.isFile()) { + // Could be a git worktree (or submodule) + const content = readFileSync(gitPath, 'utf8'); + const match = /^gitdir:\s*(.+)$/m.exec(content); + if (match) { + const gitdir = resolve(currentDir, match[1].trim()); + const commondirPath = join(gitdir, 'commondir'); + if (existsSync(commondirPath)) { + // It's a git worktree + const commondir = readFileSync(commondirPath, 'utf8').trim(); + const commonGitDir = resolve(gitdir, commondir); + + return resolve(dirname(commonGitDir), cachePathSetting); + } + } + } + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + } catch {} + + return resolve(workspaceRoot, cachePathSetting); +} + export function normalizeCacheOptions( projectMetadata: unknown, worspaceRoot: string, @@ -65,7 +106,7 @@ export function normalizeCacheOptions( } } - const cacheBasePath = resolve(worspaceRoot, path); + const cacheBasePath = getCacheBasePath(worspaceRoot, path); return { enabled: cacheEnabled, diff --git a/packages/angular/build/src/utils/normalize-cache_spec.ts b/packages/angular/build/src/utils/normalize-cache_spec.ts new file mode 100644 index 000000000000..c8a72870d62a --- /dev/null +++ b/packages/angular/build/src/utils/normalize-cache_spec.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { normalizeCacheOptions } from './normalize-cache'; + +describe('normalizeCacheOptions', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'angular-cache-spec-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('should resolve cache path relative to workspace root in a standard repository', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + }); + + it('should resolve cache path relative to main repository root in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + // Create main repo structure + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree folder and .git file pointing to the main repo's worktree metadata folder + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Create the commondir file in the worktree metadata folder pointing back to the main .git dir + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeRoot); + + expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + }); + + it('should resolve cache path relative to workspace root in a git submodule', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const submoduleRoot = join(mainRepoRoot, 'submodule'); + + // Create main repo structure and submodule metadata folder + const submoduleGitDir = join(mainRepoRoot, '.git/modules/sub'); + await mkdir(submoduleGitDir, { recursive: true }); + await mkdir(submoduleRoot, { recursive: true }); + + // Create .git file in submodule pointing to the metadata folder + await writeFile(join(submoduleRoot, '.git'), `gitdir: ../.git/modules/sub`); + + // Submodules do NOT have a 'commondir' file. + const options = normalizeCacheOptions({}, submoduleRoot); + + expect(options.basePath).toBe(resolve(submoduleRoot, '.angular/cache')); + }); + + it('should resolve cache path relative to workspace root when there is no git repository', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(workspaceRoot, { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + }); +}); diff --git a/packages/angular/cli/src/commands/cache/utilities.ts b/packages/angular/cli/src/commands/cache/utilities.ts index 84e22314763a..03a6c6ee6f8e 100644 --- a/packages/angular/cli/src/commands/cache/utilities.ts +++ b/packages/angular/cli/src/commands/cache/utilities.ts @@ -7,7 +7,8 @@ */ import { isJsonObject } from '@angular-devkit/core'; -import { resolve } from 'node:path'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; import { Cache, Environment } from '../../../lib/config/workspace-schema'; import { AngularWorkspace } from '../../utilities/config'; @@ -23,13 +24,53 @@ export function updateCacheConfig( return workspace.save(); } +function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): string { + if (isAbsolute(cachePathSetting)) { + return cachePathSetting; + } + + try { + // Find the git directory, walking up from workspaceRoot if necessary + let currentDir = workspaceRoot; + while (true) { + const gitPath = join(currentDir, '.git'); + if (existsSync(gitPath)) { + const stat = statSync(gitPath); + if (stat.isFile()) { + // Could be a git worktree (or submodule) + const content = readFileSync(gitPath, 'utf8'); + const match = /^gitdir:\s*(.+)$/m.exec(content); + if (match) { + const gitdir = resolve(currentDir, match[1].trim()); + const commondirPath = join(gitdir, 'commondir'); + if (existsSync(commondirPath)) { + // It's a git worktree + const commondir = readFileSync(commondirPath, 'utf8').trim(); + const commonGitDir = resolve(gitdir, commondir); + + return resolve(dirname(commonGitDir), cachePathSetting); + } + } + } + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + } catch {} + + return resolve(workspaceRoot, cachePathSetting); +} + export function getCacheConfig(workspace: AngularWorkspace | undefined): Required { if (!workspace) { throw new Error(`Cannot retrieve cache configuration as workspace is not defined.`); } const defaultSettings: Required = { - path: resolve(workspace.basePath, '.angular/cache'), + path: getCacheBasePath(workspace.basePath, '.angular/cache'), environment: Environment.Local, enabled: true, }; @@ -45,14 +86,14 @@ export function getCacheConfig(workspace: AngularWorkspace | undefined): Require } const { - path = defaultSettings.path, + path = '.angular/cache', environment = defaultSettings.environment, enabled = defaultSettings.enabled, // eslint-disable-next-line @typescript-eslint/no-explicit-any } = cacheSettings as Record; return { - path: resolve(workspace.basePath, path), + path: getCacheBasePath(workspace.basePath, path), environment, enabled, }; diff --git a/packages/angular/cli/src/commands/cache/utilities_spec.ts b/packages/angular/cli/src/commands/cache/utilities_spec.ts new file mode 100644 index 000000000000..8179e70309cc --- /dev/null +++ b/packages/angular/cli/src/commands/cache/utilities_spec.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { workspaces } from '@angular-devkit/core'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { AngularWorkspace } from '../../utilities/config'; +import { getCacheConfig } from './utilities'; + +describe('CLI cache config utilities', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'angular-cli-cache-spec-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function mockWorkspace(basePath: string, cliExtension?: unknown): AngularWorkspace { + return { + basePath, + extensions: cliExtension ? { cli: cliExtension } : {}, + projects: {} as unknown as workspaces.ProjectDefinitionCollection, + filePath: join(basePath, 'angular.json'), + getCli: () => cliExtension, + getProjectCli: () => undefined, + save: () => Promise.resolve(), + } as unknown as AngularWorkspace; + } + + it('should resolve default cache path relative to workspace basePath in a standard repository', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const config = getCacheConfig(mockWorkspace(workspaceRoot)); + + expect(config.path).toBe(resolve(workspaceRoot, '.angular/cache')); + }); + + it('should resolve default cache path relative to main repository root in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + // Create main repo structure + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree folder and .git file pointing to the main repo's worktree metadata folder + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Create the commondir file in the worktree metadata folder pointing back to the main .git dir + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const config = getCacheConfig(mockWorkspace(worktreeRoot)); + + expect(config.path).toBe(resolve(mainRepoRoot, '.angular/cache')); + }); + + it('should resolve custom relative cache path relative to main repository root in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + // Create main repo structure + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree folder and .git file pointing to the main repo's worktree metadata folder + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Create the commondir file in the worktree metadata folder pointing back to the main .git dir + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const config = getCacheConfig( + mockWorkspace(worktreeRoot, { cache: { path: 'custom/cache-dir' } }), + ); + + expect(config.path).toBe(resolve(mainRepoRoot, 'custom/cache-dir')); + }); + + it('should resolve cache path relative to workspace basePath in a git submodule', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const submoduleRoot = join(mainRepoRoot, 'submodule'); + + // Create main repo structure and submodule metadata folder + const submoduleGitDir = join(mainRepoRoot, '.git/modules/sub'); + await mkdir(submoduleGitDir, { recursive: true }); + await mkdir(submoduleRoot, { recursive: true }); + + // Create .git file in submodule pointing to the metadata folder + await writeFile(join(submoduleRoot, '.git'), `gitdir: ../.git/modules/sub`); + + // Submodules do NOT have a 'commondir' file. + const config = getCacheConfig(mockWorkspace(submoduleRoot)); + + expect(config.path).toBe(resolve(submoduleRoot, '.angular/cache')); + }); +});