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')); + }); +});