Files
orca/src/main/runtime/workspaces/workspace-removal.ts
T

298 lines
12 KiB
TypeScript

import type { RuntimeStore } from '../runtime-store-contract'
import type { OrcaRuntimeWithStopTerminalsForWorktree } from '../orca-runtime-stop-terminals-for-worktree'
import type { GitWorktreeInfo } from '../../../shared/worktree/types'
import { OrcaRuntimeWithCreateManagedRemoteWorktree } from '../orca-runtime-create-managed-remote-worktree'
import {
resolveWorktreeRemovalMetadata,
resolveWorktreeRemovalRepoOwner
} from '../../worktree-removal-repo-owner'
import type { RemoveWorktreeResult } from '../../../shared/worktree/create-types'
import {
getRepoExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { finishAcceptedWorktreeRemoval } from '../../worktree-background-removal'
import { preservedBranchCleanupScopeKey } from '../../../shared/preserved-branch-cleanup'
import {
getRuntimeWorktreeRemovalOptionsKey,
type RemoveManagedWorktreeOptions,
type RuntimeWorktreeRemovalTarget
} from '../runtime-worktree-selection'
import { withWorktreeSpan } from '../../observability/instrumentation'
import {
resolveWorktreeRemovalHome,
resolveWorktreeRemovalRoute
} from '../../worktree-removal-execution-host-route'
import { listWorktreesOnRemovalRoute } from '../worktree-removal-route-listing'
import { isPrunableGitFileWorktree } from '../../worktree-prunable-git-file'
import { findRegisteredDeletableWorktree } from '../../worktree-removal-safety'
import { removeRuntimeUnregisteredWorktree } from '../runtime-unregistered-worktree-removal'
import { assertWorktreeUnlockedForRemoval } from '../../../shared/worktree/removal'
import { formatWorktreeRemovalError } from '../../ipc/worktree-logic'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import { isLocalRuntimeWorktreePathMissing } from '../runtime-worktree-filesystem'
import { removeStaleLocalWorktreeRegistration } from '../../local-worktree-removal-recovery'
import { cleanupUnusedWorktreePushTargetRemote } from '../../ipc/worktree-remote'
import { removeRuntimeRegisteredLocalWorktree } from '../runtime-registered-local-worktree-removal'
import { removeOrphanOrFolderWorktree } from './workspace-registration-removal'
export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateManagedRemoteWorktree {
abstract forgetStructuredSessionMail(sessionId: string): void
abstract stopTerminalsForWorktree(
...args: Parameters<OrcaRuntimeWithStopTerminalsForWorktree['stopTerminalsForWorktree']>
): ReturnType<OrcaRuntimeWithStopTerminalsForWorktree['stopTerminalsForWorktree']>
abstract removeWorktreeMetadataAndHistory(
store: RuntimeStore,
worktreeId: string,
hostId?: ExecutionHostId
): void
abstract invalidateResolvedWorktreeCache(): void
abstract invalidateWorktreeScanCacheForRepo(repoId: string): void
protected abstract resolveWorktreeRemovalTarget(
worktreeSelector: string,
requiredHostId?: ExecutionHostId
): Promise<RuntimeWorktreeRemovalTarget>
protected abstract joinPendingWorktreeRemoval(
worktreeId: string,
options: RemoveManagedWorktreeOptions
): Promise<RemoveWorktreeResult> | undefined
protected abstract purgeRemovedWorktree(
store: RuntimeStore,
worktreeId: string,
repoId: string,
removalHostId?: ExecutionHostId
): void
protected abstract retryFailedLocalRemoval(
route: { kind: string },
target: { id: string; path: string },
registeredWorktrees: readonly GitWorktreeInfo[],
options: RemoveManagedWorktreeOptions
): boolean
async removeManagedWorktree(
worktreeSelector: string,
options: RemoveManagedWorktreeOptions = {}
): Promise<RemoveWorktreeResult & { warning?: string }> {
const {
force = false,
runHooks = false,
allowUnverifiedPtyStop = false,
allowFailedArchiveHook = false,
hostId
} = options
if (!this.store) {
throw new Error('runtime_unavailable')
}
const store = this.store
const cleanupHostId = parseExecutionHostId(hostId)?.id
const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId)
// Why: a retry or a second client asking while Git still deletes joins that removal.
const pending = this.joinPendingWorktreeRemoval(removalTarget.id, options)
if (pending) {
return options.waitForBackgroundRemoval ? await pending : { removing: true }
}
const emitRemoved = (): void =>
this.emitWorktreeLifecycle({
kind: 'removed',
worktreeId: removalTarget.id,
path: removalTarget.path
})
const cleanupScopeKey = preservedBranchCleanupScopeKey({
worktreeId: removalTarget.id,
hostId: cleanupHostId
})
const optionsKey = getRuntimeWorktreeRemovalOptionsKey({
force,
runHooks,
allowUnverifiedPtyStop,
allowFailedArchiveHook
})
const inFlightRemoval = this.removeManagedWorktreeInFlight.get(
cleanupScopeKey,
removalTarget.id,
optionsKey
)
if (inFlightRemoval) {
return options.waitForBackgroundRemoval
? finishAcceptedWorktreeRemoval(await inFlightRemoval, removalTarget.id, cleanupHostId)
: inFlightRemoval
}
const removal = (async (): Promise<RemoveWorktreeResult & { warning?: string }> => {
return withWorktreeSpan({ stage: 'remove', path: removalTarget.path }, async () => {
const repoOwner = resolveWorktreeRemovalRepoOwner(
store,
removalTarget.repoId,
cleanupHostId
)
if (repoOwner.kind === 'ambiguous') {
throw new Error(
`Workspace identity is ambiguous across hosts: ${removalTarget.id}. Retry with an explicit host.`
)
}
const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined
const removalHostId = repo ? (cleanupHostId ?? getRepoExecutionHostId(repo)) : cleanupHostId
const purgeRemovedWorktree = (): void =>
this.purgeRemovedWorktree(store, removalTarget.id, removalTarget.repoId, removalHostId)
const orphanOrFolderResult = await removeOrphanOrFolderWorktree({
runtime: this,
store,
removalTarget,
cleanupHostId,
removalHostId,
repo
})
if (orphanOrFolderResult) {
return orphanOrFolderResult
}
// One host for the whole removal. Listing on a different host from the one the prune and
// the delete use is how an `executionHostId: 'ssh:*'`-only row got listed remotely and
// deleted here; the route refuses rather than falling back to this machine.
// The helper returns for missing/folder repos; this path has a tracked repo and its host.
const route = resolveWorktreeRemovalRoute(removalHostId!)
const { localWorktreeGitOptions, registeredWorktrees } = await listWorktreesOnRemovalRoute(
route,
repo!,
this.requireStore()
)
const removedMeta = resolveWorktreeRemovalMetadata(
store,
removalTarget.repoId,
removalTarget.id,
removalHostId!
)
const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget
const removalHome = resolveWorktreeRemovalHome(route)
const registeredWorktree = findRegisteredDeletableWorktree(
repo!.path,
removalTarget.path,
registeredWorktrees,
removalHome
)
if (this.retryFailedLocalRemoval(route, removalTarget, registeredWorktrees, options)) {
return { removing: true }
}
if (!registeredWorktree) {
return removeRuntimeUnregisteredWorktree({
repo: repo!,
target: removalTarget,
registeredWorktrees,
removedMeta,
removedPushTarget,
force,
allowUnverifiedPtyStop,
route,
localOptions: localWorktreeGitOptions,
store,
acquireWatcherRemoval: this.acquireFileWatcherRemoval,
stopPtys: (worktreeId, allowUnverifiedPtyStop) =>
this.stopPtysForDestructiveWorktreeRemoval(worktreeId, {
allowUnverifiedStop: allowUnverifiedPtyStop
}),
finishRemoval: () => {
this.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId)
purgeRemovedWorktree()
this.notifyWorktreesChanged(repo!.id)
}
})
}
const canonicalWorktreePath = registeredWorktree.path
const deleteBranch = removedMeta?.preserveBranchOnDelete !== true
try {
assertWorktreeUnlockedForRemoval(registeredWorktree)
} catch (error) {
throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force))
}
if (
(await isPrunableGitFileWorktree(registeredWorktree, localWorktreeGitOptions)) ||
(force === true &&
process.platform === 'win32' &&
(isWindowsAbsolutePathLike(canonicalWorktreePath) ||
!!localWorktreeGitOptions.wslDistro) &&
removedMeta &&
(await isLocalRuntimeWorktreePathMissing(
canonicalWorktreePath,
localWorktreeGitOptions
)))
) {
const removalResult = await removeStaleLocalWorktreeRegistration({
canonicalWorktreePath,
repoPath: repo!.path,
localWorktreeGitOptions,
registeredWorktree,
deleteBranch
})
await cleanupUnusedWorktreePushTargetRemote(
repo!.path,
removalTarget.id,
removedPushTarget,
store,
localWorktreeGitOptions
)
this.preservedBranchCleanup.remember(
removalTarget.id,
cleanupHostId,
removalResult,
registeredWorktree.head,
removedPushTarget
)
purgeRemovedWorktree()
this.notifyWorktreesChanged(repo!.id)
return removalResult ?? {}
}
return removeRuntimeRegisteredLocalWorktree({
repo: repo!,
target: removalTarget,
registeredWorktree,
removedPushTarget,
store,
localOptions: localWorktreeGitOptions,
hasLocalOptions: Object.keys(localWorktreeGitOptions).length > 0,
force,
runHooks,
allowFailedArchiveHook,
allowUnverifiedPtyStop,
deleteBranch,
acquireWatcherRemoval: this.acquireFileWatcherRemoval,
stopPtys: () =>
this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, {
allowUnverifiedStop: allowUnverifiedPtyStop
}),
closeWatchers: (path) => this.closeFileWatchersForRemoval(path),
preserveBranchHead: (result, fallbackHead) =>
this.preservedBranchCleanup.preserveHead(result, fallbackHead),
finishRemoval: (result, rememberBranch, fallbackHead) => {
if (rememberBranch) {
this.preservedBranchCleanup.remember(
removalTarget.id,
cleanupHostId,
result,
fallbackHead,
removedPushTarget
)
} else {
this.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId)
}
purgeRemovedWorktree()
},
onRemoved: emitRemoved,
publish: () => this.publishWorktreeRemovalChange(repo!.id)
})
})
})()
this.removeManagedWorktreeInFlight.track(cleanupScopeKey, optionsKey, removal)
try {
const accepted = await removal
// A background removal emits this when Git finishes instead.
if (!accepted.removing) {
emitRemoved()
}
return options.waitForBackgroundRemoval
? finishAcceptedWorktreeRemoval(accepted, removalTarget.id, cleanupHostId)
: accepted
} finally {
this.removeManagedWorktreeInFlight.release(cleanupScopeKey, removal)
}
}
}