mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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('\\', '/')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,6 +47,7 @@ function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis {
|
||||
worktreeCount: 0,
|
||||
scannedWorktreeCount: 0,
|
||||
unavailableWorktreeCount: 0,
|
||||
staleRegistrationCount: 0,
|
||||
repos: [],
|
||||
worktrees: []
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user