From 98a2b626566bbca841f7be598d569aff9428c419 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:01:54 -0700 Subject: [PATCH 1/9] fix(worktrees): keep a workspace Windows refused to delete, and say why Deleting a workspace on Windows can fail with `EBUSY: resource busy or locked, rmdir ''` (STA-4895). Two things were wrong with what happened next. The message named the folder but not the cause, and there is nothing to retry: Windows refuses `rmdir` on a directory any process still has open, and a process's own current directory counts, so a shell or dev server left sitting in the workspace makes it permanently undeletable. The failure is now classified when it lands on the workspace directory itself -- not on a file inside it -- and the toast says what to close. Worse, the orphaned-worktree cleanup swallowed that failure with an empty `.catch`, then purged Orca's metadata and reported success. Every file was still on disk while the workspace vanished from the sidebar, leaving no UI to retry from. Both copies of that path (the IPC handler and the runtime service) now surface the failure and keep the row. --- src/main/ipc/worktree-logic.test.ts | 62 +++++++++++++++ src/main/ipc/worktree-logic.ts | 16 +++- .../ipc/worktrees-removal-recovery.test.ts | 55 +++++++++++++ .../remove-registered-local-worktree.ts | 20 ++++- src/main/runtime/orca-runtime.test.ts | 43 +++++++++++ src/main/runtime/orca-runtime.ts | 17 +++- src/shared/worktree/removal.ts | 77 +++++++++++++++++++ 7 files changed, 285 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index ae76b28da0c..a0795bd8148 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -21,6 +21,7 @@ import { isOrphanedWorktreeError, areWorktreePathsEqual } from './worktree-logic' +import { WORKSPACE_DIRECTORY_HELD_HINT } from '../../shared/worktree/removal' describe('sanitizeWorktreeName', () => { it('replaces spaces with hyphens', () => { @@ -629,6 +630,67 @@ describe('formatWorktreeRemovalError', () => { `Failed to delete worktree at ${path}.` ) }) + + // STA-4895: the reported toast was a bare `EBUSY ... rmdir ''` with nothing the + // user could act on. These pin the diagnosis onto exactly the root-held shape and no other. + describe('Windows workspace directory still held open', () => { + const windowsPath = 'C:/_Data/Projects/worktrees/WowApps-clientadmin-compact-sidebar' + const nativePath = 'C:\\_Data\\Projects\\worktrees\\WowApps-clientadmin-compact-sidebar' + + function heldRootError(code: string, removalPath: string): Error { + return Object.assign(new Error(`${code}: resource busy or locked, rmdir '${removalPath}'`), { + code, + syscall: 'rmdir', + path: removalPath + }) + } + + it('explains an EBUSY rmdir that failed on the workspace directory itself', () => { + expect( + formatWorktreeRemovalError(heldRootError('EBUSY', nativePath), windowsPath, false) + ).toBe( + `Failed to delete worktree at ${windowsPath}. EBUSY: resource busy or locked, rmdir '${nativePath}' ${WORKSPACE_DIRECTORY_HELD_HINT}` + ) + }) + + it('matches the extended-length path Orca actually passes to the delete', () => { + const namespaced = `\\\\?\\${nativePath}` + expect( + formatWorktreeRemovalError(heldRootError('EPERM', namespaced), windowsPath, true) + ).toContain(WORKSPACE_DIRECTORY_HELD_HINT) + }) + + it('explains the failure when it arrives as prose with no error code', () => { + const message = `EBUSY: resource busy or locked, rmdir '${nativePath}'` + expect(formatWorktreeRemovalError(new Error(message), windowsPath, false)).toBe( + `Failed to delete worktree at ${windowsPath}. ${message} ${WORKSPACE_DIRECTORY_HELD_HINT}` + ) + }) + + it('stays silent when a file inside the workspace is what failed', () => { + const child = `${nativePath}\\node_modules\\.vite\\deps` + expect( + formatWorktreeRemovalError(heldRootError('EBUSY', child), windowsPath, false) + ).not.toContain(WORKSPACE_DIRECTORY_HELD_HINT) + }) + + it('stays silent for a POSIX workspace root that reports the same code', () => { + const posixRoot = '/workspaces/feature' + expect( + formatWorktreeRemovalError(heldRootError('EBUSY', posixRoot), posixRoot, false) + ).not.toContain(WORKSPACE_DIRECTORY_HELD_HINT) + }) + + it('does not repeat the hint when an already-formatted message is reformatted', () => { + const formatted = formatWorktreeRemovalError( + heldRootError('EBUSY', nativePath), + windowsPath, + false + ) + const reformatted = formatWorktreeRemovalError(new Error(formatted), windowsPath, false) + expect(reformatted.split(WORKSPACE_DIRECTORY_HELD_HINT)).toHaveLength(2) + }) + }) }) describe('isOrphanedWorktreeError', () => { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index d5ccb345742..3c55209f3a3 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -4,6 +4,11 @@ import type { Repo } from '../../shared/repo-types' import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath, resolveWslRepoWorktreeBasePath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree/id' +import { + isHeldWorkspaceDirectoryRemovalError, + isHeldWorkspaceDirectoryRemovalFailure, + WORKSPACE_DIRECTORY_HELD_HINT +} from '../../shared/worktree/removal' import { replaceKnownEmojiWithShortcodes } from '../../shared/emoji-shortcode-catalog' import { getWslHome, getWslHomeAsync, parseWslPath } from '../wsl' @@ -339,5 +344,14 @@ export function formatWorktreeRemovalError( .map((value) => value?.trim()) .find(Boolean) - return details ? `${fallback} ${details}` : fallback + const message = details ? `${fallback} ${details}` : fallback + if (isHeldWorkspaceDirectoryRemovalError(message)) { + return message + } + // Why here and not at the delete: this is the one funnel every removal throw site already + // uses, so the diagnosis reaches the toast no matter which of them raised the failure. + return isHeldWorkspaceDirectoryRemovalFailure(error, worktreePath) || + (details !== undefined && isHeldWorkspaceDirectoryRemovalFailure(details, worktreePath)) + ? `${message} ${WORKSPACE_DIRECTORY_HELD_HINT}` + : message } diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index 49dc7b633bd..a51a6d14b10 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -276,6 +276,61 @@ describe('registerWorktreeHandlers', () => { }) }) + // STA-4895: a recursive delete that fails leaves every file on disk, so dropping Orca's row + // here reported a deletion that never happened and left no UI to retry from. + it('keeps the workspace when orphan cleanup cannot delete the directory', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-orphan-busy-')) + const repoPath = join(parentDir, 'repo') + const worktreePath = join(parentDir, 'feature-wt') + const adminDir = join(repoPath, '.git', 'worktrees', 'feature-wt') + await mkdir(adminDir, { recursive: true }) + await mkdir(worktreePath, { recursive: true }) + await writeFile(join(adminDir, 'gitdir'), `${join(worktreePath, '.git')}\n`) + await writeFile(join(worktreePath, '.git'), `gitdir: ${adminDir}\n`) + const repo = { + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null }) + mockKnownFeatureWorktree(worktreePath, repoPath) + getEffectiveHooksMock.mockReturnValue(null) + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + removeWorktreeMock.mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: `fatal: '${worktreePath}' is not a working tree` + }) + ) + const removePathSpy = vi + .spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath') + .mockRejectedValue( + Object.assign(new Error(`EBUSY: resource busy or locked, rmdir '${worktreePath}'`), { + code: 'EBUSY', + syscall: 'rmdir', + path: worktreePath + }) + ) + const worktreeId = `repo-1::${worktreePath}` + + try { + await expect(handlers['worktrees:remove'](null, { worktreeId })).rejects.toThrow( + `Failed to delete worktree at ${worktreePath}. EBUSY: resource busy or locked, rmdir '${worktreePath}'` + ) + + expect(removePathSpy).toHaveBeenCalledWith(worktreePath, {}) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + } finally { + removePathSpy.mockRestore() + await rm(parentDir, { recursive: true, force: true }) + } + }) + it('recovers forced Windows long-path worktree removal through local deletion and prune', async () => { setPlatform('win32') const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-long-path-')) diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index 2b81078b1dd..caf05b1731d 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -159,6 +159,7 @@ export async function removeRegisteredLocalWorktree( console.warn( `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` ) + let directoryRemovalError: unknown const access = getLocalWorktreePathAccess(localWorktreeGitOptions) if ( await canSafelyRemoveOrphanedWorktreeDirectory( @@ -169,8 +170,12 @@ export async function removeRegisteredLocalWorktree( ) ) { await runtime.closeFileWatchersForRemoval(canonicalWorktreePath) - await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( - () => {} + directoryRemovalError = await removeLocalWorktreePath( + canonicalWorktreePath, + localWorktreeGitOptions + ).then( + () => undefined, + (error: unknown) => error ?? new Error('Recursive worktree directory removal failed.') ) } else { console.warn( @@ -182,6 +187,17 @@ export async function removeRegisteredLocalWorktree( cwd: repo.path, ...localWorktreeGitOptions }).catch(() => {}) + // Why (STA-4895): the files are still on disk, so dropping Orca's row here would report a + // delete that did not happen and leave the workspace with no UI left to retry from. + if (directoryRemovalError) { + throw new Error( + formatWorktreeRemovalError( + directoryRemovalError, + canonicalWorktreePath, + args.force ?? false + ) + ) + } await cleanupUnusedWorktreePushTargetRemote( repo.path, args.worktreeId, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 95bade079aa..f126b1a8eef 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -49979,6 +49979,49 @@ describe('OrcaRuntimeService', () => { } }) + // STA-4895: mirrors the IPC guard -- a delete the filesystem refused must not read as removal. + it('keeps the runtime workspace when orphan cleanup cannot delete the directory', async () => { + const removeWorktreeMeta = vi.fn() + const runtime = createWorktreeRemovalRuntime({ ...store, removeWorktreeMeta }) + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(removeWorktree).mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: `fatal: '${TEST_WORKTREE_PATH}' is not a working tree` + }) + ) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ + stdout: '', + stderr: '' + }) + const adminDir = join(TEST_REPO_PATH, '.git', 'worktrees', basename(TEST_WORKTREE_PATH)) + await mkdir(adminDir, { recursive: true }) + await mkdir(TEST_WORKTREE_PATH, { recursive: true }) + await writeFile(join(adminDir, 'gitdir'), `${join(TEST_WORKTREE_PATH, '.git')}\n`) + await writeFile(join(TEST_WORKTREE_PATH, '.git'), `gitdir: ${adminDir}\n`) + const removePathSpy = vi + .spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath') + .mockRejectedValue( + Object.assign(new Error(`EBUSY: resource busy or locked, rmdir '${TEST_WORKTREE_PATH}'`), { + code: 'EBUSY', + syscall: 'rmdir', + path: TEST_WORKTREE_PATH + }) + ) + + try { + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).rejects.toThrow( + `Failed to delete worktree at ${TEST_WORKTREE_PATH}. EBUSY: resource busy or locked, rmdir '${TEST_WORKTREE_PATH}'` + ) + expect(removePathSpy).toHaveBeenCalledWith(TEST_WORKTREE_PATH, {}) + expect(removeWorktreeMeta).not.toHaveBeenCalled() + } finally { + removePathSpy.mockRestore() + gitSpy.mockRestore() + await rm(TEST_WORKTREE_PATH, { recursive: true, force: true }) + await rm(join(TEST_REPO_PATH, '.git'), { recursive: true, force: true }) + } + }) + it('refuses runtime Windows recovery while Git still reports the row and keeps metadata', async () => { setPlatform('win32') const removeWorktreeMeta = vi.fn() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3fd419cb69b..db617d1c9fd 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -29748,6 +29748,7 @@ export class OrcaRuntimeService { removalCompleted = true } else if (isOrphanedWorktreeError(error)) { const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + let directoryRemovalError: unknown if ( await canSafelyRemoveOrphanedWorktreeDirectory( toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), @@ -29757,8 +29758,13 @@ export class OrcaRuntimeService { ) ) { await this.closeFileWatchersForRemoval(canonicalWorktreePath) - await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( - () => {} + directoryRemovalError = await removeLocalWorktreePath( + canonicalWorktreePath, + localWorktreeGitOptions + ).then( + () => undefined, + (removalError: unknown) => + removalError ?? new Error('Recursive worktree directory removal failed.') ) } else { console.warn( @@ -29773,6 +29779,13 @@ export class OrcaRuntimeService { cwd: repo.path, ...localWorktreeGitOptions }).catch(() => {}) + // Why (STA-4895): the files are still on disk, so dropping Orca's row here would + // report a delete that did not happen and leave no UI left to retry from. + if (directoryRemovalError) { + throw new Error( + formatWorktreeRemovalError(directoryRemovalError, canonicalWorktreePath, force) + ) + } await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, diff --git a/src/shared/worktree/removal.ts b/src/shared/worktree/removal.ts index 8f5a6c4a4d2..2b25bf87d01 100644 --- a/src/shared/worktree/removal.ts +++ b/src/shared/worktree/removal.ts @@ -1,3 +1,7 @@ +import { + isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison +} from '../cross-platform-path' import type { ExecutionHostId } from '../execution-host' import type { GitWorktreeInfo, Worktree } from './types' @@ -46,6 +50,79 @@ export function isProvenLivePtyRemovalError(error: string): boolean { ) } +// Why (STA-4895): Windows refuses `rmdir` on a directory any process still has open, and +// a process's own current directory counts — so a shell or dev server left sitting in the +// workspace makes it undeletable. The raw `EBUSY: resource busy or locked, rmdir ''` +// names the folder but not the cause, and no retry can clear it, so the delete just fails +// again. Hint and matcher stay together for the same reason the force hint does. +export const WORKSPACE_DIRECTORY_HELD_HINT = + 'Windows would not delete the workspace folder because a program still has it open — most often a terminal, editor, or dev server whose current folder is the workspace. Close whatever is using the folder, then delete it again.' + +export function isHeldWorkspaceDirectoryRemovalError(error: string): boolean { + return error.includes(WORKSPACE_DIRECTORY_HELD_HINT) +} + +// EPERM/EACCES join EBUSY because libuv maps Windows sharing and access violations onto all three. +const HELD_DIRECTORY_ERROR_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']) + +// `EBUSY: resource busy or locked, rmdir 'C:\\...'` — the shape once the error is only prose. +const HELD_DIRECTORY_MESSAGE_PATTERN = /\b(?:EBUSY|EPERM|EACCES)\b[^\n]*?,\s*rmdir\s+'([^']+)'/ + +/** Undo `path.win32.toNamespacedPath` so a namespaced delete target compares to the plain workspace path. */ +function stripExtendedLengthPrefix(value: string): string { + const uncPath = /^\\\\\?\\UNC\\([\s\S]+)$/i.exec(value) + if (uncPath) { + return `\\\\${uncPath[1]}` + } + return value.replace(/^\\\\\?\\/, '') +} + +function isSameWorkspaceDirectory(removalPath: string, workspacePath: string): boolean { + return ( + normalizeRuntimePathForComparison(stripExtendedLengthPrefix(removalPath)) === + normalizeRuntimePathForComparison(workspacePath) + ) +} + +/** + * True only when the delete failed on the workspace directory ITSELF, not on something inside it. + * + * Why that distinction: a child that fails is an ordinary busy file, but the root failing means a + * handle is open on the folder — on Windows, the classic one being a process whose current + * directory it is. Decided by path syntax, never `process.platform`, so a Windows workspace driven + * from another client still classifies. + */ +export function isHeldWorkspaceDirectoryRemovalFailure( + error: unknown, + workspacePath: string +): boolean { + if (!isWindowsAbsolutePathLike(workspacePath)) { + return false + } + if (typeof error === 'object' && error !== null) { + const { code, syscall, path } = error as { + code?: unknown + syscall?: unknown + path?: unknown + } + if ( + typeof code === 'string' && + HELD_DIRECTORY_ERROR_CODES.has(code) && + syscall === 'rmdir' && + typeof path === 'string' && + isSameWorkspaceDirectory(path, workspacePath) + ) { + return true + } + } + // Why also prose: the same failure reaches some callers only as a formatted message, and a + // structural-only matcher would leave exactly the reported toast unclassified. + const message = + error instanceof Error ? error.message : typeof error === 'string' ? error : undefined + const quotedPath = message ? HELD_DIRECTORY_MESSAGE_PATTERN.exec(message)?.[1] : undefined + return quotedPath !== undefined && isSameWorkspaceDirectory(quotedPath, workspacePath) +} + export function createLockedWorktreeRemovalError(lockReason?: string): Error { const reason = lockReason?.trim() return new Error( From 4e0ee5a9c75b7d93554a1f9a995545eed2f4dae3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:27:16 -0700 Subject: [PATCH 2/9] fix(worktrees): fail closed when orphan cleanup is refused --- src/main/ipc/worktree-logic.test.ts | 13 +++++-- src/main/ipc/worktree-logic.ts | 16 +++++--- .../ipc/worktrees-removal-recovery.test.ts | 38 +++++++++++++++++++ .../remove-registered-local-worktree.ts | 11 +++++- src/main/runtime/orca-runtime.test.ts | 30 +++++++++++++++ src/main/runtime/orca-runtime.ts | 8 ++++ src/main/worktree-removal-safety.ts | 2 + src/shared/worktree/removal.ts | 2 +- 8 files changed, 110 insertions(+), 10 deletions(-) diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index a0795bd8148..31b93b2ea43 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -597,9 +597,9 @@ describe('parseWorktreeId', () => { describe('formatWorktreeRemovalError', () => { const path = '/workspaces/feature' - it('returns fallback for non-Error input', () => { + it('includes raw string errors', () => { expect(formatWorktreeRemovalError('oops', path, false)).toBe( - `Failed to delete worktree at ${path}.` + `Failed to delete worktree at ${path}. oops` ) }) @@ -619,7 +619,7 @@ describe('formatWorktreeRemovalError', () => { it('uses force text when force is true', () => { expect(formatWorktreeRemovalError('oops', path, true)).toBe( - `Failed to force delete worktree at ${path}.` + `Failed to force delete worktree at ${path}. oops` ) }) @@ -667,6 +667,13 @@ describe('formatWorktreeRemovalError', () => { ) }) + it('explains a raw string failure from the workspace directory itself', () => { + const message = `EBUSY: resource busy or locked, rmdir '${nativePath}'` + expect(formatWorktreeRemovalError(message, windowsPath, false)).toBe( + `Failed to delete worktree at ${windowsPath}. ${message} ${WORKSPACE_DIRECTORY_HELD_HINT}` + ) + }) + it('stays silent when a file inside the workspace is what failed', () => { const child = `${nativePath}\\node_modules\\.vite\\deps` expect( diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 3c55209f3a3..2b2b8ba834a 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -335,17 +335,23 @@ export function formatWorktreeRemovalError( ? `Failed to force delete worktree at ${worktreePath}.` : `Failed to delete worktree at ${worktreePath}.` - if (!(error instanceof Error)) { + if (!(error instanceof Error) && typeof error !== 'string') { return fallback } const errorWithStreams = error as Error & { stderr?: string; stdout?: string } - const details = [errorWithStreams.stderr, errorWithStreams.stdout, error.message] - .map((value) => value?.trim()) - .find(Boolean) + const details = + typeof error === 'string' + ? error.trim() + : [errorWithStreams.stderr, errorWithStreams.stdout, error.message] + .map((value) => value?.trim()) + .find(Boolean) const message = details ? `${fallback} ${details}` : fallback - if (isHeldWorkspaceDirectoryRemovalError(message)) { + if ( + isHeldWorkspaceDirectoryRemovalError(message) || + message.includes(WORKSPACE_DIRECTORY_HELD_HINT) + ) { return message } // Why here and not at the delete: this is the one funnel every removal throw site already diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index a51a6d14b10..bb992c57afe 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -331,6 +331,44 @@ describe('registerWorktreeHandlers', () => { } }) + it('keeps an existing orphan directory when its ownership cannot be proven', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-orphan-unproven-')) + const repoPath = join(parentDir, 'repo') + const worktreePath = join(parentDir, 'feature-wt') + await mkdir(join(worktreePath, '.git'), { recursive: true }) + const repo = { + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null }) + mockKnownFeatureWorktree(worktreePath, repoPath) + getEffectiveHooksMock.mockReturnValue(null) + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + removeWorktreeMock.mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: `fatal: '${worktreePath}' is not a working tree` + }) + ) + const removePathSpy = vi.spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath') + const worktreeId = `repo-1::${worktreePath}` + + try { + await expect(handlers['worktrees:remove'](null, { worktreeId })).rejects.toThrow( + 'Orca could not prove that its directory is safe to delete' + ) + await expect(lstat(worktreePath)).resolves.toBeTruthy() + expect(removePathSpy).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + } finally { + removePathSpy.mockRestore() + await rm(parentDir, { recursive: true, force: true }) + } + }) + it('recovers forced Windows long-path worktree removal through local deletion and prune', async () => { setPlatform('win32') const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-long-path-')) diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index caf05b1731d..3d9c11fac3f 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -20,7 +20,9 @@ import { recoverLocalWindowsWorktreeRemoval } from '../../../local-worktree-remo import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' import { canSafelyRemoveOrphanedWorktreeDirectory, - findRegisteredDeletableWorktree + findRegisteredDeletableWorktree, + isWorktreePathMissing, + UNPROVEN_ORPHANED_WORKTREE_DIRECTORY_MESSAGE } from '../../../worktree-removal-safety' import { cleanupUnusedWorktreePushTargetRemote, @@ -178,6 +180,13 @@ export async function removeRegisteredLocalWorktree( (error: unknown) => error ?? new Error('Recursive worktree directory removal failed.') ) } else { + const runtimeWorktreePath = toLocalWorktreeRuntimePath( + canonicalWorktreePath, + localWorktreeGitOptions + ) + if (!(await isWorktreePathMissing(runtimeWorktreePath, access.statPath))) { + directoryRemovalError = new Error(UNPROVEN_ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } console.warn( `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` ) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index f126b1a8eef..52eb9f7daa4 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -50022,6 +50022,36 @@ describe('OrcaRuntimeService', () => { } }) + it('keeps an existing runtime orphan directory when its ownership cannot be proven', async () => { + const removeWorktreeMeta = vi.fn() + const runtime = createWorktreeRemovalRuntime({ ...store, removeWorktreeMeta }) + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(removeWorktree).mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: `fatal: '${TEST_WORKTREE_PATH}' is not a working tree` + }) + ) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ + stdout: '', + stderr: '' + }) + await mkdir(join(TEST_WORKTREE_PATH, '.git'), { recursive: true }) + const removePathSpy = vi.spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath') + + try { + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).rejects.toThrow( + 'Orca could not prove that its directory is safe to delete' + ) + await expect(lstat(TEST_WORKTREE_PATH)).resolves.toBeTruthy() + expect(removePathSpy).not.toHaveBeenCalled() + expect(removeWorktreeMeta).not.toHaveBeenCalled() + } finally { + removePathSpy.mockRestore() + gitSpy.mockRestore() + await rm(TEST_WORKTREE_PATH, { recursive: true, force: true }) + } + }) + it('refuses runtime Windows recovery while Git still reports the row and keeps metadata', async () => { setPlatform('win32') const removeWorktreeMeta = vi.fn() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index db617d1c9fd..388a7476c1d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1249,6 +1249,7 @@ import { isWorktreePathMissing, ORPHANED_WORKTREE_DIRECTORY_MESSAGE, stripOrcaProvenanceMetaUpdates, + UNPROVEN_ORPHANED_WORKTREE_DIRECTORY_MESSAGE, UNREGISTERED_MISSING_WORKTREE_MESSAGE } from '../worktree-removal-safety' import { @@ -29767,6 +29768,13 @@ export class OrcaRuntimeService { removalError ?? new Error('Recursive worktree directory removal failed.') ) } else { + const runtimeWorktreePath = toLocalWorktreeRuntimePath( + canonicalWorktreePath, + localWorktreeGitOptions + ) + if (!(await isWorktreePathMissing(runtimeWorktreePath, access.statPath))) { + directoryRemovalError = new Error(UNPROVEN_ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } console.warn( `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` ) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index b37a2477bc2..8711a7fe3d7 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -44,6 +44,8 @@ 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.' +export const UNPROVEN_ORPHANED_WORKTREE_DIRECTORY_MESSAGE = + 'Worktree is no longer registered with Git, but Orca could not prove that its directory is safe to delete. The directory remains; verify the path and remove it manually.' function getPathOps(...paths: string[]): PathOps { // Why: forward-slash UNC roots need win32 ops; POSIX joins collapse `//Server` to `/Server`. diff --git a/src/shared/worktree/removal.ts b/src/shared/worktree/removal.ts index 2b25bf87d01..966d102ee3d 100644 --- a/src/shared/worktree/removal.ts +++ b/src/shared/worktree/removal.ts @@ -56,7 +56,7 @@ export function isProvenLivePtyRemovalError(error: string): boolean { // names the folder but not the cause, and no retry can clear it, so the delete just fails // again. Hint and matcher stay together for the same reason the force hint does. export const WORKSPACE_DIRECTORY_HELD_HINT = - 'Windows would not delete the workspace folder because a program still has it open — most often a terminal, editor, or dev server whose current folder is the workspace. Close whatever is using the folder, then delete it again.' + 'Windows would not delete the workspace folder because a program may still have it open or access was denied. Close any terminal, editor, or dev server whose current folder is the workspace, then delete it again; if nothing is using it, check the folder permissions.' export function isHeldWorkspaceDirectoryRemovalError(error: string): boolean { return error.includes(WORKSPACE_DIRECTORY_HELD_HINT) From f19087d01445e735190fd4766f4f794ab881dcd3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:54:31 -0700 Subject: [PATCH 3/9] test(worktrees): pin the refused-orphan guard from both sides The refusal guard now throws when the directory survives, but nothing pinned the other half: a guard that refused unconditionally read as correct. Add the mirror control at both call sites -- directory already gone, so forgetting the row is the honest outcome -- plus the negative half of the raw-string arm (a child path is reported without claiming the folder itself is held). Also drop an exactly-duplicate disjunct: isHeldWorkspaceDirectoryRemovalError is message.includes(WORKSPACE_DIRECTORY_HELD_HINT). --- src/main/ipc/worktree-logic.test.ts | 8 ++++ src/main/ipc/worktree-logic.ts | 5 +-- .../ipc/worktrees-removal-recovery.test.ts | 38 +++++++++++++++++++ src/main/runtime/orca-runtime.test.ts | 28 ++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 31b93b2ea43..23e3188c2e1 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -674,6 +674,14 @@ describe('formatWorktreeRemovalError', () => { ) }) + // The negative half of the raw-string arm: reported, but not misdiagnosed. + it('reports a raw string failure for a child path without claiming the folder is held', () => { + const message = `EBUSY: resource busy or locked, rmdir '${nativePath}\\node_modules'` + expect(formatWorktreeRemovalError(message, windowsPath, false)).toBe( + `Failed to delete worktree at ${windowsPath}. ${message}` + ) + }) + it('stays silent when a file inside the workspace is what failed', () => { const child = `${nativePath}\\node_modules\\.vite\\deps` expect( diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 2b2b8ba834a..d617455b8e8 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -348,10 +348,7 @@ export function formatWorktreeRemovalError( .find(Boolean) const message = details ? `${fallback} ${details}` : fallback - if ( - isHeldWorkspaceDirectoryRemovalError(message) || - message.includes(WORKSPACE_DIRECTORY_HELD_HINT) - ) { + if (isHeldWorkspaceDirectoryRemovalError(message)) { return message } // Why here and not at the delete: this is the one funnel every removal throw site already diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index bb992c57afe..90dcd59b3a1 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -369,6 +369,44 @@ describe('registerWorktreeHandlers', () => { } }) + // The mirror of the guard above: nothing is left on disk, so forgetting the row IS the + // honest outcome. Without this, a guard that refused unconditionally would read as correct. + it('forgets the workspace when a refused orphan cleanup has no directory left to delete', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-orphan-gone-')) + const repoPath = join(parentDir, 'repo') + const worktreePath = join(parentDir, 'feature-wt') + await mkdir(join(repoPath, '.git', 'worktrees', 'feature-wt'), { recursive: true }) + const repo = { + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null }) + mockKnownFeatureWorktree(worktreePath, repoPath) + getEffectiveHooksMock.mockReturnValue(null) + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + removeWorktreeMock.mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: `fatal: '${worktreePath}' is not a working tree` + }) + ) + const removePathSpy = vi.spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath') + const worktreeId = `repo-1::${worktreePath}` + + try { + await expect(handlers['worktrees:remove'](null, { worktreeId })).resolves.toEqual({}) + + expect(removePathSpy).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).toHaveBeenCalled() + } finally { + removePathSpy.mockRestore() + await rm(parentDir, { recursive: true, force: true }) + } + }) + it('recovers forced Windows long-path worktree removal through local deletion and prune', async () => { setPlatform('win32') const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-long-path-')) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 52eb9f7daa4..87f91fc25c9 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -50052,6 +50052,34 @@ describe('OrcaRuntimeService', () => { } }) + // The mirror of the guard above: nothing is left on disk, so forgetting the row IS the + // honest outcome. Without this, a guard that refused unconditionally would read as correct. + it('forgets the runtime workspace when a refused orphan cleanup left nothing on disk', async () => { + const removeWorktreeMeta = vi.fn() + const runtime = createWorktreeRemovalRuntime({ ...store, removeWorktreeMeta }) + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(removeWorktree).mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: `fatal: '${TEST_WORKTREE_PATH}' is not a working tree` + }) + ) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ + stdout: '', + stderr: '' + }) + await rm(TEST_WORKTREE_PATH, { recursive: true, force: true }) + const removePathSpy = vi.spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath') + + try { + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).resolves.toEqual({}) + expect(removePathSpy).not.toHaveBeenCalled() + expect(removeWorktreeMeta).toHaveBeenCalled() + } finally { + removePathSpy.mockRestore() + gitSpy.mockRestore() + } + }) + it('refuses runtime Windows recovery while Git still reports the row and keeps metadata', async () => { setPlatform('win32') const removeWorktreeMeta = vi.fn() From 76bb00e6d6cf6eecdd68a42b5189932d3876792d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:24:21 -0700 Subject: [PATCH 4/9] fix(worktrees): localize the held-workspace-directory delete toast The held-directory failure matches none of the force-delete reasons, so the toast fell through to the branch that renders the raw main-process error. The English hint appended in worktree-logic was therefore displayed to the user verbatim, untranslated, alongside the EBUSY prose and the native path. The hint stays where it is as the anchor its matcher keys on -- it crosses a process boundary and must not move -- and the toast now selects intent-named catalog copy for the case instead of echoing the wire text. --- .../sidebar/delete-worktree-toast.test.ts | 21 ++++++++++++++++++- .../sidebar/delete-worktree-toast.ts | 19 +++++++++++++++++ src/renderer/src/i18n/locales/en.json | 3 ++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts index dffedf06337..40c631ce902 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' -import { classifyWorktreeForceDeleteReason } from '../../../../shared/worktree/removal' +import { + classifyWorktreeForceDeleteReason, + WORKSPACE_DIRECTORY_HELD_HINT +} from '../../../../shared/worktree/removal' +import { translate } from '@/i18n/i18n' // Why: production never hands this function a literal reason — the store derives it from // classifyWorktreeForceDeleteReason (store/slices/worktrees.ts). Passing one in would let a @@ -143,4 +147,19 @@ describe('getDeleteWorktreeToastCopy', () => { isDestructive: false }) }) + // Why (STA-4895): the held-directory failure matches no force-delete reason, so it falls to + // the raw-error branch and the main process's English hint would be rendered verbatim. The + // hint stays as the wire anchor; what the user reads has to come from the catalog. + it('renders localized copy for a workspace directory Windows would not release', () => { + const error = `Failed to delete worktree at C:\\ws\\feature. EBUSY: resource busy or locked, rmdir 'C:\\ws\\feature' ${WORKSPACE_DIRECTORY_HELD_HINT}` + expect(classifyWorktreeForceDeleteReason(error)).toBeNull() + const copy = getDeleteWorktreeToastCopy('feature/foo', null, error) as { + description?: string + } + expect(copy.description).toBe( + translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') + ) + expect(copy.description).not.toContain('EBUSY') + expect(copy.description).not.toContain('C:\\ws\\feature') + }) }) diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.ts index 1c013da0da6..5dfa757ebfd 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.ts @@ -1,5 +1,6 @@ import { translate } from '@/i18n/i18n' import { + isHeldWorkspaceDirectoryRemovalError, isLockedWorktreeRemovalError, isProvenLivePtyRemovalError, type WorktreeForceDeleteReason @@ -37,6 +38,24 @@ export function getDeleteWorktreeToastCopy( } } + // Why (STA-4895): this failure matches no force-delete reason, so it would otherwise fall to + // the raw-error branch and render the main process's English hint verbatim. That hint stays + // put as the wire anchor the classifier matches on; the copy the user reads comes from here. + if (isHeldWorkspaceDirectoryRemovalError(error)) { + return { + title: translate( + 'auto.components.sidebar.delete.worktree.toast.1d0fa5c0a5', + 'Failed to delete workspace {{value0}}', + { value0: worktreeName } + ), + description: translate( + 'auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', + 'Windows would not delete the workspace folder because a program may still have it open or access was denied. Close any terminal, editor, or dev server whose current folder is the workspace, then delete it again; if nothing is using it, check the folder permissions.' + ), + isDestructive: true + } + } + if (forceDeleteReason) { if (forceDeleteReason === 'orphan-directory') { return { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6660bb6c200..92de0571491 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5683,7 +5683,8 @@ "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion.", "unstoppedPty": "Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.", - "unstoppedPtyLive": "This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold." + "unstoppedPtyLive": "This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold.", + "workspaceDirectoryHeld": "Windows would not delete the workspace folder because a program may still have it open or access was denied. Close any terminal, editor, or dev server whose current folder is the workspace, then delete it again; if nothing is using it, check the folder permissions." } } }, From 71962b1de0c36832fa1a18b06ed9fcc6a0732362 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:21:47 -0700 Subject: [PATCH 5/9] fix(worktrees): keep the Force Delete retry toast on the localized copy funnel The retry renders its own toast, so the main process's held-directory hint -- an English wire anchor, not display copy -- reached the user verbatim there even after the first delete toast was fixed. --- .../run-worktree-delete-with-toast.test.ts | 78 +++++++++++++++++++ .../sidebar/run-worktree-delete-with-toast.ts | 6 +- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts new file mode 100644 index 00000000000..7b56aba5c46 --- /dev/null +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts @@ -0,0 +1,78 @@ +/** + * STA-4895: the Force Delete retry renders its own toast, bypassing + * `getDeleteWorktreeToastCopy`. The held-workspace-directory hint is English text the + * main process appends as a wire anchor, so that bypass puts it in front of the user + * verbatim — the exact leak the first delete toast was fixed to close. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WORKSPACE_DIRECTORY_HELD_HINT } from '../../../../shared/worktree/removal' +import { translate } from '@/i18n/i18n' + +const toastError = vi.fn() +const removeWorktree = vi.fn() + +vi.mock('sonner', () => ({ toast: { error: (...args: unknown[]) => toastError(...args) } })) +vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) +vi.mock('./active-worktree-focus-after-delete', () => ({ + prepareActiveWorktreeFocusAfterDelete: () => vi.fn() +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + removeWorktree, + deleteStateByWorktreeId: { + 'repo-1::/ws/feature': { canForceDelete: true, forceDeleteReason: 'dirty' } + }, + gitStatusByWorktree: {}, + setRightSidebarTab: vi.fn(), + setRightSidebarOpen: vi.fn() + }) + } +})) + +const capturedForceHandlers: (() => void)[] = [] +vi.mock('./delete-worktree-failure-toast', () => ({ + showDeleteWorktreeFailureToast: (options: { onForceDelete: () => void }) => { + capturedForceHandlers.push(options.onForceDelete) + } +})) + +const { runWorktreeDeleteWithToast } = await import('./run-worktree-delete-with-toast') + +const HELD_ERROR = `Failed to force delete worktree at C:\\ws\\feature. EBUSY: resource busy or locked, rmdir 'C:\\ws\\feature' ${WORKSPACE_DIRECTORY_HELD_HINT}` + +describe('runWorktreeDeleteWithToast force-delete retry', () => { + beforeEach(() => { + toastError.mockClear() + removeWorktree.mockReset() + capturedForceHandlers.length = 0 + }) + + it('does not put the main process hint in front of the user when the retry fails', async () => { + removeWorktree.mockResolvedValueOnce({ ok: false, error: 'dirty' }) + await runWorktreeDeleteWithToast({ id: 'repo-1::/ws/feature' }, 'feature') + + removeWorktree.mockResolvedValueOnce({ ok: false, error: HELD_ERROR }) + capturedForceHandlers[0]?.() + await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) + + const description = (toastError.mock.calls[0]?.[1] as { description?: string })?.description + expect(description).toBe( + translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') + ) + expect(description).not.toContain('EBUSY') + }) + + it('still shows the raw failure for errors with no dedicated copy', async () => { + removeWorktree.mockResolvedValueOnce({ ok: false, error: 'dirty' }) + await runWorktreeDeleteWithToast({ id: 'repo-1::/ws/feature' }, 'feature') + + removeWorktree.mockResolvedValueOnce({ ok: false, error: 'fatal: some other git failure' }) + capturedForceHandlers[0]?.() + await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) + + expect((toastError.mock.calls[0]?.[1] as { description?: string })?.description).toBe( + 'fatal: some other git failure' + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts index 3f844d6f09d..517255e2499 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts @@ -5,6 +5,7 @@ import { translate } from '@/i18n/i18n' import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' +import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' import type { WorktreeDeleteWithToastOptions } from './worktree-delete-request' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' @@ -91,7 +92,10 @@ export function runWorktreeDeleteWithToast( 'Force delete failed' ), { - description: forceResult.error, + // Why (STA-4895): this retry renders its own toast, so without the shared + // funnel the main process's English hint reaches the user verbatim. + description: getDeleteWorktreeToastCopy(worktreeName, null, forceResult.error) + .description, action: { label: translate( 'auto.components.sidebar.delete.worktree.flow.7488ed8711', From 5eeb75c6f30f4a6dba928e35f17b04ccc3db7e53 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:31:13 -0700 Subject: [PATCH 6/9] fix(worktrees): localize the refused-orphan delete toast The held-directory hint was routed to catalog copy, but the sibling string this PR added on the same branch was not: a refused orphan cleanup throws UNPROVEN_ORPHANED_WORKTREE_DIRECTORY_MESSAGE, which matches no force-delete reason, so getDeleteWorktreeToastCopy fell to `description: error` and rendered the main process's English sentence verbatim in the toast body. Give it the same treatment -- wire anchor stays put, copy comes from the catalog -- and pin the two literals together so rewording either side cannot silently un-localize the toast again. Also repairs the two typecheck breaks the retry-toast test shipped with (WorktreeRemovalTarget requires executionHostId). --- src/main/ipc/worktree-logic.test.ts | 24 +++++++++++++++- .../sidebar/delete-worktree-toast.test.ts | 28 +++++++++++++++++++ .../sidebar/delete-worktree-toast.ts | 19 +++++++++++++ .../run-worktree-delete-with-toast.test.ts | 10 +++++-- src/renderer/src/i18n/locales/en.json | 3 +- src/shared/worktree/removal.ts | 11 ++++++++ 6 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 23e3188c2e1..63134f3d5a2 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -21,7 +21,11 @@ import { isOrphanedWorktreeError, areWorktreePathsEqual } from './worktree-logic' -import { WORKSPACE_DIRECTORY_HELD_HINT } from '../../shared/worktree/removal' +import { + isUnprovenOrphanedWorktreeDirectoryError, + WORKSPACE_DIRECTORY_HELD_HINT +} from '../../shared/worktree/removal' +import { UNPROVEN_ORPHANED_WORKTREE_DIRECTORY_MESSAGE } from '../worktree-removal-safety' describe('sanitizeWorktreeName', () => { it('replaces spaces with hyphens', () => { @@ -705,6 +709,24 @@ describe('formatWorktreeRemovalError', () => { const reformatted = formatWorktreeRemovalError(new Error(formatted), windowsPath, false) expect(reformatted.split(WORKSPACE_DIRECTORY_HELD_HINT)).toHaveLength(2) }) + + // Why (STA-4895): the toast picks this failure out of the raw-error branch by matching a + // clause of the message. Reword either side alone and the toast silently goes back to + // rendering this English sentence verbatim, so the two are pinned together here. + it('keeps the refused-orphan message matchable after the removal funnel formats it', () => { + expect( + isUnprovenOrphanedWorktreeDirectoryError(UNPROVEN_ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + ).toBe(true) + expect( + isUnprovenOrphanedWorktreeDirectoryError( + formatWorktreeRemovalError( + new Error(UNPROVEN_ORPHANED_WORKTREE_DIRECTORY_MESSAGE), + windowsPath, + false + ) + ) + ).toBe(true) + }) }) }) diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts index 40c631ce902..1022e664948 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' import { classifyWorktreeForceDeleteReason, + isUnprovenOrphanedWorktreeDirectoryError, WORKSPACE_DIRECTORY_HELD_HINT } from '../../../../shared/worktree/removal' import { translate } from '@/i18n/i18n' @@ -162,4 +163,31 @@ describe('getDeleteWorktreeToastCopy', () => { expect(copy.description).not.toContain('EBUSY') expect(copy.description).not.toContain('C:\\ws\\feature') }) + + // Why (STA-4895): the refused-orphan failure matches no force-delete reason either, so it lands + // on the same raw-error branch and would render the main process's English sentence verbatim. + it('renders localized copy when Orca refused to delete an unproven orphan directory', () => { + const error = + 'Failed to delete worktree at C:\\ws\\feature. Worktree is no longer registered with Git, but Orca could not prove that its directory is safe to delete. The directory remains; verify the path and remove it manually.' + expect(classifyWorktreeForceDeleteReason(error)).toBeNull() + const copy = getDeleteWorktreeToastCopy('feature/foo', null, error) as { + description?: string + } + expect(copy.description).toBe( + translate('auto.components.sidebar.delete.worktree.toast.unprovenOrphanDirectory', 'MISSING') + ) + expect(copy.description).not.toContain('could not prove') + expect(copy.description).not.toContain('C:\\ws\\feature') + }) + + // The other half: an over-broad anchor would swallow the two orphan messages that DO have a + // classifier, replacing their Force Delete copy with a dead end. + it('leaves the classified orphan messages to their own force-delete copy', () => { + for (const sibling of [ + 'Worktree is no longer registered with Git but its directory remains.', + 'Worktree is no longer registered with Git and its directory is already gone.' + ]) { + expect(isUnprovenOrphanedWorktreeDirectoryError(sibling)).toBe(false) + } + }) }) diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.ts index 5dfa757ebfd..3d0fdd4e31a 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.ts @@ -3,6 +3,7 @@ import { isHeldWorkspaceDirectoryRemovalError, isLockedWorktreeRemovalError, isProvenLivePtyRemovalError, + isUnprovenOrphanedWorktreeDirectoryError, type WorktreeForceDeleteReason } from '../../../../shared/worktree/removal' export type DeleteWorktreeToastCopy = { @@ -125,6 +126,24 @@ export function getDeleteWorktreeToastCopy( } } + // Why (STA-4895): Orca refusing an unproven orphan cleanup matches no force-delete reason, so + // without this it reaches the raw branch below and the main process's English sentence is what + // the user reads. Placed after the force-delete arms so it can never shadow an affordance's copy. + if (isUnprovenOrphanedWorktreeDirectoryError(error)) { + return { + title: translate( + 'auto.components.sidebar.delete.worktree.toast.1d0fa5c0a5', + 'Failed to delete workspace {{value0}}', + { value0: worktreeName } + ), + description: translate( + 'auto.components.sidebar.delete.worktree.toast.unprovenOrphanDirectory', + 'Git no longer tracks this workspace, but Orca could not confirm its folder was safe to delete, so it left the files on disk. Check the folder yourself and remove it once you are sure it is no longer needed.' + ), + isDestructive: true + } + } + return { title: translate( 'auto.components.sidebar.delete.worktree.toast.1d0fa5c0a5', diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts index 7b56aba5c46..a8fac304dd4 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts @@ -50,7 +50,10 @@ describe('runWorktreeDeleteWithToast force-delete retry', () => { it('does not put the main process hint in front of the user when the retry fails', async () => { removeWorktree.mockResolvedValueOnce({ ok: false, error: 'dirty' }) - await runWorktreeDeleteWithToast({ id: 'repo-1::/ws/feature' }, 'feature') + await runWorktreeDeleteWithToast( + { id: 'repo-1::/ws/feature', executionHostId: null }, + 'feature' + ) removeWorktree.mockResolvedValueOnce({ ok: false, error: HELD_ERROR }) capturedForceHandlers[0]?.() @@ -65,7 +68,10 @@ describe('runWorktreeDeleteWithToast force-delete retry', () => { it('still shows the raw failure for errors with no dedicated copy', async () => { removeWorktree.mockResolvedValueOnce({ ok: false, error: 'dirty' }) - await runWorktreeDeleteWithToast({ id: 'repo-1::/ws/feature' }, 'feature') + await runWorktreeDeleteWithToast( + { id: 'repo-1::/ws/feature', executionHostId: null }, + 'feature' + ) removeWorktree.mockResolvedValueOnce({ ok: false, error: 'fatal: some other git failure' }) capturedForceHandlers[0]?.() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 92de0571491..3bb0bda105d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5684,7 +5684,8 @@ "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion.", "unstoppedPty": "Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.", "unstoppedPtyLive": "This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold.", - "workspaceDirectoryHeld": "Windows would not delete the workspace folder because a program may still have it open or access was denied. Close any terminal, editor, or dev server whose current folder is the workspace, then delete it again; if nothing is using it, check the folder permissions." + "workspaceDirectoryHeld": "Windows would not delete the workspace folder because a program may still have it open or access was denied. Close any terminal, editor, or dev server whose current folder is the workspace, then delete it again; if nothing is using it, check the folder permissions.", + "unprovenOrphanDirectory": "Git no longer tracks this workspace, but Orca could not confirm its folder was safe to delete, so it left the files on disk. Check the folder yourself and remove it once you are sure it is no longer needed." } } }, diff --git a/src/shared/worktree/removal.ts b/src/shared/worktree/removal.ts index 966d102ee3d..c630a331782 100644 --- a/src/shared/worktree/removal.ts +++ b/src/shared/worktree/removal.ts @@ -62,6 +62,17 @@ export function isHeldWorkspaceDirectoryRemovalError(error: string): boolean { return error.includes(WORKSPACE_DIRECTORY_HELD_HINT) } +// Why (STA-4895): a refused orphan cleanup matches no force-delete reason, so the toast falls to +// its raw-error branch and renders the main process's English sentence. The clause is the wire +// anchor -- distinctive enough not to collide with the sibling "directory remains" message that +// classifyWorktreeForceDeleteReason already matches -- and the copy the user reads is localized. +const UNPROVEN_ORPHANED_DIRECTORY_ANCHOR = + 'Orca could not prove that its directory is safe to delete' + +export function isUnprovenOrphanedWorktreeDirectoryError(error: string): boolean { + return error.includes(UNPROVEN_ORPHANED_DIRECTORY_ANCHOR) +} + // EPERM/EACCES join EBUSY because libuv maps Windows sharing and access violations onto all three. const HELD_DIRECTORY_ERROR_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']) From 9779aafa41f19d893f427576c83b927ce9e60242 Mon Sep 17 00:00:00 2001 From: Brennan Benson Date: Sat, 29 Aug 2026 06:50:10 -0700 Subject: [PATCH 7/9] fix(worktrees): funnel every delete-toast error --- .../run-worktree-delete-with-toast.test.ts | 33 +++++++++++ .../sidebar/run-worktree-delete-with-toast.ts | 59 +++++-------------- .../show-delete-worktree-error-toast.ts | 40 +++++++++++++ 3 files changed, 87 insertions(+), 45 deletions(-) create mode 100644 src/renderer/src/components/sidebar/show-delete-worktree-error-toast.ts diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts index a8fac304dd4..f9105409859 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts @@ -66,6 +66,39 @@ describe('runWorktreeDeleteWithToast force-delete retry', () => { expect(description).not.toContain('EBUSY') }) + it('funnels a rejected force-delete retry before rendering its error', async () => { + removeWorktree.mockResolvedValueOnce({ ok: false, error: 'dirty' }) + await runWorktreeDeleteWithToast( + { id: 'repo-1::/ws/feature', executionHostId: null }, + 'feature' + ) + + removeWorktree.mockRejectedValueOnce(new Error(HELD_ERROR)) + capturedForceHandlers[0]?.() + await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) + + const description = (toastError.mock.calls[0]?.[1] as { description?: string })?.description + expect(description).toBe( + translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') + ) + expect(description).not.toContain('EBUSY') + }) + + it('funnels a rejected initial delete before rendering its error', async () => { + removeWorktree.mockRejectedValueOnce(new Error(HELD_ERROR)) + + await runWorktreeDeleteWithToast( + { id: 'repo-1::/ws/feature', executionHostId: null }, + 'feature' + ) + + const description = (toastError.mock.calls[0]?.[1] as { description?: string })?.description + expect(description).toBe( + translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') + ) + expect(description).not.toContain('EBUSY') + }) + it('still shows the raw failure for errors with no dedicated copy', async () => { removeWorktree.mockResolvedValueOnce({ ok: false, error: 'dirty' }) await runWorktreeDeleteWithToast( diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts index 517255e2499..685fcf251ed 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts @@ -1,11 +1,9 @@ -import { toast } from 'sonner' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { translate } from '@/i18n/i18n' import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' -import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' +import { showDeleteWorktreeErrorToast } from './show-delete-worktree-error-toast' import type { WorktreeDeleteWithToastOptions } from './worktree-delete-request' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' @@ -86,47 +84,24 @@ export function runWorktreeDeleteWithToast( forceRemoval .then((forceResult) => { if (!forceResult.ok) { - toast.error( - translate( - 'auto.components.sidebar.delete.worktree.flow.4f3876c0f5', - 'Force delete failed' - ), - { - // Why (STA-4895): this retry renders its own toast, so without the shared - // funnel the main process's English hint reaches the user verbatim. - description: getDeleteWorktreeToastCopy(worktreeName, null, forceResult.error) - .description, - action: { - label: translate( - 'auto.components.sidebar.delete.worktree.flow.7488ed8711', - 'View' - ), - onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId) - } - } - ) + showDeleteWorktreeErrorToast({ + error: forceResult.error, + kind: 'force-delete', + onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId), + worktreeName + }) return } commitForceFocus() options.onForceDeleted?.(target) }) .catch((err: unknown) => { - toast.error( - translate( - 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4', - 'Failed to delete workspace' - ), - { - description: err instanceof Error ? err.message : String(err), - action: { - label: translate( - 'auto.components.sidebar.delete.worktree.flow.7488ed8711', - 'View' - ), - onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId) - } - } - ) + showDeleteWorktreeErrorToast({ + error: err, + kind: 'delete', + onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId), + worktreeName + }) }) }, worktreeId, @@ -135,13 +110,7 @@ export function runWorktreeDeleteWithToast( return false }) .catch((err: unknown) => { - toast.error( - translate( - 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4', - 'Failed to delete workspace' - ), - { description: err instanceof Error ? err.message : String(err) } - ) + showDeleteWorktreeErrorToast({ error: err, kind: 'delete', worktreeName }) return false }) } diff --git a/src/renderer/src/components/sidebar/show-delete-worktree-error-toast.ts b/src/renderer/src/components/sidebar/show-delete-worktree-error-toast.ts new file mode 100644 index 00000000000..5fb8b92c6ab --- /dev/null +++ b/src/renderer/src/components/sidebar/show-delete-worktree-error-toast.ts @@ -0,0 +1,40 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' + +type DeleteWorktreeErrorToastOptions = { + error: unknown + kind: 'delete' | 'force-delete' + onViewChanges?: () => void + worktreeName: string +} + +export function showDeleteWorktreeErrorToast({ + error, + kind, + onViewChanges, + worktreeName +}: DeleteWorktreeErrorToastOptions): void { + const errorText = error instanceof Error ? error.message : String(error) + const title = + kind === 'force-delete' + ? translate('auto.components.sidebar.delete.worktree.flow.4f3876c0f5', 'Force delete failed') + : translate( + 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4', + 'Failed to delete workspace' + ) + + toast.error(title, { + // Why (STA-4895): every non-interactive delete error enters the copy funnel here, so a new + // promise failure cannot bypass localized copy by rendering its raw rejection directly. + description: getDeleteWorktreeToastCopy(worktreeName, null, errorText).description, + ...(onViewChanges + ? { + action: { + label: translate('auto.components.sidebar.delete.worktree.flow.7488ed8711', 'View'), + onClick: onViewChanges + } + } + : {}) + }) +} From 0065cfdf1452d0e34c15522ca5c74d7d90a0f67d Mon Sep 17 00:00:00 2001 From: Brennan Benson Date: Sat, 29 Aug 2026 07:00:24 -0700 Subject: [PATCH 8/9] test(worktrees): describe the delete-toast funnel --- .../sidebar/run-worktree-delete-with-toast.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts index f9105409859..169b930edb6 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts @@ -1,8 +1,7 @@ /** - * STA-4895: the Force Delete retry renders its own toast, bypassing - * `getDeleteWorktreeToastCopy`. The held-workspace-directory hint is English text the - * main process appends as a wire anchor, so that bypass puts it in front of the user - * verbatim — the exact leak the first delete toast was fixed to close. + * STA-4895: every non-interactive delete error must enter the shared copy funnel. The held- + * workspace-directory hint is English text the main process appends as a wire anchor, so either + * a resolved failure or a rejected promise that bypasses the funnel puts it in front of the user. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { WORKSPACE_DIRECTORY_HELD_HINT } from '../../../../shared/worktree/removal' From 501178bae99b31579733f2152c3f51cfe16f0c5b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:27:13 -0700 Subject: [PATCH 9/9] fix(worktrees): route every explicit Force Delete through the delete copy funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete-failure toast has six routes, not four. The dialog's Force Delete button and the Space Manager's row recovery each ran the destructive retry themselves and rendered `result.error` straight into the toast body, so on Windows a user reading "EBUSY: resource busy or locked, rmdir 'C:\...'" plus the main process's English held-directory hint got exactly the leak this branch has patched four times already. All three surfaces that spend the PTY-stop waiver now settle through one helper, so the funnel is a route rather than a habit. A sweep test enumerates those surfaces and fails when a new one appears without it — the earlier leaks were all new routes. Also fixes the rejected force-delete retry titling itself "Failed to delete workspace" instead of "Force delete failed" (reported on the PR). --- ...elete-worktree-dialog-force-delete.test.ts | 77 +++++++++++++++ .../delete-worktree-dialog-force-delete.ts | 47 +++------- .../sidebar/force-delete-retry-toast.test.ts | 93 +++++++++++++++++++ .../sidebar/force-delete-retry-toast.ts | 38 ++++++++ .../run-worktree-delete-with-toast.test.ts | 18 +++- .../sidebar/run-worktree-delete-with-toast.ts | 27 ++---- .../status-bar/WorkspaceSpaceManagerPanel.tsx | 50 ++-------- .../workspace-space-force-delete.test.ts | 70 ++++++++++++++ .../workspace-space-force-delete.ts | 41 ++++++++ 9 files changed, 358 insertions(+), 103 deletions(-) create mode 100644 src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.test.ts create mode 100644 src/renderer/src/components/sidebar/force-delete-retry-toast.test.ts create mode 100644 src/renderer/src/components/sidebar/force-delete-retry-toast.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-force-delete.test.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-force-delete.ts diff --git a/src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.test.ts b/src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.test.ts new file mode 100644 index 00000000000..037583f894b --- /dev/null +++ b/src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.test.ts @@ -0,0 +1,77 @@ +/** + * STA-4895: the dialog's Force Delete button is a second, independent route to a delete-failure + * toast. It ran the retry "directly rather than through the shared toast wrapper", so the main + * process's English held-directory hint reached the user verbatim on exactly the failure it was + * written for. Both of its outcomes have to enter the same copy funnel. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { translate } from '@/i18n/i18n' +import { WORKSPACE_DIRECTORY_HELD_HINT } from '../../../../shared/worktree/removal' +import type { Worktree } from '../../../../shared/worktree/types' + +const toastError = vi.fn() + +vi.mock('sonner', () => ({ toast: { error: (...args: unknown[]) => toastError(...args) } })) +vi.mock('./active-worktree-focus-after-delete', () => ({ + prepareActiveWorktreeFocusAfterDelete: () => vi.fn() +})) +vi.mock('./stale-workspace-list-toast', () => ({ showWorkspaceListChangedToast: vi.fn() })) + +const { runDialogForceDelete } = await import('./delete-worktree-dialog-force-delete') + +const WORKTREE = { + id: 'repo-1::C:/ws/feature', + displayName: 'feature', + path: 'C:/ws/feature', + hostId: undefined +} as unknown as Worktree + +const HELD_ERROR = `Failed to force delete worktree at C:\\ws\\feature. EBUSY: resource busy or locked, rmdir 'C:\\ws\\feature' ${WORKSPACE_DIRECTORY_HELD_HINT}` + +function runWith(removeWorktree: () => Promise): void { + runDialogForceDelete({ + worktreeId: WORKTREE.id, + currentWorktrees: [WORKTREE], + removeWorktree: removeWorktree as never, + closeModal: vi.fn(), + onDeleted: vi.fn() + }) +} + +function lastDescription(): string | undefined { + return (toastError.mock.calls.at(-1)?.[1] as { description?: string } | undefined)?.description +} + +describe('dialog Force Delete enters the delete copy funnel', () => { + beforeEach(() => { + toastError.mockClear() + }) + + it('funnels a resolved held-directory failure instead of rendering the wire anchor', async () => { + runWith(() => Promise.resolve({ ok: false, error: HELD_ERROR })) + await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) + + expect(lastDescription()).toBe( + translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') + ) + expect(lastDescription()).not.toContain('EBUSY') + expect(lastDescription()).not.toContain('C:\\ws\\feature') + }) + + it('funnels a rejected force delete instead of rendering the wire anchor', async () => { + runWith(() => Promise.reject(new Error(HELD_ERROR))) + await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) + + expect(lastDescription()).toBe( + translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') + ) + expect(lastDescription()).not.toContain('EBUSY') + }) + + it('still shows the raw failure for errors with no dedicated copy', async () => { + runWith(() => Promise.resolve({ ok: false, error: 'fatal: some other git failure' })) + await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) + + expect(lastDescription()).toBe('fatal: some other git failure') + }) +}) diff --git a/src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.ts b/src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.ts index 756952c0fac..6e4805b4949 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.ts @@ -1,5 +1,3 @@ -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' import type { Worktree } from '../../../../shared/worktree/types' import { toWorktreeRemovalTarget, @@ -9,13 +7,15 @@ import type { RemoveWorktreeOptions } from '@/store/slices/worktree-removal-opti import type { RendererRemoveWorktreeResult } from '@/store/slices/renderer-remove-worktree-result' import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' import { showWorkspaceListChangedToast } from './stale-workspace-list-toast' +import { settleForceDeleteRetry } from './force-delete-retry-toast' /** * The dialog's explicit "Force Delete" retry. * - * Runs the destructive retry directly rather than through the shared toast - * wrapper, preserving the legacy button behaviour, and closes immediately - * because the workspace cards already show the deleting state. + * Runs the destructive retry itself rather than through `runWorktreeDeleteWithToast`, + * preserving the legacy button behaviour, and closes immediately because the workspace + * cards already show the deleting state. Its failures still report through the shared + * copy funnel (STA-4895). */ export function runDialogForceDelete(args: { worktreeId: string @@ -29,10 +29,8 @@ export function runDialogForceDelete(args: { onDeleted: ((deleted: WorktreeRemovalTarget[]) => void) | null | undefined }): void { const { worktreeId, currentWorktrees, removeWorktree, closeModal, onDeleted } = args - // Why: this branch preserves the legacy "Force Delete" button behavior - // inside the dialog — it runs the destructive retry directly without - // the shared toast wrapper. Close immediately because workspace cards - // already show the deleting state while the retry runs. + // Why: this branch preserves the legacy "Force Delete" button behavior inside the + // dialog. Close immediately because workspace cards already show the deleting state. // Why the lookup (STA-4343): the confirmed row carries the host the // removal must land on; a bare id would let force delete another host's // checkout at the same path. @@ -51,32 +49,11 @@ export function runDialogForceDelete(args: { allowUnverifiedPtyStop: true }) closeModal() - deletePromise - .then((result) => { - if (!result.ok) { - toast.error( - translate( - 'auto.components.sidebar.DeleteWorktreeDialog.42e610d6cf', - 'Force delete failed' - ), - { - description: result.error - } - ) - return - } + void settleForceDeleteRetry(deletePromise, { + worktreeName: forceTarget.displayName, + onDeleted: () => { commitFocus() onDeleted?.([toWorktreeRemovalTarget(forceTarget)]) - }) - .catch((err: unknown) => { - toast.error( - translate( - 'auto.components.sidebar.DeleteWorktreeDialog.4f6750ca7b', - 'Failed to delete workspace' - ), - { - description: err instanceof Error ? err.message : String(err) - } - ) - }) + } + }) } diff --git a/src/renderer/src/components/sidebar/force-delete-retry-toast.test.ts b/src/renderer/src/components/sidebar/force-delete-retry-toast.test.ts new file mode 100644 index 00000000000..e296d85cd58 --- /dev/null +++ b/src/renderer/src/components/sidebar/force-delete-retry-toast.test.ts @@ -0,0 +1,93 @@ +/** + * STA-4895 guard: the explicit "Force Delete" retry is the one delete a user can reach from three + * separate surfaces, and it is the delete whose failures carry the main process's English wire + * anchors. Four leaks shipped because each surface was patched one at a time, so this asserts the + * property structurally: every site that spends the PTY-stop waiver reports through the funnel. + */ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { translate } from '@/i18n/i18n' +import { WORKSPACE_DIRECTORY_HELD_HINT } from '../../../../shared/worktree/removal' + +const toastError = vi.fn() +vi.mock('sonner', () => ({ toast: { error: (...args: unknown[]) => toastError(...args) } })) + +const { settleForceDeleteRetry } = await import('./force-delete-retry-toast') + +const RENDERER_ROOT = join(__dirname, '..', '..') +/** The waiver only an explicit Force Delete may spend — so it names that call and nothing else. */ +const FORCE_DELETE_WAIVER = 'allowUnverifiedPtyStop: true' +const FUNNEL_IMPORT = 'force-delete-retry-toast' + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + return sourceFiles(path) + } + if (!/\.tsx?$/.test(entry.name) || /\.(test|spec)\.tsx?$/.test(entry.name)) { + return [] + } + return [path] + }) +} + +describe('every explicit Force Delete retry reports through the copy funnel', () => { + const waiverSites = sourceFiles(RENDERER_ROOT) + .map((path) => ({ path, source: readFileSync(path, 'utf8') })) + .filter(({ source }) => source.includes(FORCE_DELETE_WAIVER)) + + it('scans the surfaces that spend the PTY-stop waiver', () => { + // Guards the sweep itself: a broken walk would pass every assertion below vacuously. + expect(waiverSites.map(({ path }) => path.slice(RENDERER_ROOT.length + 1)).sort()).toEqual([ + 'components/sidebar/delete-worktree-dialog-force-delete.ts', + 'components/sidebar/run-worktree-delete-with-toast.ts', + 'components/status-bar/workspace-space-force-delete.ts' + ]) + }) + + it('leaves no site rendering its own failure copy', () => { + const unfunnelled = waiverSites + .filter(({ source }) => !source.includes(FUNNEL_IMPORT)) + .map(({ path }) => path.slice(RENDERER_ROOT.length + 1)) + expect(unfunnelled).toEqual([]) + }) +}) + +describe('settleForceDeleteRetry', () => { + const HELD_ERROR = `Failed to force delete worktree at C:\\ws\\feature. EBUSY: resource busy or locked, rmdir 'C:\\ws\\feature' ${WORKSPACE_DIRECTORY_HELD_HINT}` + + function lastToast(): { title: string; description?: string } { + const call = toastError.mock.calls.at(-1) + return { + title: call?.[0] as string, + description: (call?.[1] as { description?: string })?.description + } + } + + it('titles a rejected retry as the force delete it was', async () => { + toastError.mockClear() + await settleForceDeleteRetry(Promise.reject(new Error(HELD_ERROR)), { + worktreeName: 'feature', + onDeleted: vi.fn() + }) + expect(lastToast().title).toBe( + translate('auto.components.sidebar.delete.worktree.flow.4f3876c0f5', 'MISSING') + ) + expect(lastToast().description).toBe( + translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') + ) + }) + + it('reports a success to its caller without a toast', async () => { + toastError.mockClear() + const onDeleted = vi.fn() + await settleForceDeleteRetry(Promise.resolve({ ok: true }), { + worktreeName: 'feature', + onDeleted + }) + expect(onDeleted).toHaveBeenCalledTimes(1) + expect(toastError).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/force-delete-retry-toast.ts b/src/renderer/src/components/sidebar/force-delete-retry-toast.ts new file mode 100644 index 00000000000..6589da26daf --- /dev/null +++ b/src/renderer/src/components/sidebar/force-delete-retry-toast.ts @@ -0,0 +1,38 @@ +import { showDeleteWorktreeErrorToast } from './show-delete-worktree-error-toast' + +type ForceDeleteRetryResult = { ok: true } | { ok: false; error: string } + +/** + * Settle an explicit "Force Delete" retry and report a failure through the shared copy funnel. + * + * Why (STA-4895): three surfaces run this same retry — the failure toast, the delete dialog's + * button, and the Space Manager — and two of them rendered `result.error` straight into a toast. + * That put the main process's English wire anchors in front of the user on exactly the failures + * they were written to explain. Settling here is what makes the funnel a route, not a habit. + */ +export function settleForceDeleteRetry( + retry: Promise, + options: { + worktreeName: string + onDeleted: () => void + onViewChanges?: () => void + } +): Promise { + const showFailure = (error: unknown): void => { + showDeleteWorktreeErrorToast({ + error, + kind: 'force-delete', + worktreeName: options.worktreeName, + ...(options.onViewChanges ? { onViewChanges: options.onViewChanges } : {}) + }) + } + return retry + .then((result) => { + if (!result.ok) { + showFailure(result.error) + return + } + options.onDeleted() + }) + .catch(showFailure) +} diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts index 169b930edb6..d70b5a356d6 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.test.ts @@ -58,7 +58,10 @@ describe('runWorktreeDeleteWithToast force-delete retry', () => { capturedForceHandlers[0]?.() await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) - const description = (toastError.mock.calls[0]?.[1] as { description?: string })?.description + // The initial `dirty` failure renders through showDeleteWorktreeFailureToast, mocked above, + // so every sonner call here is the retry's — pinned rather than assumed. + expect(toastError).toHaveBeenCalledTimes(1) + const description = (toastError.mock.calls.at(-1)?.[1] as { description?: string })?.description expect(description).toBe( translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') ) @@ -76,11 +79,16 @@ describe('runWorktreeDeleteWithToast force-delete retry', () => { capturedForceHandlers[0]?.() await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) - const description = (toastError.mock.calls[0]?.[1] as { description?: string })?.description + expect(toastError).toHaveBeenCalledTimes(1) + const description = (toastError.mock.calls.at(-1)?.[1] as { description?: string })?.description expect(description).toBe( translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') ) expect(description).not.toContain('EBUSY') + // A rejected Force Delete is still a force delete, not an ordinary one. + expect(toastError.mock.calls.at(-1)?.[0]).toBe( + translate('auto.components.sidebar.delete.worktree.flow.4f3876c0f5', 'MISSING') + ) }) it('funnels a rejected initial delete before rendering its error', async () => { @@ -91,7 +99,8 @@ describe('runWorktreeDeleteWithToast force-delete retry', () => { 'feature' ) - const description = (toastError.mock.calls[0]?.[1] as { description?: string })?.description + expect(toastError).toHaveBeenCalledTimes(1) + const description = (toastError.mock.calls.at(-1)?.[1] as { description?: string })?.description expect(description).toBe( translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') ) @@ -109,7 +118,8 @@ describe('runWorktreeDeleteWithToast force-delete retry', () => { capturedForceHandlers[0]?.() await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) - expect((toastError.mock.calls[0]?.[1] as { description?: string })?.description).toBe( + expect(toastError).toHaveBeenCalledTimes(1) + expect((toastError.mock.calls.at(-1)?.[1] as { description?: string })?.description).toBe( 'fatal: some other git failure' ) }) diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts index 685fcf251ed..4e95953c1c6 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts @@ -3,6 +3,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' +import { settleForceDeleteRetry } from './force-delete-retry-toast' import { showDeleteWorktreeErrorToast } from './show-delete-worktree-error-toast' import type { WorktreeDeleteWithToastOptions } from './worktree-delete-request' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' @@ -81,28 +82,14 @@ export function runWorktreeDeleteWithToast( const forceRemoval = useAppStore .getState() .removeWorktree(target, true, { allowUnverifiedPtyStop: true }) - forceRemoval - .then((forceResult) => { - if (!forceResult.ok) { - showDeleteWorktreeErrorToast({ - error: forceResult.error, - kind: 'force-delete', - onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId), - worktreeName - }) - return - } + void settleForceDeleteRetry(forceRemoval, { + worktreeName, + onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId), + onDeleted: () => { commitForceFocus() options.onForceDeleted?.(target) - }) - .catch((err: unknown) => { - showDeleteWorktreeErrorToast({ - error: err, - kind: 'delete', - onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId), - worktreeName - }) - }) + } + }) }, worktreeId, worktreeName diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index af74109cae9..a871d5027a3 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -65,7 +65,7 @@ import { import { runWorktreeBatchDelete } from '../sidebar/delete-worktree-flow' import { toWorktreeDeleteIdentities } from '../sidebar/worktree-delete-request' import { showWorkspaceListChangedToast } from '../sidebar/stale-workspace-list-toast' -import { prepareActiveWorktreeFocusAfterDelete } from '../sidebar/active-worktree-focus-after-delete' +import { runWorkspaceSpaceForceDelete } from './workspace-space-force-delete' import { branchDisplayName } from '../sidebar/WorktreeCardHelpers' import { Badge } from '../ui/badge' import { Button } from '../ui/button' @@ -1767,49 +1767,11 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { const forceDeleteWorktree = useCallback( (worktree: WorkspaceSpaceWorktree): void => { - // Why: Space keeps normal deletes non-force so uncommitted work is not - // discarded silently; a failed row gets this explicit recovery path. - const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktree.worktreeId) - // Why (#11960): explicit force recovery, so it may also waive PTY-stop proof. - // Why the host (STA-4343): the Space scan lists one row per host, so a bare - // id would let this force delete another host's checkout at the same path. - void removeWorktree( - { id: worktree.worktreeId, executionHostId: worktree.executionHostId ?? null }, - true, - { allowUnverifiedPtyStop: true } - ) - .then((result) => { - if (!result.ok) { - toast.error( - translate( - 'auto.components.status.bar.WorkspaceSpaceManagerPanel.2965415393', - 'Force delete failed' - ), - { - description: result.error - } - ) - return - } - commitFocus() - handleDeletedWorktrees([ - { - id: worktree.worktreeId, - executionHostId: worktree.executionHostId ?? null - } - ]) - }) - .catch((error: unknown) => { - toast.error( - translate( - 'auto.components.status.bar.WorkspaceSpaceManagerPanel.2965415393', - 'Force delete failed' - ), - { - description: error instanceof Error ? error.message : String(error) - } - ) - }) + runWorkspaceSpaceForceDelete({ + worktree, + removeWorktree, + onDeleted: (target) => handleDeletedWorktrees([target]) + }) }, [handleDeletedWorktrees, removeWorktree] ) diff --git a/src/renderer/src/components/status-bar/workspace-space-force-delete.test.ts b/src/renderer/src/components/status-bar/workspace-space-force-delete.test.ts new file mode 100644 index 00000000000..0e50b6a65d1 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-force-delete.test.ts @@ -0,0 +1,70 @@ +/** + * STA-4895: the Space Manager's Force Delete is a third, independent route to a delete-failure + * toast. It rendered `result.error` straight into the toast body, so the main process's English + * held-directory hint reached the user verbatim on exactly the failure it was written for. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { translate } from '@/i18n/i18n' +import { WORKSPACE_DIRECTORY_HELD_HINT } from '../../../../shared/worktree/removal' + +const toastError = vi.fn() + +vi.mock('sonner', () => ({ toast: { error: (...args: unknown[]) => toastError(...args) } })) +vi.mock('../sidebar/active-worktree-focus-after-delete', () => ({ + prepareActiveWorktreeFocusAfterDelete: () => vi.fn() +})) + +const { runWorkspaceSpaceForceDelete } = await import('./workspace-space-force-delete') + +const ROW = { + worktreeId: 'repo-1::C:/ws/feature', + displayName: 'feature' +} as const + +const HELD_ERROR = `Failed to force delete worktree at C:\\ws\\feature. EBUSY: resource busy or locked, rmdir 'C:\\ws\\feature' ${WORKSPACE_DIRECTORY_HELD_HINT}` + +function runWith(removeWorktree: () => Promise): void { + runWorkspaceSpaceForceDelete({ + worktree: ROW, + removeWorktree: removeWorktree as never, + onDeleted: vi.fn() + }) +} + +function lastDescription(): string | undefined { + return (toastError.mock.calls.at(-1)?.[1] as { description?: string } | undefined)?.description +} + +describe('Space Manager Force Delete enters the delete copy funnel', () => { + beforeEach(() => { + toastError.mockClear() + }) + + it('funnels a resolved held-directory failure instead of rendering the wire anchor', async () => { + runWith(() => Promise.resolve({ ok: false, error: HELD_ERROR })) + await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) + + expect(lastDescription()).toBe( + translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') + ) + expect(lastDescription()).not.toContain('EBUSY') + expect(lastDescription()).not.toContain('C:\\ws\\feature') + }) + + it('funnels a rejected force delete instead of rendering the wire anchor', async () => { + runWith(() => Promise.reject(new Error(HELD_ERROR))) + await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) + + expect(lastDescription()).toBe( + translate('auto.components.sidebar.delete.worktree.toast.workspaceDirectoryHeld', 'MISSING') + ) + expect(lastDescription()).not.toContain('EBUSY') + }) + + it('still shows the raw failure for errors with no dedicated copy', async () => { + runWith(() => Promise.resolve({ ok: false, error: 'fatal: some other git failure' })) + await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) + + expect(lastDescription()).toBe('fatal: some other git failure') + }) +}) diff --git a/src/renderer/src/components/status-bar/workspace-space-force-delete.ts b/src/renderer/src/components/status-bar/workspace-space-force-delete.ts new file mode 100644 index 00000000000..b79fe430bbe --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-force-delete.ts @@ -0,0 +1,41 @@ +import type { RemoveWorktreeOptions } from '@/store/slices/worktree-removal-options' +import type { RendererRemoveWorktreeResult } from '@/store/slices/renderer-remove-worktree-result' +import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' +import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' +import { prepareActiveWorktreeFocusAfterDelete } from '../sidebar/active-worktree-focus-after-delete' +import { settleForceDeleteRetry } from '../sidebar/force-delete-retry-toast' + +type RemoveWorktree = ( + target: WorktreeRemovalTarget, + force?: boolean, + options?: RemoveWorktreeOptions +) => Promise<({ ok: true } & RendererRemoveWorktreeResult) | { ok: false; error: string }> + +/** + * The Space Manager's explicit "Force Delete" recovery for a row whose delete failed. + * + * Why: Space keeps normal deletes non-force so uncommitted work is not discarded silently; + * a failed row gets this explicit recovery path. + */ +export function runWorkspaceSpaceForceDelete(args: { + worktree: Pick + removeWorktree: RemoveWorktree + onDeleted: (target: WorktreeRemovalTarget) => void +}): void { + const { worktree, removeWorktree, onDeleted } = args + const target: WorktreeRemovalTarget = { + id: worktree.worktreeId, + executionHostId: worktree.executionHostId ?? null + } + const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktree.worktreeId) + // Why (#11960): explicit force recovery, so it may also waive PTY-stop proof. + // Why the host (STA-4343): the Space scan lists one row per host, so a bare + // id would let this force delete another host's checkout at the same path. + void settleForceDeleteRetry(removeWorktree(target, true, { allowUnverifiedPtyStop: true }), { + worktreeName: worktree.displayName, + onDeleted: () => { + commitFocus() + onDeleted(target) + } + }) +}