mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(worktrees): guard lineage pruning from failed scans
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: [] }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user