Files
orca/src/shared/worktree-removal.ts
T
Jinwoo HongandOrca 977960ed4a Make Windows workspace deletion reliable and respect Git locks (#7963)
* Handle locked worktree force deletion

Co-authored-by: Orca <help@stably.ai>

* Harden locked worktree removal

* Recover Windows worktree deletion after Git deregisters

* Require force permission for lock overrides

* Respect Git worktree locks during deletion

* Require structural proof before Windows deletion recovery

* Align deletion tests with fail-closed recovery

* Restore deletion test filesystem spies

* Scope remote deletion tests to runtime host

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-10 17:52:50 -07:00

76 lines
2.7 KiB
TypeScript

import type { GitWorktreeInfo } from './types'
export const LOCKED_WORKTREE_REMOVAL_PREFIX = 'Worktree is locked by Git.'
export type WorktreeForceDeleteReason = 'dirty' | 'orphan-directory' | 'missing-registration'
export function createLockedWorktreeRemovalError(lockReason?: string): Error {
const reason = lockReason?.trim()
return new Error(
reason
? `${LOCKED_WORKTREE_REMOVAL_PREFIX} Lock reason: ${reason}. Run git worktree unlock <worktree-path> from its repository, then retry deletion.`
: `${LOCKED_WORKTREE_REMOVAL_PREFIX} Run git worktree unlock <worktree-path> from its repository, then retry deletion.`
)
}
export function assertWorktreeUnlockedForRemoval(
worktree: Pick<GitWorktreeInfo, 'locked' | 'lockReason'> | undefined
): void {
if (worktree?.locked) {
throw createLockedWorktreeRemovalError(worktree.lockReason)
}
}
export function isLockedWorktreeRemovalError(error: string): boolean {
return (
error.includes(LOCKED_WORKTREE_REMOVAL_PREFIX) ||
error.includes('cannot remove a locked working tree')
)
}
export function getLockedWorktreeRemovalReason(error: string): string | null {
const prefixIndex = error.indexOf(`${LOCKED_WORKTREE_REMOVAL_PREFIX} Lock reason: `)
if (prefixIndex === -1) {
return null
}
const reasonStart = prefixIndex + `${LOCKED_WORKTREE_REMOVAL_PREFIX} Lock reason: `.length
const recoverySuffix =
'. Run git worktree unlock <worktree-path> from its repository, then retry deletion.'
const suffixIndex = error.indexOf(recoverySuffix, reasonStart)
const reason = error.slice(reasonStart, suffixIndex === -1 ? undefined : suffixIndex).trim()
return reason || null
}
const FORMATTED_DIRTY_WORKTREE_REMOVAL_PATTERN =
/Failed to delete worktree at [^\n]*\.\s*(?:(?:[MADRCUT][ MADRCUT]| [MADRCUT]|\?\?)\s+\S)/
export function classifyWorktreeForceDeleteReason(
error: string,
force = false
): WorktreeForceDeleteReason | null {
if (isLockedWorktreeRemovalError(error)) {
// Why: a Git lock can represent an external safety contract. It must be
// unlocked explicitly rather than folded into Orca's dirty-file force path.
return null
}
if (force) {
return null
}
if (error.includes('Worktree is no longer registered with Git but its directory remains')) {
return 'orphan-directory'
}
if (
error.includes('Worktree is no longer registered with Git and its directory is already gone')
) {
return 'missing-registration'
}
if (
error.includes('Worktree has uncommitted or untracked changes') ||
error.includes('contains modified or untracked files') ||
FORMATTED_DIRTY_WORKTREE_REMOVAL_PATTERN.test(error)
) {
return 'dirty'
}
return null
}