From d188e7d2f602909a287a5638fa86f92fe7e28465 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:28:10 -0700 Subject: [PATCH] Recover Windows worktree deletes from long paths (#6433) Co-authored-by: Neil --- src/main/ipc/worktree-logic.test.ts | 27 ++++ src/main/ipc/worktree-logic.ts | 17 +++ src/main/ipc/worktrees.test.ts | 133 ++++++++++++++++++++ src/main/ipc/worktrees.ts | 67 +++++++++- src/main/local-worktree-filesystem.test.ts | 25 +++- src/main/local-worktree-filesystem.ts | 11 +- src/main/local-worktree-removal-recovery.ts | 94 ++++++++++++++ src/main/runtime/orca-runtime.test.ts | 122 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 64 +++++++++- 9 files changed, 549 insertions(+), 11 deletions(-) create mode 100644 src/main/local-worktree-removal-recovery.ts diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index a9da7fcd3..817becb7a 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -18,6 +18,7 @@ import { mergeWorktree, parseWorktreeId, formatWorktreeRemovalError, + isWindowsLongPathWorktreeRemovalError, isOrphanCompatiblePreflightError, isOrphanedWorktreeError, areWorktreePathsEqual @@ -535,6 +536,32 @@ describe('isOrphanedWorktreeError', () => { }) }) +describe('isWindowsLongPathWorktreeRemovalError', () => { + it('matches Git for Windows long-path deletion failures on Windows', () => { + const error = Object.assign(new Error('git worktree remove failed'), { + stderr: 'error: failed to delete some/deep/file: Filename too long' + }) + + expect(isWindowsLongPathWorktreeRemovalError(error, 'win32')).toBe(true) + }) + + it('does not match long-path text off Windows', () => { + const error = Object.assign(new Error('file name too long'), { + stderr: 'Filename too long' + }) + + expect(isWindowsLongPathWorktreeRemovalError(error, 'linux')).toBe(false) + }) + + it('does not match unrelated Git removal failures on Windows', () => { + const error = Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: contains modified or untracked files' + }) + + expect(isWindowsLongPathWorktreeRemovalError(error, 'win32')).toBe(false) + }) +}) + describe('isOrphanCompatiblePreflightError', () => { it('matches not-a-working-tree errors', () => { const error = Object.assign(new Error('git failed'), { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index eb1f64d1a..8cf5afa1a 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -281,6 +281,23 @@ export function isOrphanedWorktreeError(error: unknown): boolean { return /is not a working tree/.test(msg) } +export function isWindowsLongPathWorktreeRemovalError( + error: unknown, + platform: NodeJS.Platform = process.platform +): boolean { + if (platform !== 'win32' || typeof error !== 'object' || error === null) { + return false + } + const errorWithDetails = error as { message?: unknown; stderr?: unknown; stdout?: unknown } + const details = [errorWithDetails.stderr, errorWithDetails.stdout, errorWithDetails.message] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join('\n') + + // Why: Git for Windows has reported this failure through both stderr and the + // thrown message, with wording that varies between "filename" and "path". + return /(?:file ?name|path).{0,40}too long|too long.{0,40}(?:file ?name|path)/i.test(details) +} + export function isOrphanCompatiblePreflightError(error: unknown): boolean { if (isOrphanedWorktreeError(error)) { return true diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 5722eb2f9..51324c45c 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -5440,6 +5440,139 @@ describe('registerWorktreeHandlers', () => { }) }) + 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-')) + const repoPath = join(parentDir, 'repo') + const worktreePath = join(parentDir, 'feature-wt') + await mkdir(worktreePath, { recursive: true }) + await writeFile(join(worktreePath, 'scratch.txt'), 'delete me') + mockKnownFeatureWorktree(worktreePath, repoPath) + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta()) + const longPathError = Object.assign(new Error('git worktree remove failed'), { + stderr: 'error: failed to delete deep/file.txt: Filename too long' + }) + removeWorktreeMock.mockRejectedValue(longPathError) + const worktreeId = `repo-1::${worktreePath}` + + try { + const result = await handlers['worktrees:remove'](null, { + worktreeId, + force: true + }) + + expect(result).toEqual({ + preservedBranch: { branchName: 'feature', head: 'feature' } + }) + if (ORIGINAL_PLATFORM === 'win32') { + await expect(lstat(worktreePath)).rejects.toMatchObject({ code: 'ENOENT' }) + } + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: '/workspace/repo' + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + } finally { + await rm(parentDir, { recursive: true, force: true }) + } + }) + + it('does not create a preserved-branch target when long-path recovery preserves branch by policy', async () => { + setPlatform('win32') + mockKnownFeatureWorktree() + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true })) + removeWorktreeMock.mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: 'error: failed to delete deep/file.txt: Filename too long' + }) + ) + + const result = await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + force: true + }) + + expect(result).toEqual({}) + await expect( + handlers['worktrees:forceDeletePreservedBranch'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + branchName: 'feature', + expectedHead: 'feature' + }) + ).rejects.toThrow('No preserved branch cleanup is pending') + }) + + it('does not recover Windows long-path worktree removal without force', async () => { + setPlatform('win32') + mockKnownFeatureWorktree() + const longPathError = Object.assign(new Error('git worktree remove failed'), { + stderr: 'error: failed to delete deep/file.txt: Filename too long' + }) + removeWorktreeMock.mockRejectedValue(longPathError) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + ).rejects.toThrow('Failed to delete worktree at /workspace/feature-wt.') + + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('keeps metadata when Windows long-path recovery deletes the directory but prune fails', async () => { + setPlatform('win32') + mockKnownFeatureWorktree() + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta()) + removeWorktreeMock.mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: 'error: failed to delete deep/file.txt: Filename too long' + }) + ) + gitExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('git prune failed'), { + stderr: 'fatal: unable to lock worktree admin dir' + }) + ) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + force: true + }) + ).rejects.toThrow('Git still has stale worktree registration') + + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + }) + + it('retries stale Git registration cleanup after prior local filesystem recovery', async () => { + setPlatform('win32') + const missingWorktreePath = 'C:\\workspace\\already-removed' + const worktreeId = `repo-1::${missingWorktreePath}` + mockKnownFeatureWorktree(missingWorktreePath) + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta()) + + const result = await handlers['worktrees:remove'](null, { + worktreeId, + force: true + }) + + expect(result).toEqual({ + preservedBranch: { branchName: 'feature', head: 'feature' } + }) + expect(runHookMock).not.toHaveBeenCalled() + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: '/workspace/repo' + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + }) + it('refuses to delete the root workspace for folder-mode repos', async () => { store.getRepo.mockReturnValue({ id: 'repo-folder', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 439e0e479..929309710 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -125,6 +125,10 @@ import { removeLocalWorktreePath, toLocalWorktreeRuntimePath } from '../local-worktree-filesystem' +import { + pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval, + recoverLocalWindowsLongPathWorktreeRemoval +} from '../local-worktree-removal-recovery' const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 const WORKTREE_LIST_ALL_CONCURRENCY = 8 @@ -1466,6 +1470,44 @@ export function registerWorktreeHandlers( const canonicalWorktreePath = registeredWorktree.path const deleteBranch = removedMeta?.preserveBranchOnDelete !== true + // Why: a prior forced Windows recovery can delete the directory but leave + // Git's stale registration; retry by pruning instead of removing a missing path. + if ( + !repo.connectionId && + args.force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || + !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isAlreadyRemovedWorktreePath(repo, canonicalWorktreePath, localWorktreeGitOptions)) + ) { + const removalResult = await pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree, + deleteBranch + }) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} + } + let shouldTearDownPtys = true // Run archive hook before removal so teardown scripts still see the worktree directory. @@ -1596,8 +1638,22 @@ export function registerWorktreeHandlers( registeredWorktree.head ) } catch (error) { - // If git no longer tracks this worktree, clean up the directory and metadata - if (isOrphanedWorktreeError(error)) { + // Why: Git for Windows can fail long-path directory deletion after + // Orca has already validated the target and explicit force delete. + const recoveredRemovalResult = await recoverLocalWindowsLongPathWorktreeRemoval({ + error, + force: args.force ?? false, + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree, + deleteBranch, + closeWatcher: closeLocalWatcherForRemoval + }) + if (recoveredRemovalResult) { + removalResult = recoveredRemovalResult + } else if (isOrphanedWorktreeError(error)) { + // If git no longer tracks this worktree, clean up the directory and metadata console.warn( `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` ) @@ -1640,10 +1696,11 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return {} + } else { + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) + ) } - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) } await cleanupUnusedWorktreePushTargetRemote( repo.path, diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts index 7c5400f58..d49895a6c 100644 --- a/src/main/local-worktree-filesystem.test.ts +++ b/src/main/local-worktree-filesystem.test.ts @@ -17,7 +17,11 @@ vi.mock('node:fs/promises', () => ({ rm: rmMock })) -import { getLocalWorktreePathAccess, removeLocalWorktreePath } from './local-worktree-filesystem' +import { + getLocalWorktreePathAccess, + removeLocalWorktreePath, + toHostRemovalPath +} from './local-worktree-filesystem' function completeExecFile(stdout = ''): void { execFileMock.mockImplementation((_file, _args, _options, callback) => { @@ -59,10 +63,27 @@ describe('local worktree filesystem runtime access', () => { expect(lstatMock).toHaveBeenCalledWith('C:\\repo\\.git') expect(readFileMock).toHaveBeenCalledWith('C:\\repo\\.git', 'utf8') - expect(rmMock).toHaveBeenCalledWith('C:\\repo\\feature', { recursive: true, force: true }) + expect(rmMock).toHaveBeenCalledWith(toHostRemovalPath('C:\\repo\\feature'), { + recursive: true, + force: true + }) expect(execFileMock).not.toHaveBeenCalled() }) + it('uses a Win32 long-path namespace for host removal on Windows', async () => { + await withPlatform('win32', async () => { + const longPath = `C:\\repo\\${'nested\\'.repeat(40)}feature` + + await removeLocalWorktreePath(longPath) + + expect(toHostRemovalPath(longPath)).toBe(`\\\\?\\${longPath}`) + expect(rmMock).toHaveBeenCalledWith(`\\\\?\\${longPath}`, { + recursive: true, + force: true + }) + }) + }) + it('uses the selected WSL distro for stat, read, and removal on Windows', async () => { await withPlatform('win32', async () => { completeExecFile('file') diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index e2f1313e1..867513728 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process' import { lstat, readFile, rm } from 'node:fs/promises' +import { win32 } from 'node:path' import { buildWslLoginShellCommand, escapeWslShCommandForWindows, @@ -8,7 +9,7 @@ import { import { toLinuxPath } from './wsl' import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof' -type LocalWorktreeFilesystemOptions = { +export type LocalWorktreeFilesystemOptions = { wslDistro?: string } @@ -111,7 +112,7 @@ export async function removeLocalWorktreePath( ): Promise { const distro = options.wslDistro?.trim() if (!shouldUseWslFilesystem(options) || !distro) { - await rm(targetPath, { recursive: true, force: true }) + await rm(toHostRemovalPath(targetPath), { recursive: true, force: true }) return } @@ -119,3 +120,9 @@ export async function removeLocalWorktreePath( // Windows cannot delete safely. Run the deletion inside the selected distro. await runWslLoginShellCommand(distro, `rm -rf -- ${quotePosixShell(toLinuxPath(targetPath))}`) } + +export function toHostRemovalPath(targetPath: string): string { + // Why: Git for Windows can fail long recursive deletes even after Orca has + // proven the worktree target; Node's host deletion should use Win32 long paths. + return process.platform === 'win32' ? win32.toNamespacedPath(targetPath) : targetPath +} diff --git a/src/main/local-worktree-removal-recovery.ts b/src/main/local-worktree-removal-recovery.ts new file mode 100644 index 000000000..984feb8c3 --- /dev/null +++ b/src/main/local-worktree-removal-recovery.ts @@ -0,0 +1,94 @@ +import type { GitWorktreeInfo, RemoveWorktreeResult } from '../shared/types' +import { + formatWorktreeRemovalError, + isWindowsLongPathWorktreeRemovalError +} from './ipc/worktree-logic' +import { gitExecFileAsync } from './git/runner' +import type { GitWorktreeExecOptions } from './git/worktree' +import { removeLocalWorktreePath } from './local-worktree-filesystem' + +type LocalWindowsLongPathRecoveryArgs = { + error: unknown + force: boolean + canonicalWorktreePath: string + repoPath: string + localWorktreeGitOptions: GitWorktreeExecOptions + registeredWorktree: Pick + deleteBranch: boolean + closeWatcher: (worktreePath: string) => Promise +} + +type StaleLocalWorktreeRegistrationArgs = Omit< + LocalWindowsLongPathRecoveryArgs, + 'error' | 'force' | 'closeWatcher' +> + +function preservedBranchResult( + registeredWorktree: Pick, + deleteBranch: boolean +): RemoveWorktreeResult { + if (!deleteBranch || !registeredWorktree.branch || !registeredWorktree.head) { + return {} + } + return { + preservedBranch: { + branchName: registeredWorktree.branch.replace(/^refs\/heads\//, ''), + head: registeredWorktree.head + } + } +} + +async function pruneRequiredGitWorktreeRegistration( + repoPath: string, + localWorktreeGitOptions: GitWorktreeExecOptions, + canonicalWorktreePath: string +): Promise { + try { + await gitExecFileAsync(['worktree', 'prune'], { + cwd: repoPath, + ...localWorktreeGitOptions + }) + } catch (error) { + throw new Error( + `${formatWorktreeRemovalError( + error, + canonicalWorktreePath, + true + )} The worktree directory was removed, but Git still has stale worktree registration. Retry deletion after resolving the Git prune error.` + ) + } +} + +export async function recoverLocalWindowsLongPathWorktreeRemoval( + args: LocalWindowsLongPathRecoveryArgs +): Promise { + if (!args.force || !isWindowsLongPathWorktreeRemovalError(args.error)) { + return undefined + } + + // Why: watcher shutdown is best-effort, but Git registration must be pruned + // before callers clear Orca metadata or the branch remains locked. + await args.closeWatcher(args.canonicalWorktreePath).catch(() => {}) + try { + await removeLocalWorktreePath(args.canonicalWorktreePath, args.localWorktreeGitOptions) + } catch (error) { + throw new Error(formatWorktreeRemovalError(error, args.canonicalWorktreePath, true)) + } + await pruneRequiredGitWorktreeRegistration( + args.repoPath, + args.localWorktreeGitOptions, + args.canonicalWorktreePath + ) + return preservedBranchResult(args.registeredWorktree, args.deleteBranch) +} + +export async function pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval( + args: StaleLocalWorktreeRegistrationArgs +): Promise { + await pruneRequiredGitWorktreeRegistration( + args.repoPath, + args.localWorktreeGitOptions, + args.canonicalWorktreePath + ) + return preservedBranchResult(args.registeredWorktree, args.deleteBranch) +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c05e5b27e..46ba20b30 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -70,6 +70,7 @@ import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest } from './rpc/core' import { TERMINAL_METHODS } from './rpc/methods/terminal' +const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') function setPlatform(platform: NodeJS.Platform): void { @@ -20899,6 +20900,127 @@ describe('OrcaRuntimeService', () => { ) }) + it('recovers forced Windows runtime long-path removal and keeps skipped-hook warnings', async () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + await mkdir(TEST_WORKTREE_PATH, { recursive: true }) + await writeFile(join(TEST_WORKTREE_PATH, 'scratch.txt'), 'delete me') + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ + stdout: '', + stderr: '' + }) + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + archive: 'pnpm worktree:archive' + } + }) + vi.mocked(removeWorktree).mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: 'error: failed to delete deep/file.txt: Filename too long' + }) + ) + + try { + const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true) + + expect(result).toEqual({ + preservedBranch: { branchName: 'feature/foo', head: 'abc' }, + warning: `orca.yaml archive hook skipped for ${TEST_WORKTREE_PATH}; pass --run-hooks to run it.` + }) + expect(gitSpy).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: TEST_REPO_PATH + }) + if (ORIGINAL_PLATFORM === 'win32') { + await expect(lstat(TEST_WORKTREE_PATH)).rejects.toMatchObject({ code: 'ENOENT' }) + } + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(TEST_WORKTREE_ID) + } finally { + gitSpy.mockRestore() + await rm(TEST_WORKTREE_PATH, { recursive: true, force: true }) + } + }) + + it('keeps runtime metadata when long-path recovery deletes the directory but prune fails', async () => { + setPlatform('win32') + const removeWorktreeMeta = vi.fn() + const runtimeStore = { + ...store, + removeWorktreeMeta + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockRejectedValue( + Object.assign(new Error('git prune failed'), { + stderr: 'fatal: unable to lock worktree admin dir' + }) + ) + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(removeWorktree).mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: 'error: failed to delete deep/file.txt: Filename too long' + }) + ) + + try { + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)).rejects.toThrow( + 'Git still has stale worktree registration' + ) + expect(removeWorktreeMeta).not.toHaveBeenCalled() + } finally { + gitSpy.mockRestore() + } + }) + + it('retries stale runtime Git registration cleanup after prior filesystem recovery', async () => { + setPlatform('win32') + const missingWorktreePath = 'C:\\workspace\\already-removed' + const worktreeId = `${TEST_REPO_ID}::${missingWorktreePath}` + const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) + const runtime = new OrcaRuntimeService(runtimeStore as never) + const registeredWorktrees = [ + { + path: TEST_REPO_PATH, + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: missingWorktreePath, + head: 'abc', + branch: 'refs/heads/feature/foo', + isBare: false, + isMainWorktree: false + } + ] + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ + stdout: '', + stderr: '' + }) + vi.mocked(listWorktrees).mockResolvedValue(registeredWorktrees) + vi.mocked(listWorktreesStrict).mockResolvedValue(registeredWorktrees) + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + archive: 'pnpm worktree:archive' + } + }) + + try { + const result = await runtime.removeManagedWorktree(worktreeId, true) + + expect(result).toEqual({ + preservedBranch: { branchName: 'feature/foo', head: 'abc' } + }) + expect(runHook).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(gitSpy).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: TEST_REPO_PATH + }) + expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + } finally { + gitSpy.mockRestore() + } + }) + it('routes runtime worktree removal through the selected WSL project runtime', async () => { setPlatform('win32') const runtimeStore = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e2dfaf591..3c22a7655 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -413,6 +413,10 @@ import { removeLocalWorktreePath, toLocalWorktreeRuntimePath } from '../local-worktree-filesystem' +import { + pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval, + recoverLocalWindowsLongPathWorktreeRemoval +} from '../local-worktree-removal-recovery' import { connect as connectLinear, disconnect as disconnectLinear, @@ -14302,6 +14306,44 @@ export class OrcaRuntimeService { } const canonicalWorktreePath = registeredWorktree.path const deleteBranch = removedMeta?.preserveBranchOnDelete !== true + + // Why: a prior forced Windows recovery can delete the directory but leave + // Git's stale registration; retry by pruning instead of removing a missing path. + if ( + !repo.connectionId && + force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isRuntimeWorktreePathMissing(repo, canonicalWorktreePath, localWorktreeGitOptions)) + ) { + const removalResult = await pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree, + deleteBranch + }) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + ) + this.rememberPreservedBranchCleanupTarget( + removalTarget.id, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return removalResult ?? {} + } if (repo.connectionId) { const rawRemovalResult = await (deleteBranch ? provider!.removeWorktree(canonicalWorktreePath, force) @@ -14412,7 +14454,24 @@ export class OrcaRuntimeService { registeredWorktree.head ) } catch (error) { - if (isOrphanedWorktreeError(error)) { + // Why: Git for Windows can fail long-path directory deletion after + // Orca has already validated the target and explicit force delete. + const recoveredRemovalResult = await recoverLocalWindowsLongPathWorktreeRemoval({ + error, + force, + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree, + deleteBranch, + closeWatcher: (worktreePath) => + closeLocalWatcherForWorktreePath(worktreePath).catch((err) => { + console.warn(`[filesystem-watcher] failed to close ${worktreePath}:`, err) + }) + }) + if (recoveredRemovalResult) { + removalResult = recoveredRemovalResult + } else if (isOrphanedWorktreeError(error)) { const access = getLocalWorktreePathAccess(localWorktreeGitOptions) if ( await canSafelyRemoveOrphanedWorktreeDirectory( @@ -14457,8 +14516,9 @@ export class OrcaRuntimeService { return { ...(warning ? { warning } : {}) } + } else { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } - throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } await cleanupUnusedWorktreePushTargetRemote(