mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
sim: merge PR #17173
This commit is contained in:
@@ -21,6 +21,11 @@ import {
|
||||
isOrphanedWorktreeError,
|
||||
areWorktreePathsEqual
|
||||
} from './worktree-logic'
|
||||
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', () => {
|
||||
@@ -596,9 +601,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`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -618,7 +623,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`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -629,6 +634,100 @@ describe('formatWorktreeRemovalError', () => {
|
||||
`Failed to delete worktree at ${path}.`
|
||||
)
|
||||
})
|
||||
|
||||
// STA-4895: the reported toast was a bare `EBUSY ... rmdir '<workspace>'` 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('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}`
|
||||
)
|
||||
})
|
||||
|
||||
// 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(
|
||||
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)
|
||||
})
|
||||
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOrphanedWorktreeError', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -363,14 +368,26 @@ 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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -276,6 +276,137 @@ 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('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 })
|
||||
}
|
||||
})
|
||||
|
||||
// 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-'))
|
||||
|
||||
@@ -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,
|
||||
@@ -159,6 +161,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,10 +172,21 @@ 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 {
|
||||
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}`
|
||||
)
|
||||
@@ -182,6 +196,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,
|
||||
|
||||
@@ -50114,6 +50114,107 @@ 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('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 })
|
||||
}
|
||||
})
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -1258,6 +1258,7 @@ import {
|
||||
isWorktreePathMissing,
|
||||
ORPHANED_WORKTREE_DIRECTORY_MESSAGE,
|
||||
stripOrcaProvenanceMetaUpdates,
|
||||
UNPROVEN_ORPHANED_WORKTREE_DIRECTORY_MESSAGE,
|
||||
UNREGISTERED_MISSING_WORKTREE_MESSAGE
|
||||
} from '../worktree-removal-safety'
|
||||
import {
|
||||
@@ -29900,6 +29901,7 @@ export class OrcaRuntimeService {
|
||||
removalCompleted = true
|
||||
} else if (isOrphanedWorktreeError(error)) {
|
||||
const access = getLocalWorktreePathAccess(localWorktreeGitOptions)
|
||||
let directoryRemovalError: unknown
|
||||
if (
|
||||
await canSafelyRemoveOrphanedWorktreeDirectory(
|
||||
toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions),
|
||||
@@ -29909,10 +29911,22 @@ 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 {
|
||||
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}`
|
||||
)
|
||||
@@ -29925,6 +29939,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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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<unknown>): 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDeleteWorktreeToastCopy } from './delete-worktree-toast'
|
||||
import { classifyWorktreeForceDeleteReason } from '../../../../shared/worktree/removal'
|
||||
import {
|
||||
classifyWorktreeForceDeleteReason,
|
||||
isUnprovenOrphanedWorktreeDirectoryError,
|
||||
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 +148,46 @@ 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')
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
isHeldWorkspaceDirectoryRemovalError,
|
||||
isLockedWorktreeRemovalError,
|
||||
isProvenLivePtyRemovalError,
|
||||
isUnprovenOrphanedWorktreeDirectoryError,
|
||||
type WorktreeForceDeleteReason
|
||||
} from '../../../../shared/worktree/removal'
|
||||
export type DeleteWorktreeToastCopy = {
|
||||
@@ -37,6 +39,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 {
|
||||
@@ -106,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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<ForceDeleteRetryResult>,
|
||||
options: {
|
||||
worktreeName: string
|
||||
onDeleted: () => void
|
||||
onViewChanges?: () => void
|
||||
}
|
||||
): Promise<void> {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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'
|
||||
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', executionHostId: null },
|
||||
'feature'
|
||||
)
|
||||
|
||||
removeWorktree.mockResolvedValueOnce({ ok: false, error: HELD_ERROR })
|
||||
capturedForceHandlers[0]?.()
|
||||
await vi.waitFor(() => expect(toastError).toHaveBeenCalled())
|
||||
|
||||
// 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')
|
||||
)
|
||||
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())
|
||||
|
||||
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 () => {
|
||||
removeWorktree.mockRejectedValueOnce(new Error(HELD_ERROR))
|
||||
|
||||
await runWorktreeDeleteWithToast(
|
||||
{ id: 'repo-1::/ws/feature', executionHostId: null },
|
||||
'feature'
|
||||
)
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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', executionHostId: null },
|
||||
'feature'
|
||||
)
|
||||
|
||||
removeWorktree.mockResolvedValueOnce({ ok: false, error: 'fatal: some other git failure' })
|
||||
capturedForceHandlers[0]?.()
|
||||
await vi.waitFor(() => expect(toastError).toHaveBeenCalled())
|
||||
|
||||
expect(toastError).toHaveBeenCalledTimes(1)
|
||||
expect((toastError.mock.calls.at(-1)?.[1] as { description?: string })?.description).toBe(
|
||||
'fatal: some other git failure'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { 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'
|
||||
|
||||
@@ -82,48 +82,14 @@ export function runWorktreeDeleteWithToast(
|
||||
const forceRemoval = useAppStore
|
||||
.getState()
|
||||
.removeWorktree(target, true, { allowUnverifiedPtyStop: true })
|
||||
forceRemoval
|
||||
.then((forceResult) => {
|
||||
if (!forceResult.ok) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.4f3876c0f5',
|
||||
'Force delete failed'
|
||||
),
|
||||
{
|
||||
description: forceResult.error,
|
||||
action: {
|
||||
label: translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.7488ed8711',
|
||||
'View'
|
||||
),
|
||||
onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId)
|
||||
}
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
void settleForceDeleteRetry(forceRemoval, {
|
||||
worktreeName,
|
||||
onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId),
|
||||
onDeleted: () => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
worktreeId,
|
||||
worktreeName
|
||||
@@ -131,13 +97,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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
@@ -68,7 +68,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'
|
||||
@@ -1802,49 +1802,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]
|
||||
)
|
||||
|
||||
@@ -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<unknown>): 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')
|
||||
})
|
||||
})
|
||||
@@ -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<WorkspaceSpaceWorktree, 'worktreeId' | 'executionHostId' | 'displayName'>
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5694,7 +5694,9 @@
|
||||
"locked": "This workspace is locked by Git. Run git worktree unlock <worktree-path> from its repository, then retry deletion.",
|
||||
"lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock <worktree-path> 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.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,90 @@ 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 '<path>'`
|
||||
// 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 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)
|
||||
}
|
||||
|
||||
// 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'])
|
||||
|
||||
// `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(
|
||||
|
||||
Reference in New Issue
Block a user