Run generated worktree cleanup during automatic dev maintenance (#33277)

This commit is contained in:
Neil
2026-10-24 15:09:18 -07:00
committed by GitHub
parent 8c95a9bb5d
commit bfbe9637e9
6 changed files with 58 additions and 57 deletions
+2 -2
View File
@@ -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)
@@ -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))
)
@@ -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')
}
)
+2 -4
View File
@@ -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) {
@@ -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) {
+12 -10
View File
@@ -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.