Recover Windows worktree deletes from long paths (#6433)

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-26 13:28:10 -07:00 committed by GitHub
parent 4a98b11e77
commit d188e7d2f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 549 additions and 11 deletions

View File

@ -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'), {

View File

@ -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

View File

@ -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',

View File

@ -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,

View File

@ -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')

View File

@ -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<void> {
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
}

View File

@ -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<GitWorktreeInfo, 'branch' | 'head'>
deleteBranch: boolean
closeWatcher: (worktreePath: string) => Promise<void>
}
type StaleLocalWorktreeRegistrationArgs = Omit<
LocalWindowsLongPathRecoveryArgs,
'error' | 'force' | 'closeWatcher'
>
function preservedBranchResult(
registeredWorktree: Pick<GitWorktreeInfo, 'branch' | 'head'>,
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<void> {
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<RemoveWorktreeResult | undefined> {
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<RemoveWorktreeResult> {
await pruneRequiredGitWorktreeRegistration(
args.repoPath,
args.localWorktreeGitOptions,
args.canonicalWorktreePath
)
return preservedBranchResult(args.registeredWorktree, args.deleteBranch)
}

View File

@ -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 = {

View File

@ -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(