From bfbe9637e991d441a9eb741dc143ce8eac1a4b56 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:09:18 -0700 Subject: [PATCH] Run generated worktree cleanup during automatic dev maintenance (#33277) --- config/scripts/reclaim-worktree-command.mjs | 4 +- .../reclaim-worktree-generated-files.mjs | 4 - .../reclaim-worktree-generated-files.test.mjs | 73 ++++++++++--------- config/scripts/reclaim-worktree-space.mjs | 6 +- .../scripts/reclaim-worktree-space.test.mjs | 6 +- docs/readme/worktree-storage-maintenance.md | 22 +++--- 6 files changed, 58 insertions(+), 57 deletions(-) diff --git a/config/scripts/reclaim-worktree-command.mjs b/config/scripts/reclaim-worktree-command.mjs index 78e1d90d54f..8c34c2ae185 100644 --- a/config/scripts/reclaim-worktree-command.mjs +++ b/config/scripts/reclaim-worktree-command.mjs @@ -60,7 +60,7 @@ export async function runReclaimCommand( 'Add --tests-stopped to include old test caches after stopping cross-version tests.\n' ) output.write( - 'Manual cleanup includes old ignored dependencies and build output in sibling worktrees. Stop their apps, agents, installs, builds and tests before applying. Shared pnpm stores are retained.\n' + 'Cleanup includes old ignored dependencies and build output in sibling worktrees, including during automatic dev cleanup. Shared pnpm stores are retained.\n' ) return } @@ -71,7 +71,7 @@ export async function runReclaimCommand( } if (!options.automatic) { output.write( - 'Manual cleanup includes old dependency installs and build output in sibling worktrees. Stop their apps, agents, installs, builds and tests before applying.\n' + 'Cleanup includes old dependency installs and build output in sibling worktrees. Shared pnpm stores are retained.\n' ) } await reclaim(options) diff --git a/config/scripts/reclaim-worktree-generated-files.mjs b/config/scripts/reclaim-worktree-generated-files.mjs index 9fc122741e1..555adc26948 100644 --- a/config/scripts/reclaim-worktree-generated-files.mjs +++ b/config/scripts/reclaim-worktree-generated-files.mjs @@ -137,7 +137,6 @@ async function makeDirectoriesWritable(root) { export async function reclaimWorktreeGeneratedFiles({ repoRoot = process.cwd(), apply = false, - automatic = false, generatedMinAgeDays = 30, excludeWorktree, report = console.log @@ -145,9 +144,6 @@ export async function reclaimWorktreeGeneratedFiles({ if (!Number.isFinite(generatedMinAgeDays) || generatedMinAgeDays < 0) { throw new Error('--generated-min-age-days requires a non-negative number') } - if (automatic) { - throw new Error('Generated dependency cleanup is only available in the manual reclaim command') - } const protectedPaths = await Promise.all( [repoRoot, process.cwd(), ...(excludeWorktree ? [excludeWorktree] : [])].map((p) => realpath(p)) ) diff --git a/config/scripts/reclaim-worktree-generated-files.test.mjs b/config/scripts/reclaim-worktree-generated-files.test.mjs index 295cb391c73..933b98028e8 100644 --- a/config/scripts/reclaim-worktree-generated-files.test.mjs +++ b/config/scripts/reclaim-worktree-generated-files.test.mjs @@ -132,15 +132,6 @@ it('retains recent directories, tracked output, and ignored files outside recogn } }) -it('never permits automatic dependency cleanup', async () => { - const f = await fixture() - const file = await generated(f.sibling) - await expect( - reclaimWorktreeGeneratedFiles({ repoRoot: f.repoRoot, apply: true, automatic: true }) - ).rejects.toThrow('manual reclaim command') - expect(await readFile(file, 'utf8')).toBe('generated bytes') -}) - it('protects tracked files under package names containing Git pathspec metacharacters', async () => { const f = await fixture() const root = join(f.sibling, 'native', 'addon[1]') @@ -304,28 +295,42 @@ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( } ) -it('includes generated cleanup in the default manual CLI preview and apply', async () => { - const f = await fixture() - const file = await generated(f.sibling, 'mobile/node_modules') - const run = (args) => - runProcess({ - program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath, - args: [ - resolve(import.meta.dirname, 'reclaim-worktree-space.mjs'), - '--repo', - f.repoRoot, - ...args - ], - cwd: tmpdir(), - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, - timeoutMs: 30_000, - terminationBarrier: true - }) - const preview = await run([]) - expect(preview.code, preview.stderr).toBe(0) - expect(preview.stdout).toContain('would remove generated directory') - expect(await readFile(file, 'utf8')).toBe('generated bytes') - const applied = await run(['--apply']) - expect(applied.code, applied.stderr).toBe(0) - await expect(lstat(file)).rejects.toMatchObject({ code: 'ENOENT' }) -}) +it.each(['manual', 'automatic'])( + 'includes generated cleanup in %s CLI preview and apply', + async (mode) => { + const f = await fixture() + const protectedFile = await generated(f.repoRoot) + const file = await generated(f.sibling, 'mobile/node_modules') + const store = join(f.root, 'global-pnpm-store') + await mkdir(store) + const shared = join(store, 'package.js') + await writeFile(shared, 'shared package') + await link(shared, join(dirname(file), 'package.js')) + await symlink(store, join(dirname(file), 'shared-package'), 'junction') + await utimes(dirname(file), new Date(0), new Date(0)) + const run = (args) => + runProcess({ + program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath, + args: [ + resolve(import.meta.dirname, 'reclaim-worktree-space.mjs'), + '--repo', + f.repoRoot, + ...(mode === 'automatic' ? ['--automatic'] : []), + ...args + ], + cwd: tmpdir(), + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30_000, + terminationBarrier: true + }) + const preview = await run([]) + expect(preview.code, preview.stderr).toBe(0) + expect(preview.stdout).toContain('would remove generated directory') + expect(await readFile(file, 'utf8')).toBe('generated bytes') + const applied = await run(['--apply']) + expect(applied.code, applied.stderr).toBe(0) + await expect(lstat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(protectedFile, 'utf8')).toBe('generated bytes') + expect(await readFile(shared, 'utf8')).toBe('shared package') + } +) diff --git a/config/scripts/reclaim-worktree-space.mjs b/config/scripts/reclaim-worktree-space.mjs index 5643428b0b0..149dd5047a0 100644 --- a/config/scripts/reclaim-worktree-space.mjs +++ b/config/scripts/reclaim-worktree-space.mjs @@ -50,14 +50,12 @@ export async function reclaimWorktreeSpace({ const scripts = [ 'reclaim-electron-dists.mjs', 'reclaim-dev-electron-bundles.mjs', - 'reclaim-vite-caches.mjs' + 'reclaim-vite-caches.mjs', + 'reclaim-worktree-generated-files.mjs' ] if (!automatic && testsStopped) { scripts.unshift('reclaim-release-test-caches.mjs') } - if (!automatic) { - scripts.push('reclaim-worktree-generated-files.mjs') - } for (const script of scripts) { const args = [resolve(import.meta.dirname, script), '--repo', repoRoot] if (apply) { diff --git a/config/scripts/reclaim-worktree-space.test.mjs b/config/scripts/reclaim-worktree-space.test.mjs index 21d9f2cfa9a..c2d7f39724d 100644 --- a/config/scripts/reclaim-worktree-space.test.mjs +++ b/config/scripts/reclaim-worktree-space.test.mjs @@ -52,7 +52,7 @@ it('applies Electron, Vite and generated cleanup while retaining unacknowledged ) }) -it('leaves historical caches out of automatic invocations', async () => { +it('includes generated cleanup and leaves historical test caches out of automatic invocations', async () => { const options = { ...(await fixture()), automatic: true, @@ -61,11 +61,11 @@ it('leaves historical caches out of automatic invocations', async () => { excludeWorktree: '/dev-worktree' } await reclaimWorktreeSpace(options) - expect(options.execute).toHaveBeenCalledTimes(3) + expect(options.execute).toHaveBeenCalledTimes(4) expect(options.execute.mock.calls.flatMap(([spec]) => spec.args).join(' ')).not.toContain( 'reclaim-release-test-caches' ) - expect(options.execute.mock.calls.flatMap(([spec]) => spec.args).join(' ')).not.toContain( + expect(options.execute.mock.calls.flatMap(([spec]) => spec.args).join(' ')).toContain( 'reclaim-worktree-generated-files' ) for (const [spec] of options.execute.mock.calls) { diff --git a/docs/readme/worktree-storage-maintenance.md b/docs/readme/worktree-storage-maintenance.md index 0834c488af8..c5248aadb51 100644 --- a/docs/readme/worktree-storage-maintenance.md +++ b/docs/readme/worktree-storage-maintenance.md @@ -23,7 +23,7 @@ It shares complete Electron distributions through the repository cache and remov `out/electron-dev` bundles, which development rebuilds on demand. It also removes tracked `.cache/vite` dependency caches unused for 30 days. With `--tests-stopped`, it also removes stamped historical test checkouts and shared seeds older than 30 days; tests regenerate them when needed. It leaves source files, -worktree registrations and branches intact. The manual command also includes old dependency +worktree registrations and branches intact. Manual and automatic cleanup also include old dependency installs and build output as described below. Reported sizes are logical file data; APFS clones and hardlinks mean actual disk savings can be smaller. @@ -47,22 +47,22 @@ anything on SSH hosts. Run maintenance on the execution host when maintaining re ## Old dependency installs and build output -Old generated files are included in the normal manual command. Stop apps, agents, package -installs, builds and tests in the repository's sibling worktrees before applying: +Old generated files are included in the normal manual command and automatic dev cleanup: ```sh pnpm reclaim-space pnpm reclaim-space --apply ``` -This manual pass retains the selected repository worktree, the invoking worktree, and any +This pass retains the selected repository worktree, the invoking worktree, and any `--exclude-worktree` directory. It removes ignored, completely untracked `node_modules`, `out`, `dist`, and `build` directories older than 30 days. Recognized package roots are the repository root, `mobile`, `native/*`, `src/packages/*`, `cloud/apps/*`, and `cloud/packages/*`, each with a regular `package.json`. It does not search arbitrary folders or the user's home directory. Use `--generated-min-age-days 14` to shorten retention, or `0` to include recent generated -directories. Age is the directory modification time, not proof that an install is unused; -stop sibling work before applying even when its dependencies were installed long ago. +directories. Age is the directory modification time, not proof that an install is unused. +Old sibling installs and output can be removed while sibling work is running; reinstall +dependencies or rebuild in that worktree if needed. Shared pnpm package data is retained. Preview leaves files in place. Apply rechecks Git tracking, parent paths, directory identity and modification time before deleting. Symlinked directories, symlinked package parents, nested @@ -72,7 +72,7 @@ Source files, local changes, worktree registrations, branches, and shared pnpm s intact. Generated files are recreated by installing dependencies or rebuilding in that worktree. APFS clones and hardlinks mean directory sizes can greatly overstate the actual space reclaimed. -The automatic worker never runs this pass. Machine-wide caches, Docker images/volumes, downloads and simulator data remain +Machine-wide caches, Docker images/volumes, downloads and simulator data remain outside this repository-scoped command. ## Keeping installs shared @@ -82,10 +82,12 @@ without waiting for an Electron upgrade. The manual command migrates older insta Read-only Electron package directories are retained and reported as skipped. Cache publication or copying failures are also reported as skipped rather than silently omitted. -`pnpm dev` and `pnpm dev-stable-name` start an automatic repository-wide Electron and Vite cleanup worker +`pnpm dev` and `pnpm dev-stable-name` start an automatic repository-wide cleanup worker for +Electron, Vite caches, and old dependency installs and generated output in the background after the dev process starts. It never prompts or waits before launching the app. -The worktree being launched is excluded from Electron sharing, dev bundle removal, and Vite cleanup; active -sibling apps and recent unfinished builds remain protected by the existing bundle checks. +The worktree being launched is excluded from every cleanup pass. Existing bundle checks protect +active sibling Electron bundles and recent unfinished bundles from the bundle-specific pass; +the generated-directory pass uses its 30-day age cutoff and filesystem/Git protections. Background sweep output is suppressed; worker start or exit failures are reported without stopping dev. Set `ORCA_SKIP_DEV_STORAGE_RECLAIM=1` to opt out. The worker finishes independently if the dev app exits, and each cleanup operation has a ten-minute limit.