Files
orca/src/shared/worktree-removal.ts
T
OrcaWinandOrcaWin 20a2901677 fix(worktree): tell the truth about live PTYs, and offer force for a wedged sweep (#12394)
* fix(worktree): tell the truth about live PTYs, and offer force for a wedged sweep

Two gaps in the #11960 force path:

The delete toast described every unstopped-PTY failure as "could not confirm
every terminal has exited", including the case where verification positively
watched them running. Force Delete proceeds either way, so the user was being
asked to waive a doubt that did not exist while a running agent's uncommitted
work died with it. The live verdict now gets copy that says so.

A sweep that rejects before any per-PTY verdict exists (wedged daemon, dropped
SSH channel) fails with a teardown-timeout message that the force classifier did
not recognise, so no Force Delete button appeared — the exact dead end #11960
set out to remove. That error now carries the shared prefix and classifies.

* fix(worktree): close the sweep-rejection wedge and stop racing the delete

Review of #12394 found the fix covered only half the wedge it named, and
routed users into a force path whose own safety comment was untrue.

1. Only the outer deadline was classifiable. When a provider *rejects* the
   sweep — dropped SSH channel, erroring daemon — settleBeforeDeadline
   rejects with the provider's original error, which carries no marker, so
   classifyWorktreeForceDeleteReason still returned null and no Force Delete
   button rendered. That is the exact case #11960 named. A rejected sweep on
   the destructive path is now reworded through the existing unstopped-PTY
   prefix (provider text preserved, original kept as `cause`), so old and new
   clients alike classify it as 'unstopped-pty'.

2. Force could delete files while a sweep was still running. The deadline
   rejects without cancelling run(), so allSettled resolved with shutdown()
   still in flight — by construction the deadline error can only fire while
   something is in flight. Force then deleted the directory a live PTY still
   held open (EBUSY / half-delete on Windows and WSL). Sweeps are now tracked
   so the forced path waits for the abandoned work, bounded by a 2s grace;
   force never wedges, and when the grace expires the warning says handles may
   outlive the delete instead of implying the sweep finished.

3. The toast test named for the classifier passed the reason in as a literal,
   so it never exercised it. It now derives the reason exactly as the store
   does, and fails against main.

4. Added the missing unstoppedPtyLive key to the English catalog.

5. isProvenLivePtyRemovalError anchored the 'still live:' marker to the detail
   separator, so a worktree path can no longer spell out a live verdict and
   flip the toast to the destructive copy.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:21 -07:00

125 lines
5.2 KiB
TypeScript

import type { GitWorktreeInfo } from './types'
export const LOCKED_WORKTREE_REMOVAL_PREFIX = 'Worktree is locked by Git.'
export const UNSTOPPED_PTY_REMOVAL_PREFIX = 'Failed to physically stop every PTY for worktree:'
// Why (#11960): the desktop force affordance is driven entirely by the classifier
// below, so this hint and its matcher must stay in the same file — a message that
// tells the user to force-delete while the UI hides the button is the same dead end.
export const WORKTREE_TEARDOWN_FORCE_HINT = 'Retry with force delete (--force) to remove it anyway.'
export type WorktreeForceDeleteReason =
| 'dirty'
| 'orphan-directory'
| 'missing-registration'
| 'unstopped-pty'
// Why: everything before this separator is the worktree id — a user-chosen filesystem path.
// Only the detail after it is Orca's own wording, so verdict matchers anchor on the boundary
// rather than scanning the whole message and letting a path spell out a verdict.
export const UNSTOPPED_PTY_DETAIL_SEPARATOR = ' — '
// Why: verification distinguishes a PTY it watched stay alive from one it could not reach,
// and the delete toast must not flatten the two — a user waiving "we could not confirm" is
// making a different decision than one killing a terminal Orca just saw running. The marker
// and its matcher stay together for the same reason the force hint does.
export const UNSTOPPED_PTY_LIVE_DETAIL_PREFIX = 'still live:'
// Why (#11960): a sweep that never answers wedges removal exactly like a stop that could not
// be proven, and the waiver clears both — but this error carries different words, so without
// its own matcher the force affordance stayed hidden for the very case it was added for.
export const WORKTREE_TEARDOWN_TIMEOUT_PREFIX = 'Timed out waiting for physical PTY teardown:'
export function isUnstoppedPtyRemovalError(error: string): boolean {
return (
error.includes(UNSTOPPED_PTY_REMOVAL_PREFIX) || error.includes(WORKTREE_TEARDOWN_TIMEOUT_PREFIX)
)
}
/** True only when verification positively observed the PTYs still running. */
export function isProvenLivePtyRemovalError(error: string): boolean {
return (
isUnstoppedPtyRemovalError(error) &&
error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX}`)
)
}
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,
allowUnverifiedPtyStop = 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
}
// Why (#11960): this must be decided before the `force` guard below. The ordinary
// delete confirmation already passes force:true to skip the dirty-file prompt, but
// it does NOT waive PTY-stop proof — so `force` alone is no evidence that the user
// has already spent this escape hatch. Only the waiver itself is.
if (isUnstoppedPtyRemovalError(error)) {
return allowUnverifiedPtyStop ? null : 'unstopped-pty'
}
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
}