mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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' })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user