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
This commit is contained in:
Neil
2026-10-24 00:19:39 -07:00
committed by GitHub
parent 70c4c55db9
commit a5a5a22c60
7 changed files with 643 additions and 14 deletions
+15 -7
View File
@@ -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
@@ -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()
@@ -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
})
}
@@ -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' })
})
+10
View File
@@ -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,
@@ -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)
})
+32 -1
View File
@@ -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,