mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
* fix(worktree): remove a clean checkout that contains submodules Git refuses to remove any worktree with an initialized submodule unless --force is set, even when nothing is uncommitted. After that specific refusal, re-check the checkout including files inside the submodule and pass --force once. A dirty submodule still blocks the delete. A second --force is not added, because that would also clear a Git lock. * fix(worktree): offer Force Delete for a checkout Git refuses over submodules Drop the automatic --force retry. A forced removal deletes the submodule's repository under .git/worktrees/<id>/modules, including commits, local branches and stashes that were never pushed, and the parent's dirty check cannot see them (the retry #24602 removed for the same reason). Instead classify Git's "working trees containing submodules cannot be moved or removed" refusal as a new Force Delete reason, 'submodules', after the lock check and the force guard. The renderer classifies the error string, so older SSH and paired hosts get it too with no wire change. The toast, the delete dialog and the Space manager row show one plain warning naming what Force Delete would lose instead of Git's text. Keep --ignore-submodules=none in the pre-teardown dirty check so a user's ignore setting cannot hide submodule edits from it. Replace the retry test with one real-Git test: a clean checkout whose submodule holds a local-only commit is refused without --force, stays registered, keeps the commit, and its refusal classifies as 'submodules'. * fix(worktree): clarify the submodule Force Delete warning and hide View for it The warning now says Git deletes the workspace only with Force Delete, the submodule refusal no longer offers View (there are no changes to show), the real-Git test pins that Force Delete then succeeds, and a comment no longer describes the removed retry. * fix(worktree): name submodules when a changed submodule blocks the delete, and clear a stale delete toast With a commit inside a submodule that the parent has not recorded, the parent sees `M vendor`, so Orca's dirty check refused first with the generic "It has changed files". Force Delete then destroyed the never-pushed submodule commit without warning. On a dirty refusal the check now asks `git status --porcelain=v2` whether a changed entry is a submodule, and if so reports a refusal the app shows with the submodule Force Delete warning. The detail keeps the dirty sentence, so older apps still offer Force Delete. After the Space manager deleted a workspace (its row Force, or a later delete), the failure toast for that workspace stayed up and still offered Force Delete. A Space delete now dismisses it. * fix(worktree): warn about submodules on any refused delete of a checkout that has them Review found the warning still vanished when a submodule commit was already recorded and some other file was dirty: the parent's status shows no submodule change, yet Force Delete destroys the commit. The changed-files check now asks whether the checkout has a populated submodule (a .gitmodules path holding a .git, as Git's own refusal checks) instead of whether one changed. The refusal keeps the changed paths after the detail, and View stays available since there are changes to view. * test(worktree): mock the .gitmodules probe as missing in the preflight unit tests * refactor(worktree): attach the dirty-check status without a type assertion * fix(worktree): key the delete failure toast by host and worktree id A Space delete on one host must not dismiss another host's failure toast for a worktree with the same id. * fix(worktree): dismiss the failure toast by the store row's host after a Space row Force
202 lines
8.9 KiB
TypeScript
202 lines
8.9 KiB
TypeScript
import type { ExecutionHostId } from '../execution-host'
|
|
import type { GitWorktreeInfo, Worktree } 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'
|
|
| 'running-agent-session'
|
|
| 'submodules'
|
|
|
|
// 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 process 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 something Orca just saw running. The marker
|
|
// and its matcher stay together for the same reason the force hint does. Shared by the PTY
|
|
// sweep and the structured-session sweep, which both re-observe after their stop.
|
|
export const STILL_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:'
|
|
|
|
// Why (#11960 again): a running agent SESSION blocks removal for the same reason an unstopped PTY
|
|
// does, and it needs its own prefix for the same reason the timeout above needed one — the desktop
|
|
// force affordance comes only from the classifier below, so a refusal with no matcher shows raw
|
|
// CLI wording and hides the Force Delete button. Matcher and hint stay in this file together.
|
|
export const RUNNING_AGENT_SESSION_REMOVAL_PREFIX =
|
|
'Refusing to remove worktree with running agent sessions:'
|
|
|
|
export function isRunningAgentSessionRemovalError(error: string): boolean {
|
|
return error.includes(RUNNING_AGENT_SESSION_REMOVAL_PREFIX)
|
|
}
|
|
|
|
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}${STILL_LIVE_DETAIL_PREFIX}`)
|
|
)
|
|
}
|
|
|
|
/** True only when the observation AFTER the close found the session still attached. */
|
|
export function isProvenLiveStructuredSessionRemovalError(error: string): boolean {
|
|
return (
|
|
isRunningAgentSessionRemovalError(error) &&
|
|
error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${STILL_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
|
|
}
|
|
|
|
// Why: Orca's dirty check refuses before Git's submodule refusal can, so it says submodules itself.
|
|
// Keeps the dirty sentence so older classifiers still offer Force Delete.
|
|
export const SUBMODULE_DIRTY_REMOVAL_DETAIL =
|
|
'Worktree has uncommitted or untracked changes, and it contains submodules.'
|
|
|
|
// Why: non-force `git worktree remove` refuses any checkout with an initialized submodule, clean or
|
|
// not; only --force deletes it, and with it the submodule's never-pushed history. Git output is
|
|
// pinned to English on every host, and the renderer classifies, so older hosts get this too.
|
|
export function isSubmoduleWorktreeRemovalRefusal(error: string): boolean {
|
|
return (
|
|
error.includes('working trees containing submodules cannot be moved or removed') ||
|
|
error.includes(SUBMODULE_DIRTY_REMOVAL_DETAIL)
|
|
)
|
|
}
|
|
|
|
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'
|
|
}
|
|
// Same placement and the same reason: decided BEFORE the `force` guard, because an ordinary
|
|
// desktop delete already passes force:true to skip the dirty-file prompt and that says nothing
|
|
// about whether the user has waived closing a live agent session. Only the waiver itself does.
|
|
if (isRunningAgentSessionRemovalError(error)) {
|
|
return allowUnverifiedPtyStop ? null : 'running-agent-session'
|
|
}
|
|
if (force) {
|
|
return null
|
|
}
|
|
if (isSubmoduleWorktreeRemovalRefusal(error)) {
|
|
return 'submodules'
|
|
}
|
|
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
|
|
}
|
|
|
|
// ─── Host qualification (STA-4343) ───────────────────────────────────
|
|
//
|
|
// A workspace id is `repoId::path` with no host component, so the local host, an
|
|
// SSH host and a paired runtime can all publish the SAME id. The same repo at the
|
|
// same path on two hosts is TWO workspaces, never one — removing by that id alone
|
|
// destroys whichever checkout routing happens to pick, and routing prefers the
|
|
// ACTIVE workspace's host, which is usually not the row the user confirmed.
|
|
//
|
|
// So a destructive removal travels as a host-qualified target and is ROUTED to
|
|
// the confirmed host. Making the field required (not optional) is the point: a
|
|
// future delete entry point cannot be written unguarded without a type error.
|
|
|
|
/**
|
|
* Identity for a destructive workspace removal.
|
|
*
|
|
* `executionHostId` is required but nullable on purpose: `null` states that the
|
|
* confirmed row itself declares no host (a pre-host-qualified snapshot row), so
|
|
* a caller has to make that claim deliberately instead of omitting the field.
|
|
*/
|
|
export type WorktreeRemovalTarget = {
|
|
id: string
|
|
executionHostId: ExecutionHostId | null
|
|
}
|
|
|
|
export function toWorktreeRemovalTarget(
|
|
worktree: Pick<Worktree, 'id' | 'hostId'>
|
|
): WorktreeRemovalTarget {
|
|
return { id: worktree.id, executionHostId: worktree.hostId ?? null }
|
|
}
|