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