fix: recover missing worktree deletes (#4811)

This commit is contained in:
Jinjing 2026-06-07 11:01:26 -07:00 committed by GitHub
parent f9e8616bb6
commit bb40dcd028
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 94 additions and 9 deletions

View File

@ -4320,7 +4320,7 @@ describe('registerWorktreeHandlers', () => {
})
})
it('rejects unregistered delete paths before teardown, hooks, or git removal', async () => {
it('reports already-missing unregistered delete paths before teardown, hooks, or git removal', async () => {
mockKnownFeatureWorktree('/workspace/real-feature')
getEffectiveHooksMock.mockReturnValue({
scripts: {
@ -4332,7 +4332,9 @@ describe('registerWorktreeHandlers', () => {
handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/not-a-worktree'
})
).rejects.toThrow('Refusing to delete unregistered worktree path')
).rejects.toThrow(
'Worktree is no longer registered with Git and its directory is already gone.'
)
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
expect(runHookMock).not.toHaveBeenCalled()
@ -4363,6 +4365,27 @@ describe('registerWorktreeHandlers', () => {
})
})
it('cleans up an already-missing unregistered worktree after force recovery', async () => {
const worktreeId = 'repo-1::/workspace/already-deleted-wt'
mockKnownFeatureWorktree('/workspace/real-feature')
await expect(handlers['worktrees:remove'](null, { worktreeId })).rejects.toThrow(
'Worktree is no longer registered with Git and its directory is already gone.'
)
await handlers['worktrees:remove'](null, { worktreeId, force: true })
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
expect(runHookMock).not.toHaveBeenCalled()
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId)
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-1'
})
})
it('treats normal deletion of an already-missing unregistered worktree as cleanup', async () => {
mockKnownFeatureWorktree('/workspace/real-feature')
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())

View File

@ -90,7 +90,8 @@ import {
findRegisteredDeletableWorktree,
isWorktreePathMissing,
ORPHANED_WORKTREE_DIRECTORY_MESSAGE,
stripOrcaProvenanceMetaUpdates
stripOrcaProvenanceMetaUpdates,
UNREGISTERED_MISSING_WORKTREE_MESSAGE
} from '../worktree-removal-safety'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
@ -1123,10 +1124,12 @@ export function registerWorktreeHandlers(
notifyWorktreesChanged(mainWindow, repoId)
return {}
}
if (
(args.force || removedMeta) &&
(await isAlreadyRemovedWorktreePath(repo, worktreePath))
) {
if (await isAlreadyRemovedWorktreePath(repo, worktreePath)) {
if (!args.force && !removedMeta) {
// Why: without persisted metadata, require the renderer recovery
// path before deleting Orca-only state for an unregistered path.
throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE)
}
// Why: a manually deleted worktree is already gone from Git and disk.
// The sidebar delete action has persisted metadata proving this was
// an Orca-known row, so no force confirmation is needed.

View File

@ -444,7 +444,8 @@ import {
findRegisteredDeletableWorktree,
isWorktreePathMissing,
ORPHANED_WORKTREE_DIRECTORY_MESSAGE,
stripOrcaProvenanceMetaUpdates
stripOrcaProvenanceMetaUpdates,
UNREGISTERED_MISSING_WORKTREE_MESSAGE
} from '../worktree-removal-safety'
import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch'
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
@ -10353,6 +10354,11 @@ export class OrcaRuntimeService {
return {}
}
if (await isRuntimeWorktreePathMissing(repo, removalTarget.path)) {
if (!force && !removedMeta) {
// Why: without persisted metadata, require the renderer recovery
// path before deleting Orca-only state for an unregistered path.
throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE)
}
// Why: a manually deleted worktree is already gone from Git and disk.
// Finish runtime metadata cleanup without requiring force or touching
// any unregistered path that still exists.

View File

@ -38,6 +38,8 @@ type UnregisteredOrcaCleanupMeta = Pick<
export const ORPHANED_WORKTREE_DIRECTORY_MESSAGE =
'Worktree is no longer registered with Git but its directory remains.'
export const UNREGISTERED_MISSING_WORKTREE_MESSAGE =
'Worktree is no longer registered with Git and its directory is already gone.'
function getPathOps(...paths: string[]): PathOps {
// Why: forward-slash UNC roots need win32 ops; POSIX joins collapse `//Server` to `/Server`.

View File

@ -25,6 +25,20 @@ describe('getDeleteWorktreeToastCopy', () => {
})
})
it('uses stale-row guidance when Git already removed the worktree directory', () => {
expect(
getDeleteWorktreeToastCopy(
'feature/foo',
true,
'Worktree is no longer registered with Git and its directory is already gone.'
)
).toEqual({
title: 'Failed to delete workspace feature/foo',
description: 'Git already removed this workspace. Use Force Delete to clear it from Orca.',
isDestructive: false
})
})
it('preserves the raw error when force delete is unavailable', () => {
expect(getDeleteWorktreeToastCopy('feature/foo', false, 'permission denied')).toEqual({
title: 'Failed to delete workspace feature/foo',

View File

@ -18,6 +18,15 @@ export function getDeleteWorktreeToastCopy(
isDestructive: false
}
}
if (
error.includes('Worktree is no longer registered with Git and its directory is already gone.')
) {
return {
title: `Failed to delete workspace ${worktreeName}`,
description: 'Git already removed this workspace. Use Force Delete to clear it from Orca.',
isDestructive: false
}
}
return {
title: `Failed to delete workspace ${worktreeName}`,
description: 'It has changed files. Use Force Delete to delete it anyway.',

View File

@ -320,6 +320,33 @@ describe('removeWorktree cascade', () => {
})
})
it('offers force delete when Git already removed an unregistered worktree', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/workspace/deleted-wt'
const error =
"Error invoking remote method 'worktrees:remove': Error: Worktree is no longer registered with Git and its directory is already gone."
mockApi.worktrees.remove.mockRejectedValueOnce(new Error(error))
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
},
tabsByWorktree: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {}
})
const result = await store.getState().removeWorktree(worktreeId)
expect(result).toEqual({ ok: false, error })
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
isDeleting: false,
error,
canForceDelete: true
})
})
it('sets canForceDelete=false when force=true removal fails', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'

View File

@ -237,7 +237,8 @@ function toVisibleTabType(contentType: string): WorkspaceVisibleTabType {
const FORCE_RETRYABLE_WORKTREE_REMOVAL_MESSAGES = [
'Worktree has uncommitted or untracked changes',
'contains modified or untracked files',
'Worktree is no longer registered with Git but its directory remains'
'Worktree is no longer registered with Git but its directory remains',
'Worktree is no longer registered with Git and its directory is already gone'
] as const
// Why: local preflight formatting can surface raw git porcelain instead of the