feat(space): add a prune action for stale worktree registrations

Prunable registrations are omitted from Space rows (#8409), but they
still pin their branches as checked out with no in-app remedy — a macOS
reboot that clears /private/tmp can leave a whole batch behind. Count
them during the Space scan and surface a notice with a Prune button
that runs git's own `worktree prune` per affected repo (locked
registrations survive; branches and commits are untouched).

The relay gains a dedicated git.pruneWorktrees op since generic
git.exec is deliberately read-only; an older deployed relay without it
gets a clear error instead of a protocol failure.
This commit is contained in:
Brennan Benson
2026-07-15 15:40:01 -07:00
parent 6e2a4a824d
commit 14494b4ab6
21 changed files with 288 additions and 7 deletions
+22 -1
View File
@@ -3,7 +3,7 @@ import { mkdtemp, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { listWorktrees, removeWorktree } from './worktree'
import { listWorktrees, pruneWorktrees, removeWorktree } from './worktree'
const tempRoots: string[] = []
@@ -152,4 +152,25 @@ describe('git worktree paths', () => {
)
expect(branchExists(repoPath, 'feature/locked-delete')).toBe(true)
})
it('prunes a stale registration while keeping its branch and locked worktrees', async () => {
const { repoPath, worktreePath } = await createRepoWithPrunableWorktree()
const locked = await createRepoWithLockedDeletedWorktree()
await pruneWorktrees(repoPath)
await pruneWorktrees(locked.repoPath)
const remaining = await listWorktrees(repoPath)
expect(
remaining.some(
(worktree) => worktree.path.replaceAll('\\', '/') === worktreePath.replaceAll('\\', '/')
)
).toBe(false)
// Git guarantees the branch and its commits survive a prune.
expect(branchExists(repoPath, 'feature/stale')).toBe(true)
// A lock shields the registration from prune even with the directory gone.
expect(git(locked.repoPath, ['worktree', 'list', '--porcelain'])).toContain(
locked.worktreePath.replaceAll('\\', '/')
)
})
})
+15
View File
@@ -1179,6 +1179,21 @@ export async function moveWorktree(
/**
* Remove a worktree.
*/
export async function pruneWorktrees(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<void> {
// Why: prune only drops registrations whose directory is gone; git itself
// preserves locked registrations, so agent-locked worktrees survive.
try {
await runWithGitReadCacheInvalidation(() =>
gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, options))
)
} finally {
bumpWorktreeScanGeneration(repoPath)
}
}
export async function removeWorktree(
repoPath: string,
worktreePath: string,
+1
View File
@@ -47,6 +47,7 @@ function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis {
worktreeCount: 0,
scannedWorktreeCount: 0,
unavailableWorktreeCount: 0,
staleRegistrationCount: 0,
repos: [],
worktrees: []
}
+52
View File
@@ -27,6 +27,7 @@ const {
addWorktreeMock,
addSparseWorktreeMock,
removeWorktreeMock,
pruneWorktreesMock,
forceDeleteLocalBranchMock,
resolveLocalGitUsernameMock,
getBaseRefDefaultMock,
@@ -77,6 +78,7 @@ const {
addWorktreeMock: vi.fn(),
addSparseWorktreeMock: vi.fn(),
removeWorktreeMock: vi.fn(),
pruneWorktreesMock: vi.fn(),
forceDeleteLocalBranchMock: vi.fn(),
resolveLocalGitUsernameMock: vi.fn(),
getBaseRefDefaultMock: vi.fn(),
@@ -124,6 +126,7 @@ vi.mock('../git/worktree', () => ({
addWorktree: addWorktreeMock,
addSparseWorktree: addSparseWorktreeMock,
removeWorktree: removeWorktreeMock,
pruneWorktrees: pruneWorktreesMock,
forceDeleteLocalBranch: forceDeleteLocalBranchMock
}))
@@ -6060,6 +6063,55 @@ describe('registerWorktreeHandlers', () => {
expect(listedIds).not.toContain('repo-1::/workspace/stale-wt')
})
it('runs git worktree prune for a local repo via worktrees:pruneStaleRegistrations', async () => {
pruneWorktreesMock.mockResolvedValue(undefined)
await handlers['worktrees:pruneStaleRegistrations'](null, { repoId: 'repo-1' })
expect(pruneWorktreesMock).toHaveBeenCalledWith('/workspace/repo', expect.anything())
})
it('routes worktrees:pruneStaleRegistrations to the SSH provider for remote repos', async () => {
const repo = {
id: 'repo-1',
path: '/remote/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
connectionId: 'ssh-1'
}
store.getRepos.mockReturnValue([repo])
pruneWorktreesMock.mockClear()
const providerPrune = vi.fn().mockResolvedValue(undefined)
getSshGitProviderMock.mockReturnValue({ pruneWorktrees: providerPrune })
await handlers['worktrees:pruneStaleRegistrations'](null, { repoId: 'repo-1' })
expect(providerPrune).toHaveBeenCalledWith('/remote/repo')
expect(pruneWorktreesMock).not.toHaveBeenCalled()
})
it('fails worktrees:pruneStaleRegistrations clearly when the relay lacks prune support', async () => {
const repo = {
id: 'repo-1',
path: '/remote/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
connectionId: 'ssh-1'
}
store.getRepos.mockReturnValue([repo])
// Why: an already-deployed relay predating git.pruneWorktrees rejects the
// RPC with "Method not found"; the error must say what to do instead.
getSshGitProviderMock.mockReturnValue({
pruneWorktrees: vi.fn().mockRejectedValue(new Error('Method not found: git.pruneWorktrees'))
})
await expect(
handlers['worktrees:pruneStaleRegistrations'](null, { repoId: 'repo-1' })
).rejects.toThrow(/older Orca relay/)
})
it('limits concurrent repo scans in worktrees:listAll while preserving order', async () => {
const repos = Array.from({ length: 10 }, (_, index) => ({
id: `repo-${index}`,
+40
View File
@@ -41,6 +41,7 @@ import {
assertWorktreeCleanForRemoval,
forceDeleteLocalBranch,
listWorktreesStrict as listGitWorktreesStrict,
pruneWorktrees,
removeWorktree
} from '../git/worktree'
import { gitExecFileAsync } from '../git/runner'
@@ -1003,6 +1004,7 @@ export function registerWorktreeHandlers(
ipcMain.removeHandler('worktrees:resolveMrBase')
ipcMain.removeHandler('worktrees:remove')
ipcMain.removeHandler('worktrees:forgetLocal')
ipcMain.removeHandler('worktrees:pruneStaleRegistrations')
ipcMain.removeHandler('worktrees:forceDeletePreservedBranch')
ipcMain.removeHandler('worktrees:updateMeta')
ipcMain.removeHandler('worktrees:listLineage')
@@ -2021,6 +2023,44 @@ export function registerWorktreeHandlers(
}
)
// Why: a prunable registration (directory deleted without `git worktree
// prune`) is hidden from workspace/Space listings, but it still pins its
// branch as checked out. This runs git's own prune, which drops only dead
// registrations and never touches locked ones (active agent sessions).
ipcMain.handle(
'worktrees:pruneStaleRegistrations',
async (_event, args: { repoId: string }): Promise<void> => {
const repos = store.getRepos().filter((candidate) => candidate.id === args.repoId)
if (repos.length === 0) {
throw new Error(`Repo not found: ${args.repoId}`)
}
for (const repo of repos) {
if (isFolderRepo(repo)) {
continue
}
if (repo.connectionId) {
const provider = requireSshGitProvider(repo.connectionId)
try {
await provider.pruneWorktrees(repo.path)
} catch (error) {
// Why: an already-deployed relay predating git.pruneWorktrees
// rejects with "Method not found"; surface what to do instead.
if (/method not found/i.test(error instanceof Error ? error.message : '')) {
throw new Error(
'The connected SSH host is running an older Orca relay without worktree prune support. Reconnect to update it, or run `git worktree prune` there.'
)
}
throw error
}
} else {
await pruneWorktrees(repo.path, getLocalProjectWorktreeGitOptions(store, repo))
}
notifyWorktreesChanged(mainWindow, repo.id)
}
invalidateAuthorizedRootsCache()
}
)
ipcMain.handle(
'worktrees:forceDeletePreservedBranch',
async (
+6
View File
@@ -672,6 +672,12 @@ export class SshGitProvider implements IGitProvider {
)
}
async pruneWorktrees(repoPath: string): Promise<void> {
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.pruneWorktrees', { repoPath })
})
}
async worktreeIsClean(
worktreePath: string,
options: { includeUntracked?: boolean } = {}
@@ -165,6 +165,9 @@ describe('analyzeWorkspaceSpace', () => {
expect(result.worktrees.find((row) => row.path === stalePath)).toBeUndefined()
expect(result.worktrees.find((row) => row.path === mainPath)?.status).toBe('ok')
expect(result.repos[0]?.unavailableWorktreeCount).toBe(0)
// The omitted registrations stay countable so the page can offer a prune.
expect(result.repos[0]?.staleRegistrationCount).toBe(1)
expect(result.staleRegistrationCount).toBe(1)
})
it('reports scan progress as repos and worktrees are scanned', async () => {
+7 -1
View File
@@ -843,6 +843,7 @@ async function scanRepo(
worktreeCount: 0,
scannedWorktreeCount: 0,
unavailableWorktreeCount: 1,
staleRegistrationCount: 0,
totalSizeBytes: 0,
reclaimableBytes: 0,
error: listed.error
@@ -852,7 +853,10 @@ async function scanRepo(
// Why: a prunable registration has no directory to size or reclaim (issue
// #8389); it would only render a dead "Missing" row with no available
// action. Removal flows list worktrees separately and still see it.
// action. Count it so the page can offer a prune instead of listing it.
const staleRegistrationCount = listed.worktrees.filter(
(gitWorktree) => gitWorktree.prunable
).length
const worktrees = listed.worktrees
.filter((gitWorktree) => !gitWorktree.prunable)
.map((gitWorktree) => mergeForSpaceScan(repo, gitWorktree, store))
@@ -910,6 +914,7 @@ async function scanRepo(
worktreeCount: rows.length,
scannedWorktreeCount: rows.filter((row) => row.status === 'ok').length,
unavailableWorktreeCount: rows.filter((row) => row.status !== 'ok').length,
staleRegistrationCount,
totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0),
reclaimableBytes: rows.reduce((sum, row) => sum + row.reclaimableBytes, 0),
error: null
@@ -956,6 +961,7 @@ export async function analyzeWorkspaceSpace(
unavailableWorktreeCount:
worktrees.filter((row) => row.status !== 'ok').length +
repos.filter((repo) => repo.error !== null).length,
staleRegistrationCount: repos.reduce((sum, repo) => sum + repo.staleRegistrationCount, 0),
repos,
worktrees
}
+3
View File
@@ -1183,6 +1183,9 @@ export type PreloadApi = {
worktreeId: string
hostId?: ExecutionHostId
}) => Promise<RemoveWorktreeResult>
// Run `git worktree prune` for a repo, dropping stale registrations whose
// directory was deleted. Locked registrations are preserved by git.
pruneStaleRegistrations: (args: { repoId: string }) => Promise<void>
forceDeletePreservedBranch: (args: {
worktreeId: string
branchName: string
+3
View File
@@ -709,6 +709,9 @@ const api = {
forgetLocal: (args) => ipcRenderer.invoke('worktrees:forgetLocal', args),
pruneStaleRegistrations: (args) =>
ipcRenderer.invoke('worktrees:pruneStaleRegistrations', args),
forceDeletePreservedBranch: (args) =>
ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args),
+9
View File
@@ -233,6 +233,7 @@ export class GitHandler {
this.dispatcher.onRequest('git.listWorktrees', (p, context) => this.listWorktrees(p, context))
this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p))
this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p))
this.dispatcher.onRequest('git.pruneWorktrees', (p) => this.pruneWorktrees(p))
this.dispatcher.onRequest('git.worktreeIsClean', (p) => this.worktreeIsClean(p))
this.dispatcher.onRequest('git.refreshLocalBaseRefForWorktreeCreate', (p) =>
this.refreshLocalBaseRefForWorktreeCreate(p)
@@ -1409,6 +1410,14 @@ export class GitHandler {
: remove()
}
private async pruneWorktrees(params: Record<string, unknown>) {
const repoPath = params.repoPath as string
// Why: prune only drops registrations whose directory is gone; git itself
// preserves locked registrations, so agent-locked worktrees survive.
await this.runWithGitReadCacheClear(() => this.git(['worktree', 'prune'], repoPath))
return {}
}
private async worktreeIsClean(params: Record<string, unknown>) {
return worktreeIsCleanOp(this.git.bind(this), params)
}
@@ -1253,6 +1253,43 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
const updateWorktreeGitIdentity = useAppStore((state) => state.updateWorktreeGitIdentity)
const setUpstreamStatus = useAppStore((state) => state.setUpstreamStatus)
const fetchUpstreamStatus = useAppStore((state) => state.fetchUpstreamStatus)
const pruneStaleWorktreeRegistrations = useAppStore(
(state) => state.pruneStaleWorktreeRegistrations
)
const [isPruningStale, setIsPruningStale] = useState(false)
const pruneStale = useCallback((): void => {
if (!analysis || isPruningStale) {
return
}
const repoIds = analysis.repos
.filter((repo) => repo.staleRegistrationCount > 0)
.map((repo) => repo.repoId)
if (repoIds.length === 0) {
return
}
setIsPruningStale(true)
pruneStaleWorktreeRegistrations(repoIds)
.then(() => {
toast.success(
translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.e1978eea62',
'Stale worktree registrations pruned'
)
)
})
.catch((error: unknown) => {
toast.error(
translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.db0915334e',
'Prune failed'
),
{ description: error instanceof Error ? error.message : String(error) }
)
})
.finally(() => {
setIsPruningStale(false)
})
}, [analysis, isPruningStale, pruneStaleWorktreeRegistrations])
const [query, setQuery] = useState('')
const [onlyDeletable, setOnlyDeletable] = useState(false)
const [sortKey, setSortKey] = useState<WorkspaceSpaceSortKey>('size')
@@ -1773,6 +1810,46 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
</div>
) : null}
{analysis && analysis.staleRegistrationCount > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/70 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<div className="flex min-w-0 items-center gap-2">
<FileWarning className="size-3.5 shrink-0" />
<span className="min-w-0 break-words">
{translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.7edf0a0a79',
'{{value0}} stale worktree {{value1}} from deleted directories.',
{
value0: analysis.staleRegistrationCount,
value1: analysis.staleRegistrationCount === 1 ? 'registration' : 'registrations'
}
)}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={pruneStale}
disabled={isPruningStale}
className="gap-1.5"
>
{isPruningStale ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
{isPruningStale
? translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.7176290b7c',
'Pruning'
)
: translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.322d74038c',
'Prune'
)}
</Button>
</div>
) : null}
{hasRows || isInitialScan ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(20rem,0.6fr)]">
<WorkspaceTreemap
+6 -1
View File
@@ -3171,7 +3171,12 @@
"0990a63160": "No scanned workspace sizes yet.",
"977bdf9a36": "No top-level items to show.",
"131662ac65": "{{value0}} open",
"0d1c78d749": "Select {{value0}}"
"0d1c78d749": "Select {{value0}}",
"e1978eea62": "Stale worktree registrations pruned",
"db0915334e": "Prune failed",
"7edf0a0a79": "{{value0}} stale worktree {{value1}} from deleted directories.",
"7176290b7c": "Pruning",
"322d74038c": "Prune"
},
"ports": {
"status": {
+6 -1
View File
@@ -3171,7 +3171,12 @@
"0990a63160": "Aún no hay tamaños de espacio de trabajo escaneados.",
"977bdf9a36": "No hay elementos de nivel superior para mostrar.",
"131662ac65": "{{value0}} abierto",
"0d1c78d749": "Seleccione {{value0}}"
"0d1c78d749": "Seleccione {{value0}}",
"e1978eea62": "Stale worktree registrations pruned",
"db0915334e": "Prune failed",
"7edf0a0a79": "{{value0}} stale worktree {{value1}} from deleted directories.",
"7176290b7c": "Pruning",
"322d74038c": "Prune"
},
"ports": {
"status": {
+6 -1
View File
@@ -3171,7 +3171,12 @@
"0990a63160": "スキャンされたワークスペースのサイズはまだありません。",
"977bdf9a36": "表示するトップレベルの項目はありません。",
"131662ac65": "{{value0}} オープン",
"0d1c78d749": "{{value0}} を選択"
"0d1c78d749": "{{value0}} を選択",
"e1978eea62": "Stale worktree registrations pruned",
"db0915334e": "Prune failed",
"7edf0a0a79": "{{value0}} stale worktree {{value1}} from deleted directories.",
"7176290b7c": "Pruning",
"322d74038c": "Prune"
},
"ports": {
"status": {
+6 -1
View File
@@ -3171,7 +3171,12 @@
"0990a63160": "아직 스캔된 워크스페이스 크기가 없습니다.",
"977bdf9a36": "표시할 최상위 항목이 없습니다.",
"131662ac65": "{{value0}} 열려 있음",
"0d1c78d749": "{{value0}} 선택"
"0d1c78d749": "{{value0}} 선택",
"e1978eea62": "Stale worktree registrations pruned",
"db0915334e": "Prune failed",
"7edf0a0a79": "{{value0}} stale worktree {{value1}} from deleted directories.",
"7176290b7c": "Pruning",
"322d74038c": "Prune"
},
"ports": {
"status": {
+6 -1
View File
@@ -3171,7 +3171,12 @@
"0990a63160": "尚无扫描的工作区大小。",
"977bdf9a36": "没有可显示的顶级项目。",
"131662ac65": "{{value0}} 打开",
"0d1c78d749": "选择 {{value0}}"
"0d1c78d749": "选择 {{value0}}",
"e1978eea62": "Stale worktree registrations pruned",
"db0915334e": "Prune failed",
"7edf0a0a79": "{{value0}} stale worktree {{value1}} from deleted directories.",
"7176290b7c": "Pruning",
"322d74038c": "Prune"
},
"ports": {
"status": {
@@ -21,6 +21,7 @@ function makeAnalysis(): WorkspaceSpaceAnalysis {
worktreeCount: 2,
scannedWorktreeCount: 2,
unavailableWorktreeCount: 0,
staleRegistrationCount: 0,
repos: [
{
repoId: 'repo-1',
@@ -30,6 +31,7 @@ function makeAnalysis(): WorkspaceSpaceAnalysis {
worktreeCount: 2,
scannedWorktreeCount: 2,
unavailableWorktreeCount: 0,
staleRegistrationCount: 0,
totalSizeBytes: 300,
reclaimableBytes: 300,
error: null
@@ -16,6 +16,7 @@ export type WorkspaceSpaceSlice = {
cancelWorkspaceSpaceScan: () => Promise<boolean>
refreshWorkspaceSpace: () => Promise<WorkspaceSpaceAnalysis>
removeWorkspaceSpaceWorktrees: (worktreeIds: readonly string[]) => void
pruneStaleWorktreeRegistrations: (repoIds: readonly string[]) => Promise<void>
}
function removeDeletedWorktreesFromAnalysis(
@@ -160,5 +161,15 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
}
: state
)
},
pruneStaleWorktreeRegistrations: async (repoIds) => {
if (repoIds.length === 0) {
return
}
get().recordFeatureInteraction?.('workspace-cleanup')
for (const repoId of repoIds) {
await window.api.worktrees.pruneStaleRegistrations({ repoId })
}
await get().refreshWorkspaceSpace()
}
})
+3
View File
@@ -1437,6 +1437,9 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
forgetLocal: () => {
throw new Error('Forgetting a workspace is unavailable in paired web clients.')
},
pruneStaleRegistrations: () => {
throw new Error('Pruning stale worktree registrations is unavailable in paired web clients.')
},
forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead }) =>
callRuntimeResult<ForceDeleteWorktreeBranchResult>('worktree.forceDeleteBranch', {
worktree: toRuntimeWorktreeSelector(worktreeId),
+4
View File
@@ -46,6 +46,9 @@ export type WorkspaceSpaceRepoSummary = {
worktreeCount: number
scannedWorktreeCount: number
unavailableWorktreeCount: number
/** Prunable git registrations (directory deleted without `git worktree
* prune`) omitted from the rows but still reconcilable via prune. */
staleRegistrationCount: number
totalSizeBytes: number
reclaimableBytes: number
error: string | null
@@ -58,6 +61,7 @@ export type WorkspaceSpaceAnalysis = {
worktreeCount: number
scannedWorktreeCount: number
unavailableWorktreeCount: number
staleRegistrationCount: number
repos: WorkspaceSpaceRepoSummary[]
worktrees: WorkspaceSpaceWorktree[]
}