diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 457111cb652..87bd0ded95c 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -3,13 +3,10 @@ import { ipcMain, type BrowserWindow } from 'electron' import { readFile, stat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import type { Store } from '../persistence' +import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' import { isFolderRepo } from '../../shared/repo-kind' import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output' -import { - isWorkspaceKey, - parseWorkspaceKey, - worktreeWorkspaceKey -} from '../../shared/workspace-scope' +import { parseWorkspaceKey } from '../../shared/workspace-scope' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { TaskSourceContextSchema } from '../../shared/task-source-context-schema' @@ -726,58 +723,6 @@ function rememberLocalWorktreeRoots( ]) } -function pruneLineageForMissingRepoWorktrees( - store: Store, - repo: Repo, - gitWorktrees: GitWorktreeInfo[] -): void { - if ( - typeof store.getAllWorktreeLineage !== 'function' || - typeof store.removeWorktreeLineage !== 'function' - ) { - return - } - const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) - const repoPrefix = `${repo.id}::` - const expectedHostId = getRepoExecutionHostId(repo) - const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) - const canMutateWorktree = (worktreeId: string): boolean => { - const hostId = store.getWorktreeMeta(worktreeId)?.hostId - return hostId ? hostId === expectedHostId : repoOwners.length === 1 - } - for (const childWorkspaceKey of Object.keys(store.getAllWorkspaceLineage?.() ?? {})) { - const childScope = parseWorkspaceKey(childWorkspaceKey) - if ( - childScope?.type === 'worktree' && - childScope.worktreeId.startsWith(repoPrefix) && - canMutateWorktree(childScope.worktreeId) && - !liveIds.has(childScope.worktreeId) - ) { - if (isWorkspaceKey(childWorkspaceKey)) { - store.removeWorkspaceLineage?.(childWorkspaceKey) - } - } - } - for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { - if (childId.startsWith(repoPrefix) && canMutateWorktree(childId) && !liveIds.has(childId)) { - // Why: path-derived IDs can be reused; once a scan proves the child is gone, drop its lineage so a future same-path worktree can't inherit it. - store.removeWorktreeLineage(childId) - store.removeWorkspaceLineage?.(worktreeWorkspaceKey(childId)) - } - if ( - lineage.parentWorktreeId.startsWith(repoPrefix) && - canMutateWorktree(lineage.parentWorktreeId) && - !liveIds.has(lineage.parentWorktreeId) - ) { - const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) - if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { - // Why: keep child lineage for the "Missing parent" UI, but rotate the absent parent's identity once so a path reuse can't inherit it. - store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) - } - } - } -} - type SshWorktreeMetaCandidate = { id: string path: string diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b3e3155c8e3..6913e0d1f64 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -202,6 +202,7 @@ const restoreLocalWatcherAfterFailedRemovalMock = vi.hoisted(() => vi.fn()) const restoreRemoteWatcherAfterFailedRemovalMock = vi.hoisted(() => vi.fn()) const forgetLocalWatcherRemovalSnapshotMock = vi.hoisted(() => vi.fn()) const forgetRemoteWatcherRemovalSnapshotMock = vi.hoisted(() => vi.fn()) +const scanLocalRepoWorktreesForResolutionMock = vi.hoisted(() => vi.fn()) vi.mock('electron', () => electronMocks) vi.mock('../ipc/filesystem-watcher', () => ({ @@ -414,6 +415,10 @@ vi.mock('../git/worktree', () => ({ forceDeleteLocalBranch: forceDeleteLocalBranchMock })) +vi.mock('./repo-worktree-resolution-scan', () => ({ + scanLocalRepoWorktreesForResolution: scanLocalRepoWorktreesForResolutionMock +})) + vi.mock('../terminal-history-deletion', () => ({ deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock })) @@ -653,6 +658,18 @@ function resetRuntimeTestMocks(): void { forgetRemoteWatcherRemovalSnapshotMock.mockReset() vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES) vi.mocked(listWorktreesStrict).mockResolvedValue(MOCK_GIT_WORKTREES) + scanLocalRepoWorktreesForResolutionMock + .mockReset() + .mockImplementation(async (repoPath: string, options: { wslDistro?: string }) => { + try { + const worktrees = options.wslDistro + ? await listWorktrees(repoPath, options) + : await listWorktrees(repoPath) + return { ok: true, worktrees } + } catch { + return { ok: false, worktrees: [] } + } + }) vi.mocked(addWorktree).mockReset() vi.mocked(assertWorktreeCleanForRemoval).mockReset() vi.mocked(assertWorktreeCleanForRemoval).mockResolvedValue(undefined) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 38ece989d90..2361f7b18de 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -436,7 +436,6 @@ import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths' import { folderWorkspaceKey, - isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' @@ -672,6 +671,10 @@ import { import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { resolveTerminalOrchestrationCliCommand } from './orchestration/cli-command' +import { + scanLocalRepoWorktreesForResolution, + type RuntimeWorktreeScanResult +} from './repo-worktree-resolution-scan' import { getLocalWorktreePathAccess, removeLocalWorktreePath, @@ -864,7 +867,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultVoiceSettings } from '../../shared/constants' -import { listRepoWorktrees } from '../repo-worktrees' +import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' import { createWorktreeCopiedPaths, createWorktreeLinkedPaths, @@ -2601,10 +2604,6 @@ type WorktreeLineageResolution = warnings: WorktreeLineageWarning[] } -type RuntimeWorktreeScanResult = - | { ok: true; worktrees: GitWorktreeInfo[] } - | { ok: false; worktrees: GitWorktreeInfo[] } - type RuntimeWorktreeScanCache = { generation: number runtimeKey: string @@ -20826,7 +20825,7 @@ export class OrcaRuntimeService { scan = { ok: false, worktrees: [] } } if (scan.ok) { - this.pruneLineageForMissingRepoWorktrees(repo, scan.worktrees) + pruneLineageForMissingRepoWorktrees(store, repo, scan.worktrees) } const agentScratchWorktreePathMatcher = createAgentScratchWorktreePathMatcher([ repo.path, @@ -28793,7 +28792,7 @@ export class OrcaRuntimeService { )) ?? { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } const gitWorktrees = scan.worktrees if (scan.ok) { - this.pruneLineageForMissingRepoWorktrees(repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(this.requireStore(), repo, gitWorktrees) } return gitWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` @@ -28840,48 +28839,6 @@ export class OrcaRuntimeService { return { worktrees, platformByRepoId } } - private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void { - const store = this.store - if ( - !store || - typeof store.getAllWorktreeLineage !== 'function' || - typeof store.removeWorktreeLineage !== 'function' - ) { - return - } - const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) - const repoPrefix = `${repo.id}::` - for (const childWorkspaceKey of Object.keys(store.getAllWorkspaceLineage?.() ?? {})) { - const childScope = parseWorkspaceKey(childWorkspaceKey) - if ( - childScope?.type === 'worktree' && - childScope.worktreeId.startsWith(repoPrefix) && - !liveIds.has(childScope.worktreeId) - ) { - if (isWorkspaceKey(childWorkspaceKey)) { - store.removeWorkspaceLineage?.(childWorkspaceKey) - } - } - } - for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { - if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { - // Why: once a scan proves the child gone, purge stale lineage so it can't survive into a same-path replacement checkout. - store.removeWorktreeLineage(childId) - store.removeWorkspaceLineage?.(worktreeWorkspaceKey(childId)) - } - if ( - lineage.parentWorktreeId.startsWith(repoPrefix) && - !liveIds.has(lineage.parentWorktreeId) - ) { - const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) - if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { - // Why: a missing parent path needs one fresh identity so same-path replacement checkouts can't validate old lineage. - store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) - } - } - } - } - private async listRepoWorktreesForResolution( repo: Repo, projectRuntimeByRepoId?: ReadonlyMap @@ -28941,13 +28898,10 @@ export class OrcaRuntimeService { projectRuntime: ProjectExecutionRuntimeResolution | undefined ): Promise { if (!repo.connectionId) { - return { - ok: true, - worktrees: await listRepoWorktrees( - repo, - getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime) - ) - } + return await scanLocalRepoWorktreesForResolution( + repo.path, + getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime) + ) } const provider = getSshGitProvider(repo.connectionId) if (!provider) { diff --git a/src/main/runtime/repo-worktree-resolution-scan.test.ts b/src/main/runtime/repo-worktree-resolution-scan.test.ts new file mode 100644 index 00000000000..9d85d07fd58 --- /dev/null +++ b/src/main/runtime/repo-worktree-resolution-scan.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listWorktreesStrict } from '../git/worktree' +import { scanLocalRepoWorktreesForResolution } from './repo-worktree-resolution-scan' + +vi.mock('../git/worktree', () => ({ listWorktreesStrict: vi.fn() })) + +describe('scanLocalRepoWorktreesForResolution', () => { + beforeEach(() => { + vi.mocked(listWorktreesStrict).mockReset() + }) + + it('degrades a local Git execution failure instead of reporting an empty success', async () => { + vi.mocked(listWorktreesStrict).mockRejectedValue( + Object.assign(new Error('spawn git EAGAIN'), { code: 'EAGAIN' }) + ) + + await expect(scanLocalRepoWorktreesForResolution('/repo', {})).resolves.toEqual({ + ok: false, + worktrees: [] + }) + }) + + it('preserves a successful empty scan verdict', async () => { + vi.mocked(listWorktreesStrict).mockResolvedValue([]) + + await expect( + scanLocalRepoWorktreesForResolution('/repo', { wslDistro: 'Ubuntu' }) + ).resolves.toEqual({ ok: true, worktrees: [] }) + expect(listWorktreesStrict).toHaveBeenCalledWith('/repo', { wslDistro: 'Ubuntu' }) + }) +}) diff --git a/src/main/runtime/repo-worktree-resolution-scan.ts b/src/main/runtime/repo-worktree-resolution-scan.ts new file mode 100644 index 00000000000..7c84e87ad84 --- /dev/null +++ b/src/main/runtime/repo-worktree-resolution-scan.ts @@ -0,0 +1,21 @@ +import type { GitWorktreeInfo } from '../../shared/types' +import { listWorktreesStrict } from '../git/worktree' +import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' + +export type RuntimeWorktreeScanResult = + | { ok: true; worktrees: GitWorktreeInfo[] } + | { ok: false; worktrees: GitWorktreeInfo[] } + +export async function scanLocalRepoWorktreesForResolution( + repoPath: string, + options: LocalProjectWorktreeGitOptions +): Promise { + try { + const worktrees = options.wslDistro + ? await listWorktreesStrict(repoPath, options) + : await listWorktreesStrict(repoPath) + return { ok: true, worktrees } + } catch { + return { ok: false, worktrees: [] } + } +} diff --git a/src/main/worktree-lineage-pruning.test.ts b/src/main/worktree-lineage-pruning.test.ts new file mode 100644 index 00000000000..e9465a9f6cc --- /dev/null +++ b/src/main/worktree-lineage-pruning.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Repo, WorktreeLineage, WorkspaceLineage, WorktreeMeta } from '../shared/types' +import { worktreeWorkspaceKey } from '../shared/workspace-scope' +import { pruneLineageForMissingRepoWorktrees } from './worktree-lineage-pruning' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 +} + +function lineage(childId: string, parentId: string): WorktreeLineage { + return { + worktreeId: childId, + worktreeInstanceId: `${childId}-instance`, + parentWorktreeId: parentId, + parentWorktreeInstanceId: `${parentId}-instance`, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } +} + +function workspaceLineage(childId: string, parentId: string): WorkspaceLineage { + return { + childWorkspaceKey: worktreeWorkspaceKey(childId), + childInstanceId: `${childId}-instance`, + parentWorkspaceKey: worktreeWorkspaceKey(parentId), + parentInstanceId: `${parentId}-instance`, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } +} + +function createStore( + worktreeLineageById: Record, + workspaceLineageByChildKey: Record, + metaById: Record +) { + return { + getRepos: () => [repo], + getAllWorktreeLineage: () => worktreeLineageById, + getAllWorkspaceLineage: () => workspaceLineageByChildKey, + getWorktreeMeta: (id: string) => metaById[id], + removeWorktreeLineage: vi.fn((id: string) => delete worktreeLineageById[id]), + removeWorkspaceLineage: vi.fn((key: string) => delete workspaceLineageByChildKey[key]), + setWorktreeMeta: vi.fn((id: string, updates: Partial) => { + metaById[id] = { ...metaById[id], ...updates } + return metaById[id] + }) + } +} + +describe('pruneLineageForMissingRepoWorktrees', () => { + it('refuses an empty scan when the repo still has registered lineage', () => { + const parentId = 'repo-1::/repo/parent' + const childId = 'repo-1::/repo/child' + const edge = lineage(childId, parentId) + const workspaceEdge = workspaceLineage(childId, parentId) + const worktreeLineageById = { [childId]: edge } + const workspaceLineageByChildKey = { [worktreeWorkspaceKey(childId)]: workspaceEdge } + const metaById = { + [parentId]: { instanceId: edge.parentWorktreeInstanceId } as WorktreeMeta + } + const store = createStore(worktreeLineageById, workspaceLineageByChildKey, metaById) + + pruneLineageForMissingRepoWorktrees(store as never, repo, []) + + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(store.removeWorkspaceLineage).not.toHaveBeenCalled() + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(worktreeLineageById[childId]).toBe(edge) + expect(workspaceLineageByChildKey[worktreeWorkspaceKey(childId)]).toBe(workspaceEdge) + }) + + it('prunes missing children and rotates missing parents after a trusted non-empty scan', () => { + const liveParentId = 'repo-1::/repo/live-parent' + const missingChildId = 'repo-1::/repo/missing-child' + const liveChildId = 'repo-1::/repo/live-child' + const missingParentId = 'repo-1::/repo/missing-parent' + const missingChildEdge = lineage(missingChildId, liveParentId) + const missingParentEdge = lineage(liveChildId, missingParentId) + const worktreeLineageById = { + [missingChildId]: missingChildEdge, + [liveChildId]: missingParentEdge + } + const workspaceLineageByChildKey = { + [worktreeWorkspaceKey(missingChildId)]: workspaceLineage(missingChildId, liveParentId), + [worktreeWorkspaceKey(liveChildId)]: workspaceLineage(liveChildId, missingParentId) + } + const metaById = { + [liveParentId]: { instanceId: missingChildEdge.parentWorktreeInstanceId } as WorktreeMeta, + [missingParentId]: { instanceId: missingParentEdge.parentWorktreeInstanceId } as WorktreeMeta + } + const store = createStore(worktreeLineageById, workspaceLineageByChildKey, metaById) + + pruneLineageForMissingRepoWorktrees(store as never, repo, [ + { + path: '/repo/live-parent', + head: 'a', + branch: 'main', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo/live-child', + head: 'b', + branch: 'child', + isBare: false, + isMainWorktree: false + } + ]) + + expect(store.removeWorktreeLineage).toHaveBeenCalledWith(missingChildId) + expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith(liveChildId) + expect(store.setWorktreeMeta).toHaveBeenCalledWith(missingParentId, { + instanceId: expect.any(String) + }) + expect(store.setWorktreeMeta).not.toHaveBeenCalledWith(liveParentId, expect.anything()) + expect(metaById[missingParentId].instanceId).not.toBe( + missingParentEdge.parentWorktreeInstanceId + ) + }) +}) diff --git a/src/main/worktree-lineage-pruning.ts b/src/main/worktree-lineage-pruning.ts new file mode 100644 index 00000000000..b9dbe956f57 --- /dev/null +++ b/src/main/worktree-lineage-pruning.ts @@ -0,0 +1,97 @@ +import { randomUUID } from 'node:crypto' +import type { GitWorktreeInfo, Repo, WorktreeLineage, WorkspaceLineage } from '../shared/types' +import { getRepoExecutionHostId } from '../shared/execution-host' +import { isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import type { Store } from './persistence' + +function worktreeIdBelongsToRepo(worktreeId: string, repoPrefix: string): boolean { + return worktreeId.startsWith(repoPrefix) +} + +function workspaceKeyBelongsToRepo(workspaceKey: string, repoPrefix: string): boolean { + const scope = parseWorkspaceKey(workspaceKey) + return scope?.type === 'worktree' && worktreeIdBelongsToRepo(scope.worktreeId, repoPrefix) +} + +function hasStoredRepoLineage( + repoPrefix: string, + worktreeLineage: Readonly>, + workspaceLineage: Readonly> +): boolean { + return ( + Object.entries(worktreeLineage).some( + ([childId, lineage]) => + worktreeIdBelongsToRepo(childId, repoPrefix) || + worktreeIdBelongsToRepo(lineage.parentWorktreeId, repoPrefix) + ) || + Object.values(workspaceLineage).some( + (lineage) => + workspaceKeyBelongsToRepo(lineage.childWorkspaceKey, repoPrefix) || + workspaceKeyBelongsToRepo(lineage.parentWorkspaceKey, repoPrefix) + ) + ) +} + +export function pruneLineageForMissingRepoWorktrees( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[] +): void { + if ( + typeof store.getAllWorktreeLineage !== 'function' || + typeof store.removeWorktreeLineage !== 'function' + ) { + return + } + const worktreeLineage = store.getAllWorktreeLineage() + const workspaceLineage = store.getAllWorkspaceLineage?.() ?? {} + const repoPrefix = `${repo.id}::` + // Why: one empty observation cannot prove every registered lineage edge disappeared at once. + if ( + gitWorktrees.length === 0 && + hasStoredRepoLineage(repoPrefix, worktreeLineage, workspaceLineage) + ) { + return + } + const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) + const canMutateWorktree = (worktreeId: string): boolean => { + const hostId = store.getWorktreeMeta(worktreeId)?.hostId + return hostId ? hostId === expectedHostId : repoOwners.length === 1 + } + for (const childWorkspaceKey of Object.keys(workspaceLineage)) { + const childScope = parseWorkspaceKey(childWorkspaceKey) + if ( + childScope?.type === 'worktree' && + worktreeIdBelongsToRepo(childScope.worktreeId, repoPrefix) && + canMutateWorktree(childScope.worktreeId) && + !liveIds.has(childScope.worktreeId) && + isWorkspaceKey(childWorkspaceKey) + ) { + store.removeWorkspaceLineage?.(childWorkspaceKey) + } + } + for (const [childId, lineage] of Object.entries(worktreeLineage)) { + if ( + worktreeIdBelongsToRepo(childId, repoPrefix) && + canMutateWorktree(childId) && + !liveIds.has(childId) + ) { + // Why: a proven-missing path must not transfer its lineage to a future checkout at that path. + store.removeWorktreeLineage(childId) + store.removeWorkspaceLineage?.(worktreeWorkspaceKey(childId)) + } + if ( + worktreeIdBelongsToRepo(lineage.parentWorktreeId, repoPrefix) && + canMutateWorktree(lineage.parentWorktreeId) && + !liveIds.has(lineage.parentWorktreeId) + ) { + const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) + if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { + // Why: rotate a proven-missing parent's identity once so path reuse cannot validate old lineage. + store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) + } + } + } +}