From a5a5a22c600f491af0adcc09d16d93bd479a2f13 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:19:39 -0700 Subject: [PATCH] feat: reclaim stale dependencies and build output across worktrees (#32331) * feat: reclaim stale generated files from sibling worktrees * Include generated worktree cleanup in manual reclaim by default * Preserve nested repositories inside generated output --- config/scripts/reclaim-worktree-command.mjs | 22 +- .../scripts/reclaim-worktree-command.test.mjs | 3 + .../reclaim-worktree-generated-files.mjs | 244 +++++++++++++ .../reclaim-worktree-generated-files.test.mjs | 330 ++++++++++++++++++ config/scripts/reclaim-worktree-space.mjs | 10 + .../scripts/reclaim-worktree-space.test.mjs | 15 +- docs/readme/worktree-storage-maintenance.md | 33 +- 7 files changed, 643 insertions(+), 14 deletions(-) create mode 100644 config/scripts/reclaim-worktree-generated-files.mjs create mode 100644 config/scripts/reclaim-worktree-generated-files.test.mjs diff --git a/config/scripts/reclaim-worktree-command.mjs b/config/scripts/reclaim-worktree-command.mjs index ed33d1f700c..78e1d90d54f 100644 --- a/config/scripts/reclaim-worktree-command.mjs +++ b/config/scripts/reclaim-worktree-command.mjs @@ -17,6 +17,7 @@ export function parseReclaimArguments(args) { } else if ( argument === '--repo' || argument === '--release-cache-min-age-days' || + argument === '--generated-min-age-days' || argument === '--exclude-worktree' ) { const value = args[++index] @@ -28,12 +29,11 @@ export function parseReclaimArguments(args) { } else if (argument === '--exclude-worktree') { options.excludeWorktree = resolve(value) } else { - options.releaseCacheMinAgeDays = Number(value) - if ( - !Number.isFinite(options.releaseCacheMinAgeDays) || - options.releaseCacheMinAgeDays < 0 - ) { - throw new Error('--release-cache-min-age-days requires a non-negative number') + const key = + argument === '--generated-min-age-days' ? 'generatedMinAgeDays' : 'releaseCacheMinAgeDays' + options[key] = Number(value) + if (!Number.isFinite(options[key]) || options[key] < 0) { + throw new Error(`${argument} requires a non-negative number`) } } } else { @@ -50,7 +50,7 @@ export async function runReclaimCommand( const options = parseReclaimArguments(args) if (options.help) { output.write( - 'Usage: pnpm reclaim-space [--apply] [--repo DIRECTORY] [--tests-stopped] [--release-cache-min-age-days 30]\n' + 'Usage: pnpm reclaim-space [--apply] [--repo DIRECTORY] [--tests-stopped] [--release-cache-min-age-days 30] [--generated-min-age-days 30]\n' ) output.write( 'Shares Electron installs and removes idle dev bundles and Vite caches unused for 30 days across local Git worktrees.\n' @@ -59,6 +59,9 @@ export async function runReclaimCommand( output.write( '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' + ) return } if (!options.testsStopped && !options.automatic) { @@ -66,6 +69,11 @@ export async function runReclaimCommand( 'Historical test caches retained. Add --tests-stopped after stopping cross-version tests to include them.\n' ) } + 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' + ) + } await reclaim(options) if (options.apply || options.automatic || !input.isTTY || !output.isTTY) { return diff --git a/config/scripts/reclaim-worktree-command.test.mjs b/config/scripts/reclaim-worktree-command.test.mjs index 88b74062ccc..6bffbb6558d 100644 --- a/config/scripts/reclaim-worktree-command.test.mjs +++ b/config/scripts/reclaim-worktree-command.test.mjs @@ -74,6 +74,9 @@ it.each([ ['--release-cache-min-age-days'], ['--release-cache-min-age-days', 'NaN'], ['--release-cache-min-age-days', '-1'], + ['--generated-min-age-days'], + ['--generated-min-age-days', 'NaN'], + ['--generated-min-age-days', '-1'], ['--unknown'] ])('rejects invalid flags before any sweep: %j', (...args) => { expect(() => parseReclaimArguments(args)).toThrow() diff --git a/config/scripts/reclaim-worktree-generated-files.mjs b/config/scripts/reclaim-worktree-generated-files.mjs new file mode 100644 index 00000000000..9fc122741e1 --- /dev/null +++ b/config/scripts/reclaim-worktree-generated-files.mjs @@ -0,0 +1,244 @@ +import { chmod, lstat, readdir, realpath, rm } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { runProcess } from '@orca/process-host' +import { localRepositoryWorktrees } from './local-repository-worktrees.mjs' +import { parseReclaimArguments } from './reclaim-worktree-command.mjs' + +const OUTPUTS = ['node_modules', 'out', 'dist', 'build'] +const PACKAGE_ROOTS = [ + [], + ['mobile'], + ['native', '*'], + ['src', 'packages', '*'], + ['cloud', 'apps', '*'], + ['cloud', 'packages', '*'] +] + +async function info(path) { + try { + return await lstat(path) + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + return null + } + throw error + } +} + +async function descend(root, parts) { + let path = root + for (const part of parts) { + path = join(path, part) + if (!(await info(path))?.isDirectory()) { + return null + } + if (await info(join(path, '.git'))) { + return null + } + } + return path +} + +async function packageRoots(worktree) { + const roots = new Set() + for (const pattern of PACKAGE_ROOTS) { + const wildcard = pattern.at(-1) === '*' + const parent = await descend(worktree, wildcard ? pattern.slice(0, -1) : pattern) + if (!parent) { + continue + } + const paths = wildcard + ? (await readdir(parent, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => [...pattern.slice(0, -1), entry.name]) + : [pattern] + for (const parts of paths) { + const path = await descend(worktree, parts) + if (path && (await info(join(path, 'package.json')))?.isFile()) { + roots.add(path) + } + } + } + return roots +} + +async function ignoredAndUntracked(worktree, path) { + const run = (args) => + runProcess({ + program: 'git', + args: ['-C', worktree, ...args], + timeoutMs: 30_000, + maxOutputBytes: 4096, + killOnOutputLimit: true, + terminationBarrier: true + }) + const target = relative(worktree, path).split(sep).join('/') + const tracked = await run(['--literal-pathspecs', 'ls-files', '--cached', '-z', '--', target]) + if (tracked.code !== 0 || tracked.timedOut || tracked.outputTruncated || tracked.stdout) { + return false + } + const ignored = await run(['check-ignore', '-q', '--', `${target}/`]) + return ignored.code === 0 && !ignored.timedOut && !ignored.outputTruncated +} + +function contains(root, path) { + const part = relative(root, path) + return part === '' || (!isAbsolute(part) && part !== '..' && !part.startsWith(`..${sep}`)) +} + +function sameDirectory(first, second) { + return second?.isDirectory() && first.dev === second.dev && first.ino === second.ino +} + +async function containsNestedRepository(root) { + const pending = [root] + while (pending.length) { + const path = pending.pop() + if (!(await info(path))?.isDirectory()) { + return true + } + const entries = await readdir(path, { withFileTypes: true }) + const names = new Set(entries.map((entry) => entry.name)) + if ( + names.has('.git') || + (names.has('HEAD') && names.has('objects') && (names.has('refs') || names.has('config'))) + ) { + return true + } + for (const entry of entries) { + if (entry.isDirectory()) { + pending.push(join(path, entry.name)) + } + } + } + return false +} + +async function makeDirectoriesWritable(root) { + const pending = [root] + while (pending.length) { + const path = pending.pop() + const current = await info(path) + if (!current?.isDirectory()) { + continue + } + // Package files can be hardlinked into a shared store; only change directory permissions. + if (process.getuid && current.uid === process.getuid() && !(current.mode & 0o200)) { + await chmod(path, current.mode | 0o200) + } + for (const entry of await readdir(path, { withFileTypes: true })) { + if (entry.isDirectory()) { + pending.push(join(path, entry.name)) + } + } + } +} + +export async function reclaimWorktreeGeneratedFiles({ + repoRoot = process.cwd(), + apply = false, + automatic = false, + generatedMinAgeDays = 30, + excludeWorktree, + report = console.log +} = {}) { + 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)) + ) + const { worktrees } = await localRepositoryWorktrees(repoRoot) + const cutoff = Date.now() - generatedMinAgeDays * 86_400_000 + const candidates = [] + const result = { entries: 0, protected: 0, failed: 0 } + for (const root of new Set(worktrees)) { + if (!(await info(root))?.isDirectory()) { + continue + } + const worktree = await realpath(root) + if (protectedPaths.some((path) => contains(worktree, path))) { + continue + } + for (const packageRoot of await packageRoots(worktree)) { + for (const name of OUTPUTS) { + const path = join(packageRoot, name) + const original = await info(path) + if (!original?.isDirectory() || original.mtimeMs > cutoff) { + continue + } + if (await ignoredAndUntracked(worktree, path)) { + candidates.push({ worktree, path, original }) + } + } + } + } + + async function processCandidate({ worktree, path, original }) { + try { + const parts = relative(worktree, path).split(sep) + if (!(await descend(worktree, parts))) { + result.protected += 1 + return + } + const current = await info(path) + if ( + !sameDirectory(original, current) || + current.mtimeMs > cutoff || + !(await ignoredAndUntracked(worktree, path)) || + (await containsNestedRepository(path)) + ) { + result.protected += 1 + return + } + if (apply) { + const parent = await descend(worktree, parts) + const ready = await info(path) + if (!parent || !sameDirectory(original, ready) || ready.mtimeMs > cutoff) { + result.protected += 1 + return + } + try { + await rm(path, { recursive: true, force: true }) + } catch (error) { + if ( + !['EACCES', 'EPERM'].includes(error.code) || + !(await descend(worktree, parts)) || + !sameDirectory(original, await info(path)) + ) { + throw error + } + await makeDirectoriesWritable(path) + await rm(path, { recursive: true, force: true }) + } + } + result.entries += 1 + report(`${apply ? 'removed' : 'would remove'} generated directory ${JSON.stringify(path)}`) + } catch (error) { + result.failed += 1 + report(`failed to remove ${JSON.stringify(path)}: ${error.message}`) + } + } + // Bound filesystem pressure without serializing hundreds of large dependency installs. + for (let index = 0; index < candidates.length; index += 4) { + await Promise.all(candidates.slice(index, index + 4).map(processCandidate)) + } + report( + `${apply ? 'Removed' : 'Would remove'} ${result.entries} generated directory(s); ${result.protected} changed; ${result.failed} failed. Shared pnpm stores and source files retained.` + ) + if (result.failed) { + throw new Error('Some generated directories could not be removed; see failures above') + } + return result +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const options = parseReclaimArguments(process.argv.slice(2)) + reclaimWorktreeGeneratedFiles(options).catch((error) => { + console.error(error.message) + process.exitCode = 1 + }) +} diff --git a/config/scripts/reclaim-worktree-generated-files.test.mjs b/config/scripts/reclaim-worktree-generated-files.test.mjs new file mode 100644 index 00000000000..59d1801825b --- /dev/null +++ b/config/scripts/reclaim-worktree-generated-files.test.mjs @@ -0,0 +1,330 @@ +import { + chmod, + link, + lstat, + mkdir, + mkdtemp, + readFile, + rename, + rm, + symlink, + utimes, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import * as processHost from '@orca/process-host' +import { runProcess } from '@orca/process-host' +import { reclaimWorktreeGeneratedFiles } from './reclaim-worktree-generated-files.mjs' + +const roots = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-generated-reclaim-')) + roots.push(root) + const repoRoot = join(root, 'main') + const sibling = join(root, 'sibling') + await mkdir(repoRoot) + const git = async (...args) => { + const result = await runProcess({ + program: 'git', + args: [ + '-C', + repoRoot, + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.invalid', + '-c', + 'commit.gpgsign=false', + ...args + ], + env: { + ...process.env, + GIT_CONFIG_GLOBAL: join(root, 'no-global-config'), + GIT_CONFIG_NOSYSTEM: '1' + }, + timeoutMs: 10_000, + terminationBarrier: true + }) + expect(result.code, result.stderr).toBe(0) + } + await git('init', '--quiet') + await writeFile(join(repoRoot, '.gitignore'), '**/node_modules/\n**/out/\n**/dist/\n**/build/\n') + for (const packagePath of ['', 'mobile', 'native/addon', 'src/packages/example']) { + const path = join(repoRoot, packagePath) + await mkdir(path, { recursive: true }) + await writeFile(join(path, 'package.json'), '{"private":true}') + } + await writeFile(join(repoRoot, 'source.js'), 'original source') + await git('add', '.') + await git('commit', '--quiet', '-m', 'fixture') + await git('worktree', 'add', '--quiet', '--detach', sibling) + return { root, repoRoot, sibling, git } +} + +async function generated(root, path = 'node_modules', days = 40) { + const directory = join(root, path) + await mkdir(directory, { recursive: true }) + const file = join(directory, 'generated.txt') + await writeFile(file, 'generated bytes') + const time = new Date(Date.now() - days * 86_400_000) + await utimes(directory, time, time) + return file +} + +it('previews and removes old root/nested generated files while preserving dirty source and shared pnpm data', async () => { + const f = await fixture() + const protectedFile = await generated(f.repoRoot) + const paths = [ + 'node_modules', + 'mobile/node_modules', + 'native/addon/build', + 'src/packages/example/dist', + 'out' + ] + const files = await Promise.all(paths.map((path) => generated(f.sibling, path))) + const store = join(f.root, 'shared-pnpm-store') + await mkdir(store) + await writeFile(join(store, 'package.js'), 'shared package') + await symlink(store, join(f.sibling, 'node_modules', 'shared-package'), 'junction') + await utimes(join(f.sibling, 'node_modules'), new Date(0), new Date(0)) + await writeFile(join(f.sibling, 'source.js'), 'uncommitted source') + await mkdir(join(f.sibling, '.context')) + await writeFile(join(f.sibling, '.context', 'report.md'), 'keep this report') + const options = { repoRoot: f.repoRoot, report: () => {} } + const preview = await reclaimWorktreeGeneratedFiles(options) + expect(preview.entries).toBe(5) + for (const file of files) { + expect(await readFile(file, 'utf8')).toBe('generated bytes') + } + await reclaimWorktreeGeneratedFiles({ ...options, apply: true }) + for (const file of files) { + await expect(lstat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + } + expect(await readFile(protectedFile, 'utf8')).toBe('generated bytes') + expect(await readFile(join(store, 'package.js'), 'utf8')).toBe('shared package') + expect(await readFile(join(f.sibling, 'source.js'), 'utf8')).toBe('uncommitted source') + expect(await readFile(join(f.sibling, '.context', 'report.md'), 'utf8')).toBe('keep this report') + expect((await lstat(join(f.sibling, '.git'))).isFile()).toBe(true) +}) + +it('retains recent directories, tracked output, and ignored files outside recognized package roots', async () => { + const f = await fixture() + const recent = await generated(f.sibling, 'node_modules', 1) + const tracked = await generated(f.sibling, 'dist') + const unknown = await generated(f.sibling, 'personal/build') + await f.git('-C', f.sibling, 'add', '-f', 'dist/generated.txt') + await reclaimWorktreeGeneratedFiles({ + repoRoot: f.repoRoot, + apply: true, + report: () => {} + }) + for (const file of [recent, tracked, unknown]) { + expect(await readFile(file, 'utf8')).toBe('generated bytes') + } +}) + +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]') + const file = await generated(root, 'build') + await writeFile(join(root, 'package.json'), '{}') + await f.git( + '--literal-pathspecs', + '-C', + f.sibling, + 'add', + '-f', + 'native/addon[1]/build/generated.txt' + ) + await reclaimWorktreeGeneratedFiles({ + repoRoot: f.repoRoot, + apply: true, + report: () => {} + }) + expect(await readFile(file, 'utf8')).toBe('generated bytes') +}) + +it('skips symlinked targets and package ancestors, and nested repositories', async () => { + const f = await fixture() + const external = join(f.root, 'external') + const outside = await generated(external) + await writeFile(join(external, 'package.json'), '{}') + await symlink(external, join(f.sibling, 'node_modules'), 'junction') + await rm(join(f.sibling, 'native', 'addon'), { recursive: true }) + await symlink(external, join(f.sibling, 'native', 'addon'), 'junction') + const nested = await generated(f.sibling, 'mobile/node_modules') + await writeFile(join(f.sibling, 'mobile', '.git'), 'gitdir: elsewhere') + const preview = await reclaimWorktreeGeneratedFiles({ repoRoot: f.repoRoot, report: () => {} }) + expect(preview.entries).toBe(0) + expect(await readFile(outside, 'utf8')).toBe('generated bytes') + expect(await readFile(nested, 'utf8')).toBe('generated bytes') +}) + +it.each(['directory', 'file'])( + 'preserves uncommitted source in a nested repository with a .git %s beneath output', + async (kind) => { + const f = await fixture() + const file = await generated(f.sibling, 'out') + const nested = join(dirname(file), 'local-checkout') + await mkdir(nested) + await (kind === 'directory' + ? f.git('-C', nested, 'init', '--quiet') + : writeFile(join(nested, '.git'), 'gitdir: elsewhere')) + const source = join(nested, 'source.js') + await writeFile(source, 'uncommitted source') + await utimes(dirname(file), new Date(0), new Date(0)) + for (const apply of [false, true]) { + const result = await reclaimWorktreeGeneratedFiles({ + repoRoot: f.repoRoot, + apply, + report: () => {} + }) + expect(result.entries).toBe(0) + expect(result.protected).toBe(1) + expect(await readFile(source, 'utf8')).toBe('uncommitted source') + expect(await readFile(file, 'utf8')).toBe('generated bytes') + } + } +) + +it('preserves bare repositories and their refs beneath ignored output', async () => { + const f = await fixture() + const file = await generated(f.sibling, 'out') + const nested = join(dirname(file), 'saved.git') + await f.git('clone', '--quiet', '--bare', f.repoRoot, nested) + const head = await readFile(join(nested, 'HEAD'), 'utf8') + await utimes(dirname(file), new Date(0), new Date(0)) + const result = await reclaimWorktreeGeneratedFiles({ + repoRoot: f.repoRoot, + apply: true, + report: () => {} + }) + expect(result.entries).toBe(0) + expect(result.protected).toBe(1) + expect(await readFile(join(nested, 'HEAD'), 'utf8')).toBe(head) + expect((await lstat(join(nested, 'objects'))).isDirectory()).toBe(true) +}) + +it('resolves worktree exclusions through filesystem aliases', async () => { + const f = await fixture() + const file = await generated(f.sibling) + const alias = join(f.root, 'alias') + await symlink(f.sibling, alias, 'junction') + await reclaimWorktreeGeneratedFiles({ + repoRoot: f.repoRoot, + excludeWorktree: alias, + apply: true, + report: () => {} + }) + expect(await readFile(file, 'utf8')).toBe('generated bytes') +}) + +it('retains a replacement directory introduced while the final Git check is running', async () => { + const f = await fixture() + const file = await generated(f.sibling) + const target = dirname(file) + const saved = join(f.sibling, 'saved-original') + const execute = processHost.runProcess + let checks = 0 + vi.spyOn(processHost, 'runProcess').mockImplementation(async (spec) => { + const result = await execute(spec) + if ( + spec.args.includes('check-ignore') && + spec.args.at(-1) === 'node_modules/' && + ++checks === 2 + ) { + await rename(target, saved) + await generated(f.sibling) + } + return result + }) + const result = await reclaimWorktreeGeneratedFiles({ + repoRoot: f.repoRoot, + apply: true, + report: () => {} + }) + expect(checks).toBe(2) + expect(result.protected).toBe(1) + expect(await readFile(file, 'utf8')).toBe('generated bytes') + expect(await readFile(join(saved, 'generated.txt'), 'utf8')).toBe('generated bytes') +}) + +it('supports a retention override and rejects invalid ages', async () => { + const f = await fixture() + const file = await generated(f.sibling, 'mobile/node_modules', 2) + await reclaimWorktreeGeneratedFiles({ + repoRoot: f.repoRoot, + apply: true, + generatedMinAgeDays: 1, + report: () => {} + }) + await expect(lstat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect( + reclaimWorktreeGeneratedFiles({ repoRoot: f.repoRoot, generatedMinAgeDays: Number.NaN }) + ).rejects.toThrow('--generated-min-age-days') +}) + +it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'reclaims read-only generated directories without chmodding hardlinked package files', + async () => { + const f = await fixture() + const shared = join(f.root, 'shared-package.js') + await writeFile(shared, 'shared package', { mode: 0o444 }) + const file = await generated(f.sibling) + await link(shared, join(dirname(file), 'package.js')) + await utimes(dirname(file), new Date(0), new Date(0)) + await chmod(dirname(file), 0o555) + await reclaimWorktreeGeneratedFiles({ + repoRoot: f.repoRoot, + apply: true, + report: () => {} + }) + await expect(lstat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(shared, 'utf8')).toBe('shared package') + expect((await lstat(shared)).mode & 0o777).toBe(0o444) + } +) + +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' }) +}) diff --git a/config/scripts/reclaim-worktree-space.mjs b/config/scripts/reclaim-worktree-space.mjs index d46482c50f1..5643428b0b0 100644 --- a/config/scripts/reclaim-worktree-space.mjs +++ b/config/scripts/reclaim-worktree-space.mjs @@ -12,12 +12,16 @@ export async function reclaimWorktreeSpace({ acquireLock = lock, commonDirectory, testsStopped = false, + generatedMinAgeDays = 30, excludeWorktree, releaseCacheMinAgeDays = 30 } = {}) { if (!Number.isFinite(releaseCacheMinAgeDays) || releaseCacheMinAgeDays < 0) { throw new Error('--release-cache-min-age-days requires a non-negative number') } + if (!Number.isFinite(generatedMinAgeDays) || generatedMinAgeDays < 0) { + throw new Error('--generated-min-age-days requires a non-negative number') + } if (!commonDirectory) { const result = await runProcess({ program: 'git', @@ -51,6 +55,9 @@ export async function reclaimWorktreeSpace({ 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) { @@ -66,6 +73,9 @@ export async function reclaimWorktreeSpace({ args.push('--tests-stopped') } } + if (script === 'reclaim-worktree-generated-files.mjs') { + args.push('--generated-min-age-days', String(generatedMinAgeDays)) + } const result = await execute({ program: process.execPath, args, diff --git a/config/scripts/reclaim-worktree-space.test.mjs b/config/scripts/reclaim-worktree-space.test.mjs index c398ef9f0e8..3d4830d746e 100644 --- a/config/scripts/reclaim-worktree-space.test.mjs +++ b/config/scripts/reclaim-worktree-space.test.mjs @@ -22,14 +22,14 @@ async function fixture() { it('previews historical test caches, Electron cleanup, and Vite caches', async () => { const options = { ...(await fixture()), testsStopped: true } await reclaimWorktreeSpace(options) - expect(options.execute).toHaveBeenCalledTimes(4) + expect(options.execute).toHaveBeenCalledTimes(5) expect(options.execute.mock.calls.flatMap(([spec]) => spec.args)).not.toContain('--apply') }) it('forwards apply and repository selection to all cleanup operations', async () => { const options = { ...(await fixture()), apply: true, testsStopped: true } await reclaimWorktreeSpace(options) - expect(options.execute).toHaveBeenCalledTimes(4) + expect(options.execute).toHaveBeenCalledTimes(5) for (const [spec] of options.execute.mock.calls) { expect(spec.args).toContain('--apply') expect(spec.args).toContain(options.repoRoot) @@ -43,10 +43,10 @@ it('forwards apply and repository selection to all cleanup operations', async () } }) -it('applies Electron and Vite cleanup while retaining unacknowledged test caches', async () => { +it('applies Electron, Vite and generated cleanup while retaining unacknowledged test caches', async () => { const options = { ...(await fixture()), apply: true } 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' ) @@ -65,6 +65,9 @@ it('leaves historical caches out of automatic invocations', async () => { 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( + 'reclaim-worktree-generated-files' + ) for (const [spec] of options.execute.mock.calls) { expect(spec.args).toEqual( expect.arrayContaining(['--exclude-worktree', options.excludeWorktree]) @@ -173,7 +176,7 @@ it('rejects competing sweeps and releases the lock after a failed operation', as await rejected options.execute.mockResolvedValue({ code: 0, timedOut: false }) await reclaimWorktreeSpace(options) - expect(options.execute).toHaveBeenCalledTimes(5) + expect(options.execute).toHaveBeenCalledTimes(6) }) it('preserves the cleanup failure and reports a concurrent lock release failure separately', async () => { @@ -200,5 +203,5 @@ it('fails if cleanup succeeds but the repository lock cannot be released', async await expect(reclaimWorktreeSpace({ ...options, acquireLock: async () => release })).rejects.toBe( failure ) - expect(options.execute).toHaveBeenCalledTimes(4) + expect(options.execute).toHaveBeenCalledTimes(5) }) diff --git a/docs/readme/worktree-storage-maintenance.md b/docs/readme/worktree-storage-maintenance.md index 33dd9cfa2ce..0834c488af8 100644 --- a/docs/readme/worktree-storage-maintenance.md +++ b/docs/readme/worktree-storage-maintenance.md @@ -23,7 +23,8 @@ 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, -dependency installs, worktree registrations, and other build output alone. Reported sizes are +worktree registrations and branches intact. The manual command also includes 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. Use `--release-cache-min-age-days 7` to shorten historical-cache retention, or `0` to select all @@ -44,6 +45,36 @@ including registered worktrees outside Orca's workspace directory. Folder worksp have no repository-wide sweep. These commands run on the local host: they do not inspect or delete anything on SSH hosts. Run maintenance on the execution host when maintaining remote repositories. +## 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: + +```sh +pnpm reclaim-space +pnpm reclaim-space --apply +``` + +This manual 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. + +Preview leaves files in place. Apply rechecks Git tracking, parent paths, directory identity +and modification time before deleting. Symlinked directories, symlinked package parents, nested +repositories and directories containing tracked files are retained. Read-only generated +directories can be made writable for removal; hardlinked package files are never chmodded. +Source files, local changes, worktree registrations, branches, and shared pnpm stores remain +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 +outside this repository-scoped command. + ## Keeping installs shared Healthy Electron installs join the shared distribution cache during native dependency setup,