mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
298 lines
12 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
}
|