Files
orca/src/main/runtime/runtime-worktree-filesystem.ts
T
Neil 3c91404820 fix(worktrees): route managed worktree removal by resolved execution host (#18529)
`removeManagedWorktree` resolved its host once — for the metadata prune
(`cleanupHostId ?? getRepoExecutionHostId(repo)`) — and then read raw
`repo.connectionId` for every step that touches the filesystem: the
`git worktree list` deciding whether the path is registered, the provider handed
to the unregistered-removal branch, the registered-remote-vs-local fork, and the
PTY/history teardown. One function, two spellings.

For a row naming its owner only as `executionHostId: 'ssh:<target>'` — the exact
class #18296 names — the list ran on the client against a remote path,
`removeRuntimeUnregisteredWorktree` was entered with `provider: null`, and the
metadata was pruned under `ssh:<target>` while a same-named *local* directory was
the one considered for deletion (#11163). #18358 made this reachable: it migrated
the cleanup scan, so `executionHostId`-only rows now surface as removable
candidates, but removal did not move with it.

Routing is now one answer for the whole removal, taken from the host the prune
already used, through #18296's host-keyed dispatch. The ambiguous
`provider: SshGitProvider | null` carrier is deleted from the callees rather than
supplemented, so every remaining reader is a compile error in the typed modules
that do the destructive work (`runtime-unregistered-worktree-removal`,
`runtime-registered-remote-worktree-removal`, `runtime-worktree-filesystem`).
The orchestrator itself carries `@ts-nocheck` from its mechanical split, so that
guarantee does not reach it — tests cover it instead.

Every change is in the refusing direction; nothing became more aggressive:

- an `ssh:` host with no registered provider throws instead of deleting a
  client-side path (`requireSshGitProvider` already threw for rows that spelled
  the same host as `connectionId`);
- `runtime:<env>` throws rather than dialling a same-named target in this
  client's namespace, matching `workspace-cleanup-git-route` and
  `runtime-git-command-target`;
- the folder-workspace teardown resolves its connection instead of reading the
  raw field, so a `runtime:` row stops dialling the wrong namespace.

No wire or persistence change: `removeWorktreeMetadataAndHistory` already took
the resolved host, and the removal RPC result shape is untouched.
2026-09-03 16:21:52 -07:00

117 lines
4.4 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id'
import type { Repo } from '../../shared/repo-types'
import type { Worktree } from '../../shared/worktree/types'
import { gitExecFileAsync } from '../git/runner'
import { isENOENT } from '../ipc/filesystem-auth'
import {
getLocalWorktreePathAccess,
toLocalWorktreeRuntimePath
} from '../local-worktree-filesystem'
import {
ExecutionHostNotDispatchableError,
resolveFilesystemRouteForHost
} from '../providers/execution-host-provider-dispatch'
import { isWorktreePathMissing } from '../worktree-removal-safety'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import {
getRuntimeFolderWorkspaceRootId,
isRuntimeFolderWorkspaceIdForRepo,
mergeRuntimeFolderWorkspace
} from './runtime-folder-workspace'
import type { RuntimeStore } from './runtime-store-contract'
import { gitStatusErrorMeansNotRepository } from './runtime-worktree-selection'
// Takes the resolved host rather than the repo: reading `repo.connectionId` answered "stat this on
// the client" for a row that names its owner only as `executionHostId: 'ssh:<target>'`, which is
// the evidence a forced removal prunes registrations on.
export async function isRuntimeWorktreePathMissing(
hostId: ExecutionHostId,
worktreePath: string,
localWorktreeGitOptions: { wslDistro?: string } = {}
): Promise<boolean> {
const route = resolveFilesystemRouteForHost(hostId)
if (route.kind === 'runtime') {
throw new ExecutionHostNotDispatchableError(route.hostId)
}
if (route.kind === 'local') {
const access = getLocalWorktreePathAccess(localWorktreeGitOptions)
return isWorktreePathMissing(
toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions),
access.statPath
)
}
const fsProvider = route.provider
return fsProvider ? isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) : false
}
export async function isLocalRuntimeGitRepository(
runtimeWorktreePath: string,
localWorktreeGitOptions: { wslDistro?: string } = {}
): Promise<boolean> {
try {
await gitExecFileAsync(['status', '--short'], {
cwd: runtimeWorktreePath,
...localWorktreeGitOptions
})
return true
} catch (error) {
return !gitStatusErrorMeansNotRepository(error)
}
}
function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string {
const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID()
}
export function listRuntimeFolderWorkspaces(
store: Pick<RuntimeStore, 'getAllWorktreeMeta' | 'getRepos' | 'setWorktreeMeta'>,
repo: Repo,
repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
): Worktree[] {
const rootId = getRuntimeFolderWorkspaceRootId(repo)
const allMeta = store.getAllWorktreeMeta()
const expectedHostId = getRepoExecutionHostId(repo)
const ids = Object.keys(allMeta).filter(
(worktreeId) =>
isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId) &&
(repoOwnerCount === 1 || allMeta[worktreeId]?.hostId === expectedHostId)
)
if (!ids.includes(rootId)) {
ids.unshift(rootId)
} else {
ids.sort((left, right) => (left === rootId ? -1 : right === rootId ? 1 : 0))
}
return ids.map((worktreeId) => {
const existing = getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount)
const meta: Partial<WorktreeMeta> = existing?.instanceId
? existing
: existing || repoOwnerCount === 1
? store.setWorktreeMeta(worktreeId, {
instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId),
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
})
: {}
return {
...mergeRuntimeFolderWorkspace(repo, worktreeId, meta),
hostId: repoOwnerCount === 1 ? (meta.hostId ?? expectedHostId) : expectedHostId
}
})
}
export async function runtimePathExists(pathValue: string): Promise<boolean> {
try {
await stat(pathValue)
return true
} catch (error) {
if (isENOENT(error)) {
return false
}
throw error
}
}