From cb6542b2493b28d417e3fee97d841aa3154f0b05 Mon Sep 17 00:00:00 2001 From: rk Date: Fri, 30 Oct 2026 12:21:05 +0500 Subject: [PATCH 1/2] fix(@angular/cli): also clean the worktree-local cache in a git worktree in git worktree `path` points to shared cache in main checkout, and since #33976 dev-server keeps its Vite cache in worktree own `.angular/cache`, so `ng cache clean` wiped cache of main checkout and left stale Vite prebundles of worktree in place `getCacheConfig` now also returns `localPath`, resolved against workspace like `localBasePath` in @angular/build, and clean removes both. Outside of worktree they are same path --- .../cli/src/commands/cache/clean/cli.ts | 18 +++-- .../cli/src/commands/cache/clean/cli_spec.ts | 76 +++++++++++++++++++ .../cli/src/commands/cache/utilities.ts | 14 +++- .../cli/src/commands/cache/utilities_spec.ts | 3 + 4 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 packages/angular/cli/src/commands/cache/clean/cli_spec.ts diff --git a/packages/angular/cli/src/commands/cache/clean/cli.ts b/packages/angular/cli/src/commands/cache/clean/cli.ts index 492699271b54..ac7e573db640 100644 --- a/packages/angular/cli/src/commands/cache/clean/cli.ts +++ b/packages/angular/cli/src/commands/cache/clean/cli.ts @@ -24,13 +24,17 @@ export class CacheCleanModule extends CommandModule implements CommandModuleImpl return localYargs.strict(); } - run(): Promise { - const { path } = getCacheConfig(this.context.workspace); + async run(): Promise { + const { path, localPath } = getCacheConfig(this.context.workspace); - return rm(path, { - force: true, - recursive: true, - maxRetries: 3, - }); + // In a Git worktree, `path` is shared with the main checkout and `localPath` holds the + // development server's Vite cache. Both are the same path outside of a worktree. + for (const cachePath of new Set([path, localPath])) { + await rm(cachePath, { + force: true, + recursive: true, + maxRetries: 3, + }); + } } } diff --git a/packages/angular/cli/src/commands/cache/clean/cli_spec.ts b/packages/angular/cli/src/commands/cache/clean/cli_spec.ts new file mode 100644 index 000000000000..8102be842224 --- /dev/null +++ b/packages/angular/cli/src/commands/cache/clean/cli_spec.ts @@ -0,0 +1,76 @@ +/** + * @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 assert from 'node:assert'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { CommandContext } from '../../../command-builder/command-module'; +import { AngularWorkspace } from '../../../utilities/config'; +import { CacheCleanModule } from './cli'; + +describe('CacheCleanModule', () => { + let tempDir: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempDir = await mkdtemp(join(baseTmpDir, 'angular-cli-cache-clean-spec-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function runClean(basePath: string): Promise { + const workspace = { + basePath, + extensions: {}, + projects: {} as unknown as workspaces.ProjectDefinitionCollection, + filePath: join(basePath, 'angular.json'), + getCli: () => undefined, + getProjectCli: () => undefined, + save: () => Promise.resolve(), + } as unknown as AngularWorkspace; + + return new CacheCleanModule({ workspace } as unknown as CommandContext).run(); + } + + it('should delete the cache of a standard repository', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + await mkdir(join(workspaceRoot, '.angular/cache/app'), { recursive: true }); + + await runClean(workspaceRoot); + + expect(existsSync(join(workspaceRoot, '.angular/cache'))).toBeFalse(); + }); + + it('should delete both the shared and the worktree-local cache in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + // The shared cache lives in the main checkout, while the development server keeps + // its Vite cache in the worktree itself. + await mkdir(join(mainRepoRoot, '.angular/cache/app'), { recursive: true }); + await mkdir(join(worktreeRoot, '.angular/cache/app/dev-server/vite'), { recursive: true }); + + await runClean(worktreeRoot); + + expect(existsSync(join(mainRepoRoot, '.angular/cache'))).toBeFalse(); + expect(existsSync(join(worktreeRoot, '.angular/cache'))).toBeFalse(); + }); +}); diff --git a/packages/angular/cli/src/commands/cache/utilities.ts b/packages/angular/cli/src/commands/cache/utilities.ts index cca7e5bfaf50..e9e479bfd186 100644 --- a/packages/angular/cli/src/commands/cache/utilities.ts +++ b/packages/angular/cli/src/commands/cache/utilities.ts @@ -69,13 +69,22 @@ function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): stri return resolve(workspaceRoot, cachePathSetting); } -export function getCacheConfig(workspace: AngularWorkspace | undefined): Required { +export interface CacheConfig extends Required { + /** + * The cache path resolved against the workspace itself. In a Git worktree, `path` is shared + * with the main checkout, while the development server keeps its Vite cache in this path. + */ + localPath: string; +} + +export function getCacheConfig(workspace: AngularWorkspace | undefined): CacheConfig { if (!workspace) { throw new Error(`Cannot retrieve cache configuration as workspace is not defined.`); } - const defaultSettings: Required = { + const defaultSettings: CacheConfig = { path: getCacheBasePath(workspace.basePath, '.angular/cache'), + localPath: resolve(workspace.basePath, '.angular/cache'), environment: Environment.Local, enabled: true, }; @@ -99,6 +108,7 @@ export function getCacheConfig(workspace: AngularWorkspace | undefined): Require return { path: getCacheBasePath(workspace.basePath, path), + localPath: resolve(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 index ad5e3ab4f69c..5a2ea55af3df 100644 --- a/packages/angular/cli/src/commands/cache/utilities_spec.ts +++ b/packages/angular/cli/src/commands/cache/utilities_spec.ts @@ -45,6 +45,7 @@ describe('CLI cache config utilities', () => { const config = getCacheConfig(mockWorkspace(workspaceRoot)); expect(config.path).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(config.localPath).toBe(config.path); }); it('should resolve default cache path relative to main repository root in a git worktree', async () => { @@ -67,6 +68,7 @@ describe('CLI cache config utilities', () => { const config = getCacheConfig(mockWorkspace(worktreeRoot)); expect(config.path).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(config.localPath).toBe(resolve(worktreeRoot, '.angular/cache')); }); it('should resolve default cache path relative to corresponding nested workspace in main repository for a git worktree', async () => { @@ -137,6 +139,7 @@ describe('CLI cache config utilities', () => { ); expect(config.path).toBe(resolve(mainRepoRoot, 'custom/cache-dir')); + expect(config.localPath).toBe(resolve(worktreeRoot, 'custom/cache-dir')); }); it('should resolve cache path relative to workspace basePath in a git submodule', async () => { From 74f6ef1f17005a0627010e116daeafb1007d9ed0 Mon Sep 17 00:00:00 2001 From: rk Date: Fri, 30 Oct 2026 14:04:20 +0500 Subject: [PATCH 2/2] fixup! fix(@angular/cli): also clean the worktree-local cache in a git worktree --- .../cli/src/commands/cache/clean/cli.ts | 16 ++++---- .../cli/src/commands/cache/clean/cli_spec.ts | 40 ++++++++++++++++--- .../cli/src/commands/cache/utilities_spec.ts | 3 ++ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/angular/cli/src/commands/cache/clean/cli.ts b/packages/angular/cli/src/commands/cache/clean/cli.ts index ac7e573db640..96a450bc3235 100644 --- a/packages/angular/cli/src/commands/cache/clean/cli.ts +++ b/packages/angular/cli/src/commands/cache/clean/cli.ts @@ -29,12 +29,14 @@ export class CacheCleanModule extends CommandModule implements CommandModuleImpl // In a Git worktree, `path` is shared with the main checkout and `localPath` holds the // development server's Vite cache. Both are the same path outside of a worktree. - for (const cachePath of new Set([path, localPath])) { - await rm(cachePath, { - force: true, - recursive: true, - maxRetries: 3, - }); - } + await Promise.all( + Array.from(new Set([path, localPath]), (cachePath) => + rm(cachePath, { + force: true, + recursive: true, + maxRetries: 3, + }), + ), + ); } } diff --git a/packages/angular/cli/src/commands/cache/clean/cli_spec.ts b/packages/angular/cli/src/commands/cache/clean/cli_spec.ts index 8102be842224..dc176ecaafac 100644 --- a/packages/angular/cli/src/commands/cache/clean/cli_spec.ts +++ b/packages/angular/cli/src/commands/cache/clean/cli_spec.ts @@ -6,13 +6,13 @@ * found in the LICENSE file at https://angular.dev/license */ -import { workspaces } from '@angular-devkit/core'; +import type { workspaces } from '@angular-devkit/core'; import assert from 'node:assert'; import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { CommandContext } from '../../../command-builder/command-module'; -import { AngularWorkspace } from '../../../utilities/config'; +import type { CommandContext } from '../../../command-builder/command-module'; +import type { AngularWorkspace } from '../../../utilities/config'; import { CacheCleanModule } from './cli'; describe('CacheCleanModule', () => { @@ -28,13 +28,13 @@ describe('CacheCleanModule', () => { await rm(tempDir, { recursive: true, force: true }); }); - function runClean(basePath: string): Promise { + function runClean(basePath: string, cliExtension?: unknown): Promise { const workspace = { basePath, - extensions: {}, + extensions: cliExtension ? { cli: cliExtension } : {}, projects: {} as unknown as workspaces.ProjectDefinitionCollection, filePath: join(basePath, 'angular.json'), - getCli: () => undefined, + getCli: () => cliExtension, getProjectCli: () => undefined, save: () => Promise.resolve(), } as unknown as AngularWorkspace; @@ -73,4 +73,32 @@ describe('CacheCleanModule', () => { expect(existsSync(join(mainRepoRoot, '.angular/cache'))).toBeFalse(); expect(existsSync(join(worktreeRoot, '.angular/cache'))).toBeFalse(); }); + + it('should succeed when cache directories do not exist', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + await expectAsync(runClean(workspaceRoot)).toBeResolved(); + }); + + it('should delete both the shared and the worktree-local cache with a custom relative path', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const customCachePath = 'custom-cache-dir'; + await mkdir(join(mainRepoRoot, customCachePath, 'app'), { recursive: true }); + await mkdir(join(worktreeRoot, customCachePath, 'app/dev-server/vite'), { recursive: true }); + + await runClean(worktreeRoot, { cache: { path: customCachePath } }); + + expect(existsSync(join(mainRepoRoot, customCachePath))).toBeFalse(); + expect(existsSync(join(worktreeRoot, customCachePath))).toBeFalse(); + }); }); diff --git a/packages/angular/cli/src/commands/cache/utilities_spec.ts b/packages/angular/cli/src/commands/cache/utilities_spec.ts index 5a2ea55af3df..8e2b2aaf3191 100644 --- a/packages/angular/cli/src/commands/cache/utilities_spec.ts +++ b/packages/angular/cli/src/commands/cache/utilities_spec.ts @@ -94,6 +94,7 @@ describe('CLI cache config utilities', () => { const config = getCacheConfig(mockWorkspace(worktreeWorkspaceRoot)); expect(config.path).toBe(resolve(mainWorkspaceRoot, '.angular/cache')); + expect(config.localPath).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); }); it('should fall back to worktree workspace basePath when nested workspace does not exist in main repository', async () => { @@ -115,6 +116,7 @@ describe('CLI cache config utilities', () => { const config = getCacheConfig(mockWorkspace(worktreeWorkspaceRoot)); expect(config.path).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); + expect(config.localPath).toBe(config.path); }); it('should resolve custom relative cache path relative to main repository root in a git worktree', async () => { @@ -158,5 +160,6 @@ describe('CLI cache config utilities', () => { const config = getCacheConfig(mockWorkspace(submoduleRoot)); expect(config.path).toBe(resolve(submoduleRoot, '.angular/cache')); + expect(config.localPath).toBe(config.path); }); });