fix(worktrees): guard lineage pruning from failed scans

This commit is contained in:
Brennan Benson
2026-08-07 23:08:29 -07:00
parent bba32bd00c
commit b1a3e45c0f
7 changed files with 306 additions and 114 deletions
+2 -57
View File
@@ -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
+17
View File
@@ -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)
+11 -57
View File
@@ -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<string, ProjectExecutionRuntimeResolution>
@@ -28941,13 +28898,10 @@ export class OrcaRuntimeService {
projectRuntime: ProjectExecutionRuntimeResolution | undefined
): Promise<RuntimeWorktreeScanResult> {
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) {
@@ -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' })
})
})
@@ -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<RuntimeWorktreeScanResult> {
try {
const worktrees = options.wslDistro
? await listWorktreesStrict(repoPath, options)
: await listWorktreesStrict(repoPath)
return { ok: true, worktrees }
} catch {
return { ok: false, worktrees: [] }
}
}
+127
View File
@@ -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<string, WorktreeLineage>,
workspaceLineageByChildKey: Record<string, WorkspaceLineage>,
metaById: Record<string, WorktreeMeta>
) {
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<WorktreeMeta>) => {
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
)
})
})
+97
View File
@@ -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<Record<string, WorktreeLineage>>,
workspaceLineage: Readonly<Record<string, WorkspaceLineage>>
): 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() })
}
}
}
}