fix(runtime): sweep missing local worktree metadata on the host that owns it

`pruneMetadataMissingFromAuthoritativeLocalScan` had exactly one caller:
`ipcMain.handle('worktrees:listAll')`. A headless runtime host has no
renderer, so it never ran, and that host's `worktreeMeta` grew without bound
even for its own local repos -- 129 of 139 rows dangling on the profile in
#17776.

Run it from the runtime's own detected listing instead. That is the same
trigger on the same evidence: `listDetected` already prunes lineage on an
authoritative scan, and a paired client refreshing a remote repo calls
`worktree.detectedList`, so the host now sweeps exactly when the desktop
would have.

The expectation is captured before the scan, because listing can mutate
metadata synchronously before its first await. WSL-routed repos are excluded
for the 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 needing repair throws rather
than resolving routing, which is likewise no basis for deleting rows.

The prune's own gates still apply, so an SSH- or otherwise off-host repo is
never swept from a local stat -- the execution host owns that verdict.

Refs #17776
This commit is contained in:
Neil
2026-09-01 17:20:17 -07:00
parent 1a11f82fcc
commit ff8b4d08ab
3 changed files with 164 additions and 0 deletions
@@ -0,0 +1,117 @@
// Why this file exists: the authoritative missing-metadata prune had exactly one caller,
// `ipcMain.handle('worktrees:listAll')`. A headless runtime host has no renderer, so it never swept
// its own repos and their `worktreeMeta` rows grew without bound (#17776).
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import type { Repo } from '../../shared/repo-types'
import { testState, createStore, makeRepo } from '../persistence-test-harness'
import type { Store } from '../persistence/loading-store/store'
import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries'
import type { RuntimeStore } from './runtime-store-contract'
vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(),
sshConfigHostsToTargets: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) }))
const gitWorktree = (path: string): GitWorktreeInfo => ({
path,
branch: 'main',
head: 'abc1234',
isBare: false,
isMainWorktree: true
})
function queries(
store: Store,
repo: Repo,
worktrees: readonly GitWorktreeInfo[],
ok = true
): RuntimeManagedWorktreeQueries {
return new RuntimeManagedWorktreeQueries({
getStore: () => store as unknown as RuntimeStore,
listResolved: async () => [],
resolveRepo: async () => repo,
selectRepos: () => [repo],
scanRepo: async () => ({ ok, worktrees: [...worktrees] })
})
}
describe('runtime detected-worktree listing sweeps missing local metadata', () => {
let repoPath = ''
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-runtime-sweep-'))
repoPath = join(testState.dir, 'repo')
mkdirSync(repoPath, { recursive: true })
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('drops a metadata row whose directory is gone and the scan does not list', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath })
store.addRepo(repo)
const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}`
store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' })
expect(store.getWorktreeMeta(missingId)).toBeDefined()
await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo)
expect(store.getWorktreeMeta(missingId)).toBeUndefined()
})
it('keeps a row whose directory still exists', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath })
store.addRepo(repo)
const livePath = join(testState.dir, 'live-worktree')
mkdirSync(livePath, { recursive: true })
const liveId = `${repo.id}::${livePath}`
store.setWorktreeMetaForHost(liveId, 'local', { displayName: 'Live' })
await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo)
expect(store.getWorktreeMeta(liveId)).toBeDefined()
})
// A non-authoritative scan is a failed listing, which is no evidence any checkout is gone.
it('keeps every row when the scan is not authoritative', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath })
store.addRepo(repo)
const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}`
store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' })
await queries(store, repo, [], false).listDetected(repo)
expect(store.getWorktreeMeta(missingId)).toBeDefined()
})
// The execution host owns this verdict: a runtime host cannot stat an SSH checkout, so a local
// miss is not evidence of absence. See docs/reference/ssh-execution-boundary.md.
it('never sweeps a repo whose git runs off-host', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath, connectionId: 'build-box' })
store.addRepo(repo)
const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}`
store.setWorktreeMetaForHost(missingId, 'ssh:build-box', { displayName: 'Gone' })
await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo)
expect(store.getWorktreeMetaForHost(missingId, 'ssh:build-box')).toBeDefined()
})
})
@@ -20,6 +20,10 @@ import {
} 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'
@@ -36,6 +40,31 @@ type Dependencies = {
scanRepo(repo: Repo): Promise<RuntimeWorktreeScanResult>
}
/**
* 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) {}
@@ -129,6 +158,10 @@ export class RuntimeManagedWorktreeQueries {
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)
@@ -136,6 +169,17 @@ export class RuntimeManagedWorktreeQueries {
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(
@@ -30,6 +30,9 @@ export type RuntimeStore = {
removeProjectForHost?: Store['removeProjectForHost']
reorderRepos?: Store['reorderRepos']
getAllWorktreeMeta: Store['getAllWorktreeMeta']
captureNativeLocalWorktreeMetadataScanExpectation?: Store['captureNativeLocalWorktreeMetadataScanExpectation']
pruneSessionlessMissingLocalWorktreeMetadataForRepo?: Store['pruneSessionlessMissingLocalWorktreeMetadataForRepo']
getProfileStorageDirectory?: Store['getProfileStorageDirectory']
getWorktreeMeta: Store['getWorktreeMeta']
setWorktreeMeta: Store['setWorktreeMeta']
setWorktreeMetaForHost?: Store['setWorktreeMetaForHost']