diff --git a/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.test.ts b/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.test.ts index 0716d24874e..7418e2203ed 100644 --- a/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.test.ts +++ b/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import type { GitWorktreeInfo, Worktree } from '../../../../shared/worktree/types' @@ -10,9 +11,11 @@ import { buildDetectedGitWorktrees } from '../../../ipc/worktrees/listing/ssh-wo import { resolveRepoWorktreeRows, resolveScopedWorktreeIdRow, + scanRepoWorktreeRows, type RepoWorktreeRowDeps } from './repo-worktree-row-resolution' import type { HostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' +import { bumpLocalWorktreeScanGeneration } from '../../../workspaces/catalog/local-worktree-scan-generation' function repo( id: string, @@ -160,6 +163,39 @@ describe('scoped worktree id resolution across path spellings (#16243)', () => { }) }) +describe('scanRepoWorktreeRows lineage pruning', () => { + // #16654: lineage written by a create that overtook the scan must survive its stale rows. + it('keeps lineage recorded by a worktree change that overtook the scan', async () => { + const owner = repo('repo-1', '/repo') + const deps = createDeps([owner]) + const childId = 'repo-1::/repo-child' + const lineageById: Record = {} + const removeWorktreeLineage = vi.fn() + Object.assign(deps.store, { + getAllWorktreeLineage: () => lineageById, + getWorktreeMeta: () => undefined, + removeWorktreeLineage + }) + deps.scanRepo.mockImplementationOnce(async () => { + bumpLocalWorktreeScanGeneration(owner.id) + lineageById[childId] = { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo-1::/repo', + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + return { ok: true, worktrees: [gitWorktree('/repo')] } + }) + + await scanRepoWorktreeRows(deps, owner, new Map()) + + expect(removeWorktreeLineage).not.toHaveBeenCalled() + }) +}) + describe('folder-to-Git checkout identity', () => { it.each([ ['C:\\projects\\draft', 'C:/projects/draft'], diff --git a/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.ts b/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.ts index 980b21d816c..637892c7758 100644 --- a/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.ts +++ b/src/main/runtime/workspaces/catalog/repo-worktree-row-resolution.ts @@ -28,6 +28,7 @@ import { createRepoRowExecutionHostLookup } from '../../../../shared/execution-h import { areWorktreePathsEqual } from '../../../git/worktrees/catalog/worktree-path-comparison' import { mergeWorktree } from '../../../ipc/worktree-metadata-merge' import { pruneLineageForMissingRepoWorktrees } from '../../../workspaces/catalog/worktree-lineage-pruning' +import { getLocalWorktreeScanGeneration } from '../../../workspaces/catalog/local-worktree-scan-generation' import { getRepoOwnedWorktreeMeta } from '../../../workspaces/catalog/worktree-metadata-ownership' import { resolveLocalProjectRuntimesForRepos } from '../../../workspaces/execution-host/local-project-runtime-resolution' import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' @@ -117,6 +118,7 @@ export async function scanRepoWorktreeRows( repoOwnerCount = deps.store.getRepos().filter((candidate) => candidate.id === repo.id).length ): Promise { const { store } = deps + const scanGeneration = getLocalWorktreeScanGeneration(repo.id) // Absorb rejection before withTimeout: rejection preserves shipped empty results, but timeout restores last-known rows. const scan: RuntimeWorktreeScanResult = (await withTimeout( deps @@ -132,7 +134,7 @@ export async function scanRepoWorktreeRows( } const gitWorktrees = preserveFolderUpgradeWorktreePath(repo, scan.worktrees) if (scan.ok) { - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees, { scanGeneration }) } return { ok: scan.ok, worktrees: gitWorktrees } } diff --git a/src/main/runtime/workspaces/catalog/runtime-managed-worktree-queries.test.ts b/src/main/runtime/workspaces/catalog/runtime-managed-worktree-queries.test.ts index 601576ebea6..464deed5b46 100644 --- a/src/main/runtime/workspaces/catalog/runtime-managed-worktree-queries.test.ts +++ b/src/main/runtime/workspaces/catalog/runtime-managed-worktree-queries.test.ts @@ -38,6 +38,7 @@ import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-querie import type { RuntimeStore } from '../../construction/runtime-store-contract' import { bumpLocalWorktreeScanGeneration, + getLocalWorktreeScanGeneration, resetLocalWorktreeScanGenerationsForTests } from '../../../workspaces/catalog/local-worktree-scan-generation' @@ -287,6 +288,28 @@ describe('RuntimeManagedWorktreeQueries.listDetected overtaken scan', () => { expect(pruneLineageMock.mock.calls[0]?.[2]).toEqual(rows([repo.path, created])) }) + // #16654: a create landing during the metadata probe writes lineage the scan never saw. + it('gates lineage pruning on the scan-start generation when a change lands during metadata pruning', async () => { + const store = gitStore({ + pruneSessionlessMissingLocalWorktreeMetadataForRepo: vi.fn(() => []), + getProfileStorageDirectory: () => '/profile', + captureNativeLocalWorktreeMetadataScanExpectation: vi.fn(() => ({ repo, metadata: [] })) + }) + const atScanStart = getLocalWorktreeScanGeneration(repo.id) + pruneMetadataMock.mockImplementationOnce(async () => { + bumpLocalWorktreeScanGeneration(repo.id) + return { scanGenerationCurrent: false, preservedMetadataCandidateIds: new Set() } + }) + const scanRepo = vi.fn(async () => ({ ok: true as const, worktrees: rows([repo.path]) })) + + await queries(store, { scanRepo }).listDetected(repo) + + // The prune refuses a generation that has moved on; it must see the scan-start one. + expect(pruneLineageMock).toHaveBeenCalledWith(store, repo, rows([repo.path]), { + scanGeneration: atScanStart + }) + }) + it('publishes the last scan non-authoritatively once the re-scan bound is spent', async () => { const scanRepo = vi.fn(overtakenScan([repo.path])) diff --git a/src/main/runtime/workspaces/catalog/runtime-managed-worktree-queries.ts b/src/main/runtime/workspaces/catalog/runtime-managed-worktree-queries.ts index 5d0c79c73b9..ed08471a010 100644 --- a/src/main/runtime/workspaces/catalog/runtime-managed-worktree-queries.ts +++ b/src/main/runtime/workspaces/catalog/runtime-managed-worktree-queries.ts @@ -173,7 +173,9 @@ export class RuntimeManagedWorktreeQueries { scanGeneration: scan.scanGeneration }) } - pruneLineageForMissingRepoWorktrees(store, repo, scan.worktrees) + pruneLineageForMissingRepoWorktrees(store, repo, scan.worktrees, { + scanGeneration: scan.scanGeneration + }) } const matcher = createWorktreeVisibilitySourceMatcher( [repo.path, ...scan.worktrees.map((worktree) => worktree.path)], diff --git a/src/main/workspaces/catalog/worktree-lineage-pruning.ts b/src/main/workspaces/catalog/worktree-lineage-pruning.ts index 001c6e6b80b..9a701ff5def 100644 --- a/src/main/workspaces/catalog/worktree-lineage-pruning.ts +++ b/src/main/workspaces/catalog/worktree-lineage-pruning.ts @@ -9,6 +9,7 @@ import { worktreeRetentionPathComparisonKey } from './worktree-retention-path-co import type { Store } from '../../persistence/loading-store/store' import { isWslMoveRepoBlocked } from '../../persistence/moving-wsl-projects/orchestration/wsl-move-background-backoff' import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' +import { isLocalWorktreeScanGenerationCurrent } from './local-worktree-scan-generation' type WorktreeLineagePruningStore = Pick & Partial< @@ -58,12 +59,16 @@ export function pruneLineageForMissingRepoWorktrees( options: { platform?: NodeJS.Platform preservedMetadataCandidateIds?: ReadonlySet + /** Scan-start generation; a worktree change since then may have recorded lineage these rows miss (#16654). */ + scanGeneration?: number } = {} ): void { - const { platform = process.platform, preservedMetadataCandidateIds } = options + const { platform = process.platform, preservedMetadataCandidateIds, scanGeneration } = options // Mid-move a scan can miss paths that still exist; lineage waits for the move to settle. if ( isWslMoveRepoBlocked(repo) || + (scanGeneration !== undefined && + !isLocalWorktreeScanGenerationCurrent(repo.id, scanGeneration)) || typeof store.getAllWorktreeLineage !== 'function' || typeof store.removeWorktreeLineage !== 'function' ) {