From 14494b4ab62f361d6f40871bf2f87937c7fb5ade Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:16:22 -0700 Subject: [PATCH] feat(space): add a prune action for stale worktree registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/main/git/worktree-list-paths.test.ts | 23 +++++- src/main/git/worktree.ts | 15 ++++ src/main/ipc/workspace-space.test.ts | 1 + src/main/ipc/worktrees.test.ts | 52 +++++++++++++ src/main/ipc/worktrees.ts | 40 ++++++++++ src/main/providers/ssh-git-provider.ts | 6 ++ src/main/workspace-space-analysis.test.ts | 3 + src/main/workspace-space-analysis.ts | 8 +- src/preload/api-types.ts | 3 + src/preload/index.ts | 3 + src/relay/git-handler.ts | 9 +++ .../status-bar/WorkspaceSpaceManagerPanel.tsx | 77 +++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../src/store/slices/workspace-space.test.ts | 2 + .../src/store/slices/workspace-space.ts | 11 +++ src/renderer/src/web/web-preload-api.ts | 3 + src/shared/workspace-space-types.ts | 4 + 21 files changed, 288 insertions(+), 7 deletions(-) diff --git a/src/main/git/worktree-list-paths.test.ts b/src/main/git/worktree-list-paths.test.ts index a248ddb7518..d000ca51b64 100644 --- a/src/main/git/worktree-list-paths.test.ts +++ b/src/main/git/worktree-list-paths.test.ts @@ -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('\\', '/') + ) + }) }) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 8823c683b60..cda466af58b 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -1179,6 +1179,21 @@ export async function moveWorktree( /** * Remove a worktree. */ +export async function pruneWorktrees( + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { + // 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, diff --git a/src/main/ipc/workspace-space.test.ts b/src/main/ipc/workspace-space.test.ts index c6605c73cce..2aee41af42b 100644 --- a/src/main/ipc/workspace-space.test.ts +++ b/src/main/ipc/workspace-space.test.ts @@ -47,6 +47,7 @@ function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis { worktreeCount: 0, scannedWorktreeCount: 0, unavailableWorktreeCount: 0, + staleRegistrationCount: 0, repos: [], worktrees: [] } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 201a85c35aa..a3b1c0381a1 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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}`, diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 9e7ff509b88..4fa591bc72c 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -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 => { + 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 ( diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 4bca707a0fb..98f7241c314 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -672,6 +672,12 @@ export class SshGitProvider implements IGitProvider { ) } + async pruneWorktrees(repoPath: string): Promise { + await this.runWithDiffDedupeClear(async () => { + await this.mux.request('git.pruneWorktrees', { repoPath }) + }) + } + async worktreeIsClean( worktreePath: string, options: { includeUntracked?: boolean } = {} diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index 86af032aefb..2a488943344 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -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 () => { diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index cc792202fb8..c29e4a27b73 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -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 } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 92ba7bd92d9..d94ae47d719 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1183,6 +1183,9 @@ export type PreloadApi = { worktreeId: string hostId?: ExecutionHostId }) => Promise + // 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 forceDeletePreservedBranch: (args: { worktreeId: string branchName: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 05e1d62dafe..5026aa94af6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 6872c5f5bbd..fe1496597d0 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -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) { + 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) { return worktreeIsCleanOp(this.git.bind(this), params) } diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index dcfce654488..250d7822042 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -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('size') @@ -1773,6 +1810,46 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { ) : null} + {analysis && analysis.staleRegistrationCount > 0 ? ( +
+
+ + + {translate( + 'auto.components.status.bar.WorkspaceSpaceManagerPanel.7edf0a0a79', + '{{value0}} stale worktree {{value1}} from deleted directories.', + { + value0: analysis.staleRegistrationCount, + value1: analysis.staleRegistrationCount === 1 ? 'registration' : 'registrations' + } + )} + +
+ +
+ ) : null} + {hasRows || isInitialScan ? (
Promise refreshWorkspaceSpace: () => Promise removeWorkspaceSpaceWorktrees: (worktreeIds: readonly string[]) => void + pruneStaleWorktreeRegistrations: (repoIds: readonly string[]) => Promise } function removeDeletedWorktreesFromAnalysis( @@ -160,5 +161,15 @@ export const createWorkspaceSpaceSlice: StateCreator { + if (repoIds.length === 0) { + return + } + get().recordFeatureInteraction?.('workspace-cleanup') + for (const repoId of repoIds) { + await window.api.worktrees.pruneStaleRegistrations({ repoId }) + } + await get().refreshWorkspaceSpace() } }) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index ac19ddce7c8..aed99937cec 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1437,6 +1437,9 @@ function createWorktreesApi(): NonNullable['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('worktree.forceDeleteBranch', { worktree: toRuntimeWorktreeSelector(worktreeId), diff --git a/src/shared/workspace-space-types.ts b/src/shared/workspace-space-types.ts index bfd173db4eb..ddeffc57816 100644 --- a/src/shared/workspace-space-types.ts +++ b/src/shared/workspace-space-types.ts @@ -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[] }