Files
orca/src/main/runtime/runtime-managed-worktree-queries.ts
T
Neil 886fcf083f fix(runtime): let a scoped worktree listing report the host it could not cover (#18645)
* fix(runtime): let a scoped worktree listing report the host it could not cover

`orca worktree list --repo <id>` passed `[]` as `knownHostIds`, so a scoped
listing could never report a gap — for any host kind, reachable or not. With zero
matched rows both scope lists are empty by construction, and the answer is
`{hostIds: [], omittedHostIds: []}`: byte-identical to a repo that genuinely has
no worktrees. docs/reference/ssh-execution-boundary.md forbids a listing from
implying exactly that.

Measured on one runtime with one refusing SSH host, in the same second:

  unscoped  totalCount 0  omittedHostIds ["local","ssh:<target>"] (+ --host selectors)
  scoped    totalCount 0  hostScope {"hostIds":[],"omittedHostIds":[]}

The same runtime reports nine omitted hosts unscoped and zero scoped against a
live profile, so this is not a subtle inconsistency: it is one runtime giving two
contradictory answers about its own coverage.

A scoped listing now names the one host the caller asked about. That costs
nothing when rows come back — the host lands in `covered`, so it is never
reported omitted — and is the whole answer when they do not. Hosts the caller
scoped out are still never named, which a test pins, because naming them all is
the obvious over-correction.

* test(runtime): pin the scoped host derivation for local and executionHostId repos

Review flagged that the host-scope cases only covered a connectionId repo.
getRepoExecutionHostId reads two spellings, and a scoped listing naming the
wrong host would be worse than naming none, so both are pinned. Both fail with
the fix reverted.
2026-09-04 05:06:44 -07:00

325 lines
13 KiB
TypeScript

import type { DetectedWorktreeListResult, Worktree } from '../../shared/worktree/types'
import type { Repo } from '../../shared/repo-types'
import type { RuntimeWorktreeListResult } from '../../shared/runtime-types'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import { buildWorktreeListingPage, listingKnownHostIds } from './worktree-listing-host-scope'
import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta'
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import { isFolderRepo } from '../../shared/repo-kind'
import {
applyMetadataFallbackVisibility,
buildKnownOrcaWorkspaceLayouts,
isLegacyRepoForExternalWorktreeVisibility,
toDetectedWorktree
} from '../../shared/worktree/ownership'
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
import {
createWorktreeVisibilitySourceMatcher,
resolveCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatcher
} from '../../shared/worktree/visibility-sources'
import { mergeWorktree } from '../ipc/worktree-logic'
import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning'
import { pruneMetadataMissingFromAuthoritativeLocalScan } from '../ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning'
import type { NativeLocalWorktreeMetadataScanExpectation } from '../persistence/tracking-repos/missing-local-worktree-metadata-pruning'
import { getLocalWorktreeScanGeneration } from '../local-worktree-scan-generation'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import type { Store } from '../persistence'
import type { RuntimeStore } from './runtime-store-contract'
import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan'
import { listRuntimeFolderWorkspaces } from './runtime-worktree-filesystem'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path'
import { getRetiredNameRegistryForRepo } from '../worktree-name-retirement'
type Dependencies = {
getStore(): RuntimeStore | null
listResolved(): Promise<ResolvedWorktree[]>
resolveRepo(selector: string): Promise<Repo>
selectRepos(selector: string): Repo[]
scanRepo(repo: Repo): Promise<RuntimeWorktreeScanResult>
/** Hosts this runtime has repos or workspaces on, so a host with no rows is still named. */
listKnownHostIds(): Iterable<ExecutionHostId>
}
/**
* The destructive scan expectation for one repo, or undefined when this repo must not carry one.
*
* WSL-routed repos are excluded for the same reason the desktop listing excludes them: the listing
* runs in the distro and reports Linux paths while metadata can hold UNC ones, and v1 cannot prove
* those aliases equivalent. A runtime that needs repair throws rather than resolving routing, which
* is likewise no basis for deleting rows.
*/
function captureLocalMetadataPruneExpectation(
store: RuntimeStore,
repo: Repo
): NativeLocalWorktreeMetadataScanExpectation | undefined {
if (typeof store.captureNativeLocalWorktreeMetadataScanExpectation !== 'function') {
return undefined
}
try {
if (getLocalProjectWorktreeGitOptions(store as unknown as Store, repo).wslDistro) {
return undefined
}
} catch {
return undefined
}
return store.captureNativeLocalWorktreeMetadataScanExpectation(repo)
}
export class RuntimeManagedWorktreeQueries {
constructor(private readonly deps: Dependencies) {}
async list(
repoSelector: string | undefined,
limit: number,
sourceDefaultsSupported = true
): Promise<RuntimeWorktreeListResult> {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
}
const resolved = await this.deps.listResolved()
const scopedRepo = repoSelector ? await this.deps.resolveRepo(repoSelector) : null
const pathsByRepo = new Map<string, string[]>()
for (const worktree of resolved) {
const paths = pathsByRepo.get(worktree.repoId) ?? []
paths.push(worktree.path)
pathsByRepo.set(worktree.repoId, paths)
}
const visibilityDefaults = this.visibilityDefaults(sourceDefaultsSupported)
const matchers = new Map(
(this.deps.getStore()?.getRepos() ?? []).map((repo) => [
repo.id,
createWorktreeVisibilitySourceMatcher(
[repo.path, ...(pathsByRepo.get(repo.id) ?? [])],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
])
)
const worktrees = resolved.filter(
(worktree) =>
(!scopedRepo || worktree.repoId === scopedRepo.id) &&
this.isVisible(worktree, matchers.get(worktree.repoId), sourceDefaultsSupported)
)
// See `listingKnownHostIds`: a scoped listing must still name the host it was asked about.
const knownHostIds = listingKnownHostIds(scopedRepo, () => this.deps.listKnownHostIds())
return buildWorktreeListingPage(worktrees, limit, knownHostIds)
}
resolveRepoForConnection(selector: string, connectionId?: string | null): Promise<Repo> {
if (connectionId === undefined) {
return this.deps.resolveRepo(selector)
}
const wanted = connectionId?.trim() || null
const matches = this.deps
.selectRepos(selector)
.filter((repo) => (repo.connectionId?.trim() || null) === wanted)
if (matches.length !== 1) {
throw new Error(matches.length > 1 ? 'selector_ambiguous' : 'repo_not_found')
}
return Promise.resolve(matches[0])
}
async listDetected(
repo: Repo,
sourceDefaultsSupported = true
): Promise<DetectedWorktreeListResult> {
const store = this.deps.getStore()
if (!store) {
throw new Error('runtime_unavailable')
}
const settings = store.getSettings()
const visibilityDefaults = this.visibilityDefaults(sourceDefaultsSupported)
const visibilitySettings = { ...settings, worktreeVisibilityDefaults: visibilityDefaults }
if (isFolderRepo(repo)) {
const worktrees = listRuntimeFolderWorkspaces(store, repo)
const metaById = store.getAllWorktreeMeta()
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
const matcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
const detected = worktrees.map((worktree) =>
this.toDetected(
repo,
worktree,
matcher,
sourceDefaultsSupported,
visibilitySettings,
getRepoOwnedWorktreeMeta(repo, worktree.id, metaById, repoOwnerCount) ?? null
)
)
return {
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
}
}
// Why capture before the scan: listing can mutate metadata synchronously before its first
// await, and the prune revalidates against the rows as they stood when the scan was issued.
const metadataScanGeneration = getLocalWorktreeScanGeneration(repo.id)
const metadataPruneExpectation = captureLocalMetadataPruneExpectation(store, repo)
let scan: RuntimeWorktreeScanResult
try {
scan = await this.deps.scanRepo(repo)
} catch {
scan = { ok: false, worktrees: [] }
}
if (scan.ok) {
// Why the runtime sweeps too: the desktop listing that used to own this runs off `ipcMain`,
// so a headless host -- which has no renderer -- never pruned its own repos' rows (#17776).
if (metadataPruneExpectation) {
await pruneMetadataMissingFromAuthoritativeLocalScan({
store: store as unknown as Store,
repo,
gitWorktrees: scan.worktrees,
scan: metadataPruneExpectation,
scanGeneration: metadataScanGeneration
})
}
pruneLineageForMissingRepoWorktrees(store as unknown as Store, repo, scan.worktrees)
}
const matcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...scan.worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
const expectedHostId = getRepoExecutionHostId(repo)
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
const metaById = store.getAllWorktreeMeta()
const detected = scan.worktrees.map((gitWorktree) => {
const id = `${repo.id}::${gitWorktree.path}`
const meta =
readWorktreeMetaForHost(store as unknown as Store, id, expectedHostId) ??
getRepoOwnedWorktreeMeta(repo, id, metaById, repoOwnerCount)
const worktree = {
...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
hostId: repoOwnerCount === 1 ? (meta?.hostId ?? expectedHostId) : expectedHostId
}
const result = this.toDetected(
repo,
worktree,
matcher,
sourceDefaultsSupported,
visibilitySettings,
meta ?? null
)
return scan.ok ? result : applyMetadataFallbackVisibility(result)
})
return {
repoId: repo.id,
authoritative: scan.ok,
source: scan.ok ? 'git' : 'metadata-fallback',
worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
}
}
isVisible(
worktree: Worktree,
matcher?: WorktreeVisibilitySourceMatcher,
sourceDefaultsSupported = true,
providedSettings?: ReturnType<RuntimeStore['getSettings']>
): boolean {
const repo = this.deps.getStore()?.getRepo(worktree.repoId)
return repo
? this.toDetected(repo, worktree, matcher, sourceDefaultsSupported, providedSettings).visible
: true
}
buildVisibilityMatchers(
worktrees: readonly Worktree[],
sourceDefaultsSupported = true,
providedSettings?: ReturnType<RuntimeStore['getSettings']>
): Map<string, WorktreeVisibilitySourceMatcher> {
const checkoutPathsByRepoId = new Map<string, string[]>()
for (const worktree of worktrees) {
const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? []
checkoutPaths.push(worktree.path)
checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths)
}
const visibilityDefaults = this.visibilityDefaults(sourceDefaultsSupported, providedSettings)
return new Map(
(this.deps.getStore()?.getRepos() ?? [])
.filter((repo) => checkoutPathsByRepoId.has(repo.id))
.map((repo) => [
repo.id,
createWorktreeVisibilitySourceMatcher(
[repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
])
)
}
private toDetected(
repo: Repo,
worktree: Worktree,
matcher?: WorktreeVisibilitySourceMatcher,
sourceDefaultsSupported = true,
providedSettings?: ReturnType<RuntimeStore['getSettings']>,
providedMeta?: WorktreeMeta | null
) {
const store = this.deps.getStore()
const settings = providedSettings ?? store?.getSettings()
if (!settings) {
return {
...worktree,
ownership: 'unknown-legacy' as const,
selectedCheckout: false,
visible: true
}
}
const visibilityDefaults = this.visibilityDefaults(sourceDefaultsSupported, settings)
return toDetectedWorktree({
repo,
worktree,
meta:
providedMeta === undefined
? store?.getWorktreeMeta(worktree.id)
: (providedMeta ?? undefined),
settings: { ...settings, worktreeVisibilityDefaults: visibilityDefaults },
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo),
isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo),
worktreeVisibilitySourceMatcher: matcher
})
}
async listRetiredNames(repoSelector: string): Promise<{
retiredNamesByRepo: Record<string, readonly string[]>
retiredNameTiersByRepo: Record<string, number>
}> {
const store = this.deps.getStore()
if (!store?.getRetiredWorktreeNameRegistry || !store.mergeRetiredWorktreeNames) {
return { retiredNamesByRepo: {}, retiredNameTiersByRepo: {} }
}
const repo = await this.deps.resolveRepo(repoSelector)
const settings = store.getSettings()
const registry = await getRetiredNameRegistryForRepo(
store as never,
repo,
store.getRepos(),
settings
)
return {
retiredNamesByRepo: { [repo.id]: registry.names },
retiredNameTiersByRepo: { [repo.id]: registry.exhaustedTiers }
}
}
private visibilityDefaults(
sourceDefaultsSupported: boolean,
providedSettings?: ReturnType<RuntimeStore['getSettings']>
) {
const defaults =
providedSettings !== undefined
? providedSettings.worktreeVisibilityDefaults
: this.deps.getStore()?.getSettings().worktreeVisibilityDefaults
return sourceDefaultsSupported || !defaults ? defaults : { external: defaults.external }
}
}