mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user