fix(worktrees): skip missing roots in headless scans

This commit is contained in:
Brennan Benson
2026-10-14 14:30:45 -07:00
parent 82ccf05a4d
commit 13854fd3dc
5 changed files with 78 additions and 45 deletions
+16
View File
@@ -1,5 +1,6 @@
import { runProcess } from '@orca/process-host'
import { lstat, readFile } from 'node:fs/promises'
import { isDefinitiveAbsence } from '../shared/definitive-filesystem-absence'
import { buildWslExecArgs, quotePosixShell } from '../shared/wsl-login-shell-command'
import { removeHostTree } from './host-tree-removal'
import { toLinuxPath } from './wsl'
@@ -119,6 +120,21 @@ export function getLocalWorktreePathAccess(
}
}
export async function isLocalWorktreePathMissing(
path: string,
options: LocalWorktreeFilesystemOptions = {}
): Promise<boolean> {
try {
await getLocalWorktreePathAccess(options).statPath(path)
return false
} catch (error) {
if (isDefinitiveAbsence(error)) {
return true
}
throw error
}
}
export async function removeLocalWorktreePath(
targetPath: string,
options: LocalWorktreeFilesystemOptions = {}
+36 -35
View File
@@ -1,17 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock, statMock } = vi.hoisted(
() => ({
const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock, missingPathMock } =
vi.hoisted(() => ({
listWorktreeGraphMock: vi.fn(),
listWorktreesMock: vi.fn(),
listWorktreesStrictMock: vi.fn(),
statMock: vi.fn()
})
)
missingPathMock: vi.fn()
}))
vi.mock('./local-worktree-filesystem', () => ({
getLocalWorktreePathAccess: () => ({ statPath: statMock })
}))
vi.mock('./local-worktree-filesystem', () => ({ isLocalWorktreePathMissing: missingPathMock }))
vi.mock('./git/worktree', () => ({
listWorktreeGraph: listWorktreeGraphMock,
@@ -30,32 +27,12 @@ import {
} from './repo-worktrees'
import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability'
const testGitRepo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git' as const
}
describe('repo-worktrees', () => {
beforeEach(() => {
listWorktreeGraphMock.mockReset()
listWorktreesMock.mockReset()
listWorktreesStrictMock.mockReset()
statMock.mockReset().mockResolvedValue({})
})
it('skips Git on each missing-folder scan and resumes when the folder returns', async () => {
statMock.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
statMock.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
expect(await listRepoWorktreesForDetectedScan(testGitRepo)).toEqual([])
expect(await listRepoWorktreesForDetectedScan(testGitRepo)).toEqual([])
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
listWorktreesStrictMock.mockResolvedValueOnce([])
await listRepoWorktreesForDetectedScan(testGitRepo)
expect(listWorktreesStrictMock).toHaveBeenCalledOnce()
missingPathMock.mockReset().mockResolvedValue(false)
})
it('creates a stable synthetic worktree for folder repos', () => {
@@ -128,7 +105,17 @@ describe('repo-worktrees', () => {
])
const signal = new AbortController().signal
const result = await listRepoWorktrees(testGitRepo, { signal })
const result = await listRepoWorktrees(
{
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git'
},
{ signal }
)
expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { signal })
expect(result).toHaveLength(1)
@@ -140,7 +127,14 @@ describe('repo-worktrees', () => {
{ path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true }
])
const result = await listRepoWorktreeGraph(testGitRepo)
const result = await listRepoWorktreeGraph({
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git'
})
expect(listWorktreeGraphMock).toHaveBeenCalledWith('/workspace/repo')
expect(listWorktreesMock).not.toHaveBeenCalled()
@@ -176,10 +170,17 @@ describe('repo-worktrees', () => {
])
const signal = new AbortController().signal
const result = await listLocalRepoWorktreesStrict(testGitRepo, {
signal,
wslDistro: 'Ubuntu'
})
const result = await listLocalRepoWorktreesStrict(
{
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git'
},
{ signal, wslDistro: 'Ubuntu' }
)
expect(listWorktreesStrictMock).toHaveBeenCalledWith('/workspace/repo', {
signal,
+3 -10
View File
@@ -12,8 +12,7 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execu
import { resolveGitRouteForHost } from './providers/execution-host-provider-dispatch'
import { areWorktreePathsEqual } from './ipc/worktree-logic'
import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability'
import { isDefinitiveAbsence } from '../shared/definitive-filesystem-absence'
import { getLocalWorktreePathAccess } from './local-worktree-filesystem'
import { isLocalWorktreePathMissing } from './local-worktree-filesystem'
type LocalRepoWorktreeListOptions = {
wslDistro?: string
@@ -64,14 +63,8 @@ export async function listRepoWorktreesForDetectedScan(
options?: LocalRepoWorktreeListOptions
): Promise<GitWorktreeInfo[]> {
return listRoutedRepoWorktrees(repo, options, async (repoPath, localOptions) => {
try {
// The local route runs on the execution host, including the server for SSH repositories.
await getLocalWorktreePathAccess(localOptions).statPath(repoPath)
} catch (error) {
if (isDefinitiveAbsence(error)) {
return []
}
throw error
if (await isLocalWorktreePathMissing(repoPath, localOptions)) {
return []
}
return listWorktreesSharedStrictAllowingTrueEmpty(repoPath, localOptions)
})
@@ -2,11 +2,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listWorktreesStrict } from '../git/worktree'
import { scanLocalRepoWorktreesForResolution } from './repo-worktree-resolution-scan'
const { missingPathMock } = vi.hoisted(() => ({ missingPathMock: vi.fn() }))
vi.mock('../git/worktree', () => ({ listWorktreesStrict: vi.fn() }))
vi.mock('../local-worktree-filesystem', () => ({
isLocalWorktreePathMissing: missingPathMock
}))
describe('scanLocalRepoWorktreesForResolution', () => {
beforeEach(() => {
vi.mocked(listWorktreesStrict).mockReset()
missingPathMock.mockReset().mockResolvedValue(false)
})
it('skips Git for a missing repo folder', async () => {
missingPathMock.mockResolvedValue(true)
expect(await scanLocalRepoWorktreesForResolution('/repo', {})).toEqual({
ok: false,
worktrees: []
})
expect(listWorktreesStrict).not.toHaveBeenCalled()
})
it('degrades a local Git execution failure instead of reporting an empty success', async () => {
@@ -1,5 +1,6 @@
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { listWorktreesStrict } from '../git/worktree'
import { isLocalWorktreePathMissing } from '../local-worktree-filesystem'
import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
export type RuntimeWorktreeScanResult =
@@ -10,6 +11,13 @@ export async function scanLocalRepoWorktreesForResolution(
repoPath: string,
options: LocalProjectWorktreeGitOptions
): Promise<RuntimeWorktreeScanResult> {
try {
if (await isLocalWorktreePathMissing(repoPath, options)) {
return { ok: false, worktrees: [] }
}
} catch {
// A failed host check is not proof of absence; preserve the Git scan's verdict.
}
try {
const worktrees = options.wslDistro
? await listWorktreesStrict(repoPath, options)