mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* 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>
76 lines
2.7 KiB
TypeScript
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
|
|
}
|