From 13854fd3dc3d519502c16bed2dc5a5fe1dc65db9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:30:45 -0700 Subject: [PATCH] fix(worktrees): skip missing roots in headless scans --- src/main/local-worktree-filesystem.ts | 16 +++++ src/main/repo-worktrees.test.ts | 71 ++++++++++--------- src/main/repo-worktrees.ts | 13 +--- .../repo-worktree-resolution-scan.test.ts | 15 ++++ .../runtime/repo-worktree-resolution-scan.ts | 8 +++ 5 files changed, 78 insertions(+), 45 deletions(-) diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index 346f9e057fd..af55c241f10 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -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 { + 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 = {} diff --git a/src/main/repo-worktrees.test.ts b/src/main/repo-worktrees.test.ts index 9ed407956bc..97580b5cccb 100644 --- a/src/main/repo-worktrees.test.ts +++ b/src/main/repo-worktrees.test.ts @@ -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, diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index a6753eb1c29..f70436bb239 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -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 { 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) }) diff --git a/src/main/runtime/repo-worktree-resolution-scan.test.ts b/src/main/runtime/repo-worktree-resolution-scan.test.ts index 9d85d07fd58..e1a76d65169 100644 --- a/src/main/runtime/repo-worktree-resolution-scan.test.ts +++ b/src/main/runtime/repo-worktree-resolution-scan.test.ts @@ -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 () => { diff --git a/src/main/runtime/repo-worktree-resolution-scan.ts b/src/main/runtime/repo-worktree-resolution-scan.ts index 052118cf2bf..2210b25a545 100644 --- a/src/main/runtime/repo-worktree-resolution-scan.ts +++ b/src/main/runtime/repo-worktree-resolution-scan.ts @@ -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 { + 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)