Add manual cleanup for worktree Electron storage (#27414)

* Add manual worktree storage cleanup command

* Validate cleanup exclusions and preserve failure diagnostics
This commit is contained in:
Neil
2026-10-11 01:55:34 -07:00
committed by GitHub
parent 37277808b2
commit c69f9d2f62
11 changed files with 388 additions and 12 deletions
+10 -1
View File
@@ -58,12 +58,21 @@ export function isDevBundleInUse(dir, processTable) {
return processTable.includes(`${dir}/`) || processTable.includes(`${alternate}/`)
}
export function selectStaleDevBundleDirs({ bundles, currentDir, processTable, nowMs }) {
export function selectStaleDevBundleDirs({
bundles,
currentDir,
processTable,
nowMs,
minAgeMs = 0
}) {
return bundles
.filter(({ dir, hasMarker, mtimeMs }) => {
if (dir === currentDir || isDevBundleInUse(dir, processTable)) {
return false
}
if (minAgeMs > 0 && nowMs - mtimeMs < minAgeMs) {
return false
}
const buildInFlight = !hasMarker && nowMs - mtimeMs < IN_PROGRESS_WINDOW_MS
return !buildInFlight
})
@@ -22,6 +22,17 @@ function psLine(dir: string, appName = 'Orca: some-branch') {
}
describe('dev-electron-bundle-cache', () => {
it('keeps recently used bundles during automatic retention sweeps', () => {
expect(
selectStaleDevBundleDirs({
bundles: [settled(A), { ...settled(B), mtimeMs: NOW - 8 * 24 * 60 * 60 * 1000 }],
currentDir: null,
processTable: '',
nowMs: NOW,
minAgeMs: 7 * 24 * 60 * 60 * 1000
})
).toEqual([B])
})
it('reclaims siblings while keeping the current bundle', () => {
expect(
selectStaleDevBundleDirs({
@@ -29,7 +29,6 @@ const projectDir = resolve(import.meta.dirname, '../..')
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
const electronRequire = createRequire(resolve(electronPackageDir, 'package.json'))
const { version: electronVersion } = electronRequire('./package.json')
const { downloadArtifact } = electronRequire('@electron/get')
const targetPlatform = getElectronTargetPlatform()
const targetArch = getElectronTargetArch()
const platformPath = getElectronPlatformPath(targetPlatform)
@@ -249,6 +248,7 @@ function formatShareError(error) {
}
async function downloadElectronArtifactWithRetry(downloadOptions, { cacheRootIsPersistent }) {
const { downloadArtifact } = electronRequire('@electron/get')
const retryDelays = getDownloadRetryDelays()
for (let attempt = 0; ; attempt += 1) {
+14
View File
@@ -19,6 +19,10 @@
*/
import { rebuild } from '@electron/rebuild'
import {
hasAdoptedSharedElectronDist,
resolveSharedElectronDistEntry
} from './shared-electron-dist-cache.mjs'
import { execFileSync, spawnSync } from 'node:child_process'
import {
ensureWindowsProcessTreeCommandLinePatch,
@@ -310,6 +314,16 @@ function restoreNodePtyWindowsConptyRuntime() {
function ensureElectronPackageInstalled() {
repairElectronPathFile()
if (electronPackageIsUsable()) {
const shared = resolveSharedElectronDistEntry({
repoRoot: projectDir,
electronPackageDir,
version: electronVersion,
targetPlatform: electronInstallPlatform,
targetArch: electronInstallArch
})
if (shared && !hasAdoptedSharedElectronDist(shared)) {
runElectronPackageBinaryInstall()
}
return
}
@@ -11,7 +11,7 @@
// now shared, rebuilding is cheap.
import { execFileSync } from 'node:child_process'
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
import { existsSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs'
import path from 'node:path'
import {
DEV_BUNDLE_MARKER_FILENAME,
@@ -20,6 +20,14 @@ import {
} from './dev-electron-bundle-cache.mjs'
const apply = process.argv.includes('--apply')
const minAgeIndex = process.argv.indexOf('--min-age-days')
const minAgeDays = minAgeIndex === -1 ? 0 : Number(process.argv[minAgeIndex + 1])
const excludeIndex = process.argv.indexOf('--exclude-worktree')
const excludeArgument = excludeIndex === -1 ? undefined : process.argv[excludeIndex + 1]
if (excludeIndex !== -1 && (!excludeArgument || excludeArgument.startsWith('-'))) {
throw new Error('--exclude-worktree requires a directory')
}
const excludeWorktree = excludeArgument ? realpathSync(excludeArgument) : null
const repoRoot = process.argv.includes('--repo')
? path.resolve(process.argv[process.argv.indexOf('--repo') + 1])
: process.cwd()
@@ -34,6 +42,14 @@ function listWorktrees(root) {
.map((line) => line.slice('worktree '.length).trim())
}
function canonicalWorktreePath(worktree) {
try {
return realpathSync(worktree)
} catch {
return path.resolve(worktree)
}
}
function measure(targetPath) {
let total = 0
let entries
@@ -72,6 +88,9 @@ export function collectDevBundles(worktree) {
}
function main() {
if (!Number.isFinite(minAgeDays) || minAgeDays < 0) {
throw new Error('--min-age-days requires a non-negative number')
}
// The patched dev bundle is only built on macOS; elsewhere the dev app runs from dist directly.
if (process.platform !== 'darwin') {
console.log('No dev Electron bundles on this platform; nothing to reclaim.')
@@ -86,14 +105,17 @@ function main() {
return
}
const bundles = listWorktrees(repoRoot).flatMap((worktree) => collectDevBundles(worktree))
const bundles = listWorktrees(repoRoot)
.filter((worktree) => canonicalWorktreePath(worktree) !== excludeWorktree)
.flatMap((worktree) => collectDevBundles(worktree))
// currentDir is null on purpose: unlike the dev runner, this sweep is not about to launch anything,
// so the only thing protecting a bundle is a live process or an in-flight build.
const stale = selectStaleDevBundleDirs({
bundles,
currentDir: null,
processTable,
nowMs: Date.now()
nowMs: Date.now(),
minAgeMs: minAgeDays * 24 * 60 * 60 * 1000
})
let reclaimed = 0
@@ -107,20 +129,40 @@ function main() {
continue
}
try {
const currentProcessTable = getDevBundleProcessTable()
if (currentProcessTable === null) {
throw new Error('Process ownership is unverifiable')
}
const currentBundle = collectDevBundles(path.resolve(dir, '..', '..', '..')).find(
(bundle) => bundle.dir === dir
)
if (
!currentBundle ||
!selectStaleDevBundleDirs({
bundles: [currentBundle],
currentDir: null,
processTable: currentProcessTable,
nowMs: Date.now(),
minAgeMs: minAgeDays * 24 * 60 * 60 * 1000
}).includes(dir)
) {
continue
}
rmSync(dir, { recursive: true, force: true })
reclaimed += size
removed += 1
console.log(`removed ${dir} ${(size / 1024 ** 3).toFixed(2)} GiB`)
} catch (error) {
console.warn(`skip ${dir} (${error instanceof Error ? error.message : String(error)})`)
process.exitCode = 1
}
}
const inUse = bundles.length - stale.length
console.log(
`\n${apply ? 'Removed' : 'Would remove'} ${removed} bundle(s); ` +
`${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` +
`${inUse > 0 ? `; left ${inUse} in use or still building` : ''}` +
`logical file data ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB (shared blocks affect disk savings)` +
`${inUse > 0 ? `; retained ${inUse} protected or recent bundle(s)` : ''}` +
`${apply ? '' : '\nRe-run with --apply to do it.'}`
)
}
@@ -1,7 +1,8 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { runProcess } from '@orca/process-host'
import {
DEV_BUNDLE_MARKER_FILENAME,
getDevBundleProcessTable,
@@ -51,6 +52,65 @@ describe('collectDevBundles', () => {
})
describe('sweeping across worktrees', () => {
it.each([
['--apply', '--exclude-worktree'],
['--exclude-worktree', '--apply'],
['--exclude-worktree', '-invalid', '--apply']
])('rejects invalid exclusion operands before cleanup: %j', async (...flags) => {
const worktree = makeWorktree([{ name: 'idle', marker: true }])
const initialized = await runProcess({ program: 'git', args: ['-C', worktree, 'init', '-q'] })
expect(initialized.code).toBe(0)
const result = await runProcess({
program: process.execPath,
args: [
path.join(import.meta.dirname, 'reclaim-dev-electron-bundles.mjs'),
'--repo',
worktree,
...flags
]
})
expect(result.code).not.toBe(0)
expect(result.stderr).toContain('--exclude-worktree requires a directory')
expect(existsSync(path.join(worktree, 'out', 'electron-dev', 'idle'))).toBe(true)
})
it.skipIf(process.platform !== 'darwin')(
'previews and removes only bundles outside the retention window',
async () => {
const worktree = makeWorktree([
{ name: 'old', marker: true },
{ name: 'recent', marker: true }
])
const initialized = await runProcess({ program: 'git', args: ['-C', worktree, 'init', '-q'] })
expect(initialized.code).toBe(0)
const old = path.join(worktree, 'out', 'electron-dev', 'old')
const recent = path.join(worktree, 'out', 'electron-dev', 'recent')
const aged = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)
utimesSync(old, aged, aged)
const args = [
path.join(import.meta.dirname, 'reclaim-dev-electron-bundles.mjs'),
'--repo',
worktree,
'--min-age-days',
'7'
]
const preview = await runProcess({ program: process.execPath, args })
expect(preview.code, preview.stderr).toBe(0)
expect(preview.stdout).toContain('Would remove 1 bundle(s)')
expect(existsSync(old)).toBe(true)
const excluded = await runProcess({
program: process.execPath,
args: [...args, '--exclude-worktree', worktree, '--apply']
})
expect(excluded.code, excluded.stderr).toBe(0)
expect(existsSync(old)).toBe(true)
const applied = await runProcess({ program: process.execPath, args: [...args, '--apply'] })
expect(applied.code, applied.stderr).toBe(0)
expect(existsSync(old)).toBe(false)
expect(existsSync(recent)).toBe(true)
}
)
it('spares a bundle a live process is running from, and takes the idle ones', () => {
const worktree = makeWorktree([
{ name: 'live', marker: true },
+3 -4
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env node
// Converts worktrees that already have their own Electron dist over to the shared cache.
// A normal install only shares when Electron is (re)installed, and an existing healthy worktree
// never reaches that path -- so without this, sharing only arrives at the next Electron upgrade.
// Healthy installs also share now; this migrates worktrees without waiting for their next install.
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
@@ -153,7 +152,7 @@ function main() {
recordAdoptedSharedElectronDist(entry, writeFileSync)
reclaimed += size
converted += 1
console.log(`shared ${worktree} reclaimed ${(size / 1024 ** 3).toFixed(2)} GiB`)
console.log(`shared ${worktree} ${(size / 1024 ** 3).toFixed(2)} GiB logical file data`)
}
} catch (error) {
// A worktree that fails is left exactly as it was; it still has its own working dist.
@@ -164,7 +163,7 @@ function main() {
console.log(
`\n${apply ? 'Shared' : 'Would share'} ${converted} worktree(s); ` +
`${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` +
`logical file data ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB (shared blocks affect disk savings)` +
`${skipped > 0 ? `; skipped ${skipped}` : ''}` +
`${apply ? '' : '\nRe-run with --apply to do it.'}`
)
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
import { mkdir } from 'node:fs/promises'
import { resolve, join } from 'node:path'
import { lock } from 'proper-lockfile'
import { runProcess } from '@orca/process-host'
export async function reclaimWorktreeSpace({
repoRoot = process.cwd(),
apply = false,
execute = runProcess,
acquireLock = lock,
commonDirectory
} = {}) {
if (!commonDirectory) {
const result = await runProcess({
program: 'git',
args: ['-C', repoRoot, 'rev-parse', '--git-common-dir']
})
if (result.code !== 0 || result.timedOut || !result.stdout.trim()) {
throw new Error('Run this command inside a local Git repository, or supply --repo')
}
commonDirectory = resolve(repoRoot, result.stdout.trim())
}
const cacheRoot = join(commonDirectory, 'orca-cache')
const lockPath = join(cacheRoot, 'storage-cleanup')
await mkdir(cacheRoot, { recursive: true })
let release
try {
release = await acquireLock(lockPath, { realpath: false, stale: 600_000, retries: 0 })
} catch (error) {
if (error.code === 'ELOCKED') {
throw new Error('Another worktree cleanup is already running')
}
throw error
}
let cleanupFailed = false
let cleanupError
try {
const scripts = ['reclaim-electron-dists.mjs', 'reclaim-dev-electron-bundles.mjs']
for (const script of scripts) {
const args = [resolve(import.meta.dirname, script), '--repo', repoRoot]
if (apply) {
args.push('--apply')
}
const result = await execute({
program: process.execPath,
args,
env: process.env,
stdio: 'inherit',
timeoutMs: 10 * 60 * 1000,
terminationBarrier: true
})
if (result.code !== 0 || result.timedOut) {
throw new Error(`${script} failed; rerun cleanup after resolving the error`)
}
}
} catch (error) {
cleanupFailed = true
cleanupError = error
}
try {
await release()
} catch (error) {
if (!cleanupFailed) {
throw error
}
console.error(
`Failed to release cleanup lock: ${error instanceof Error ? error.message : String(error)}`
)
}
if (cleanupFailed) {
throw cleanupError
}
return { completed: true }
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
if (process.argv.includes('--help')) {
console.log('Usage: pnpm run reclaim:worktree-space [--repo DIRECTORY] [--apply]')
console.log(
'Previews Electron distribution sharing and idle dev bundle removal; --apply performs them.'
)
process.exit(0)
}
const repoIndex = process.argv.indexOf('--repo')
for (let index = 2; index < process.argv.length; index += 1) {
const argument = process.argv[index]
if (argument === '--repo') {
index += 1
} else if (argument !== '--apply') {
throw new Error(`Unknown argument: ${argument}`)
}
}
if (
repoIndex !== -1 &&
(!process.argv[repoIndex + 1] || process.argv[repoIndex + 1].startsWith('--'))
) {
throw new Error('--repo requires a directory')
}
await reclaimWorktreeSpace({
repoRoot: repoIndex !== -1 ? resolve(process.argv[repoIndex + 1]) : process.cwd(),
apply: process.argv.includes('--apply')
}).catch((error) => {
console.error(error.message)
process.exitCode = 1
})
}
@@ -0,0 +1,89 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { reclaimWorktreeSpace } from './reclaim-worktree-space.mjs'
const roots = []
afterEach(async () => {
for (const root of roots.splice(0)) {
await rm(root, { recursive: true, force: true })
}
})
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-storage-maintenance-'))
roots.push(root)
const execute = vi.fn(async () => ({ code: 0, timedOut: false }))
return { commonDirectory: root, repoRoot: root, execute }
}
it('previews both existing cleanup operations without applying either', async () => {
const options = await fixture()
await reclaimWorktreeSpace(options)
expect(options.execute).toHaveBeenCalledTimes(2)
expect(options.execute.mock.calls.flatMap(([spec]) => spec.args)).not.toContain('--apply')
})
it('forwards apply and repository selection to both cleanup operations', async () => {
const options = { ...(await fixture()), apply: true }
await reclaimWorktreeSpace(options)
expect(options.execute).toHaveBeenCalledTimes(2)
for (const [spec] of options.execute.mock.calls) {
expect(spec.args).toContain('--apply')
expect(spec.args).toContain(options.repoRoot)
expect(spec.terminationBarrier).toBe(true)
}
})
it('rejects competing sweeps and releases the lock after a failed operation', async () => {
const options = { ...(await fixture()), apply: true }
let release
const blocked = new Promise((resolve) => {
release = resolve
})
let entered
const ready = new Promise((resolve) => {
entered = resolve
})
options.execute.mockImplementation(async () => {
entered()
await blocked
return { code: 1, timedOut: false }
})
const first = reclaimWorktreeSpace(options)
const rejected = expect(first).rejects.toThrow('failed')
await ready
await expect(reclaimWorktreeSpace(options)).rejects.toThrow('already running')
release()
await rejected
options.execute.mockResolvedValue({ code: 0, timedOut: false })
await reclaimWorktreeSpace(options)
expect(options.execute).toHaveBeenCalledTimes(3)
})
it('preserves the cleanup failure and reports a concurrent lock release failure separately', async () => {
const options = await fixture()
const failure = new Error('original cleanup failure')
options.execute.mockRejectedValue(failure)
const release = vi.fn().mockRejectedValue(new Error('lock release failure'))
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
await expect(
reclaimWorktreeSpace({ ...options, acquireLock: async () => release })
).rejects.toBe(failure)
expect(release).toHaveBeenCalledTimes(1)
expect(report).toHaveBeenCalledWith('Failed to release cleanup lock: lock release failure')
} finally {
report.mockRestore()
}
})
it('fails if cleanup succeeds but the repository lock cannot be released', async () => {
const options = await fixture()
const failure = new Error('lock release failure')
const release = vi.fn().mockRejectedValue(failure)
await expect(reclaimWorktreeSpace({ ...options, acquireLock: async () => release })).rejects.toBe(
failure
)
expect(options.execute).toHaveBeenCalledTimes(2)
})
@@ -0,0 +1,44 @@
# Worktree storage maintenance
Run from the repository to preview maintenance across its locally registered Git worktrees:
```sh
pnpm run reclaim:worktree-space
```
Apply the previewed operations with:
```sh
pnpm run reclaim:worktree-space --apply
```
The command reuses the existing Electron distribution migration and dev bundle cleanup scripts.
It shares complete Electron distributions through the repository cache and removes idle
`out/electron-dev` bundles, which development rebuilds on demand. It leaves source files,
dependency installs, worktree registrations, and other build output alone. Reported sizes are
logical file data; APFS clones and hardlinks mean actual disk savings can be smaller.
Use `--repo DIRECTORY` to choose another local repository. Git's worktree list determines scope,
including registered worktrees outside Orca's workspace directory. Folder workspaces without Git
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.
## Keeping installs shared
Healthy Electron installs join the shared distribution cache during native dependency setup,
without waiting for an Electron upgrade. The manual command migrates older installs immediately.
Cleanup runs only when explicitly requested. Starting `pnpm dev` does not trigger a sweep.
The repository lock prevents competing cleanup commands. Dev bundle removal checks active
processes and recent unfinished builds, then rechecks eligibility immediately before deletion.
An unreadable process table prevents removal.
To retain older bundles during a manual sweep, run the underlying command with an age limit:
```sh
node config/scripts/reclaim-dev-electron-bundles.mjs --min-age-days 7 --apply
```
That limit uses the bundle directory's modification time. The underlying command also accepts
`--exclude-worktree DIRECTORY` to protect an entire worktree. A missing exclusion path is an error;
filesystem aliases resolve to the same worktree. Neither option schedules cleanup.
+1
View File
@@ -116,6 +116,7 @@
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
"reclaim:electron-dists": "node config/scripts/reclaim-electron-dists.mjs",
"reclaim:dev-bundles": "node config/scripts/reclaim-dev-electron-bundles.mjs",
"reclaim:worktree-space": "node config/scripts/reclaim-worktree-space.mjs",
"rebuild:node": "pnpm rebuild node-pty",
"build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir",
"build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win",