mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
fix(worktrees): check missing roots at Git listing boundary
This commit is contained in:
@@ -3,9 +3,14 @@ import type * as FsPromises from 'node:fs/promises'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import type { gitExecFileAsync } from './runner'
|
||||
|
||||
const { statProbe, gitExec } = vi.hoisted(() => ({
|
||||
const { statProbe, gitExec, missingPathProbe } = vi.hoisted(() => ({
|
||||
statProbe: vi.fn<(worktreePath: string) => Promise<void>>(),
|
||||
gitExec: vi.fn<typeof gitExecFileAsync>()
|
||||
gitExec: vi.fn<typeof gitExecFileAsync>(),
|
||||
missingPathProbe: vi.fn<() => Promise<boolean>>()
|
||||
}))
|
||||
|
||||
vi.mock('../local-worktree-filesystem', () => ({
|
||||
isLocalWorktreePathMissing: missingPathProbe
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
@@ -22,6 +27,7 @@ vi.mock('../../shared/git-worktree-admin', () => ({
|
||||
|
||||
import { clearGitCapabilityStateForTests } from './git-capability-state'
|
||||
import { readWorktreeList } from './worktree-list-reader'
|
||||
import { listWorktreesStrictAllowingTrueEmpty } from './worktree-listing'
|
||||
|
||||
function porcelainRow(worktreePath: string, markers: string[] = []): string {
|
||||
return [
|
||||
@@ -43,6 +49,7 @@ beforeEach(() => {
|
||||
clearGitCapabilityStateForTests()
|
||||
statProbe.mockReset()
|
||||
gitExec.mockReset()
|
||||
missingPathProbe.mockReset().mockResolvedValue(false)
|
||||
porcelain =
|
||||
porcelainRow('/repo') +
|
||||
Array.from({ length: 32 }, (_, index) => porcelainRow(`/repo/task-${index}`)).join('')
|
||||
@@ -54,6 +61,14 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
it('skips Git for a missing repo path and scans after the path returns', async () => {
|
||||
missingPathProbe.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
|
||||
await expect(listWorktreesStrictAllowingTrueEmpty('/repo')).resolves.toEqual([])
|
||||
expect(gitExec).not.toHaveBeenCalled()
|
||||
await readWorktreeList('/repo')
|
||||
expect(gitExec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('old-Git native worktree listing cancellation', () => {
|
||||
it('rejects before pending existence probes finish and prevents further probes', async () => {
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -22,9 +22,18 @@ import {
|
||||
translateWorktreePath
|
||||
} from './worktree-path-comparison'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { isLocalWorktreePathMissing } from '../local-worktree-filesystem'
|
||||
|
||||
const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8
|
||||
|
||||
export class MissingWorktreePathError extends Error {
|
||||
readonly code = 'ENOENT'
|
||||
|
||||
constructor(repoPath: string) {
|
||||
super(`Repository path missing: ${repoPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
type RepoLocation = { topLevel: string; commonDir: string; gitDir: string }
|
||||
|
||||
function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined {
|
||||
@@ -209,6 +218,13 @@ export async function readWorktreeList(
|
||||
repoPath: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
throwIfSignalAborted(options.signal)
|
||||
// A host check may be unverifiable; only positive absence skips Git.
|
||||
const missingPath = await isLocalWorktreePathMissing(repoPath, options).catch(() => false)
|
||||
throwIfSignalAborted(options.signal)
|
||||
if (missingPath) {
|
||||
throw new MissingWorktreePathError(repoPath)
|
||||
}
|
||||
const execOptions = {
|
||||
cwd: repoPath,
|
||||
...options,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { toWslExecutionSpace } from '../../shared/wsl-paths'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import {
|
||||
readCheckedOutBranchRef,
|
||||
MissingWorktreePathError,
|
||||
readRepoCommonDirFromGit,
|
||||
readRepoLocation,
|
||||
readTranslatedWorktreeGraph,
|
||||
@@ -70,6 +71,9 @@ export async function listWorktreesUnshared(
|
||||
* path is gone, or it is not a Git repo. Every other failure means the scan could not read Git.
|
||||
*/
|
||||
async function isTrueEmptyWorktreeListing(repoPath: string, err: unknown): Promise<boolean> {
|
||||
if (err instanceof MissingWorktreePathError) {
|
||||
return true
|
||||
}
|
||||
if (getErrorCode(err) === 'ENOENT') {
|
||||
try {
|
||||
await stat(repoPath)
|
||||
|
||||
@@ -2,7 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types'
|
||||
|
||||
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
|
||||
const { gitExecFileAsyncMock, missingPathProbe } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
missingPathProbe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../local-worktree-filesystem', () => ({
|
||||
isLocalWorktreePathMissing: missingPathProbe
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: vi.fn(), removeHandler: vi.fn() },
|
||||
@@ -66,6 +73,7 @@ async function listDetected(): Promise<DetectedWorktreeListResult> {
|
||||
describe('detected worktree listing authority', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
missingPathProbe.mockReset().mockResolvedValue(false)
|
||||
removeWorktreeLineage.mockReset()
|
||||
__resetDetectedWorktreeScanCacheForTests()
|
||||
_resetWorktreeScanCacheForTests()
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock, missingPathMock } =
|
||||
vi.hoisted(() => ({
|
||||
listWorktreeGraphMock: vi.fn(),
|
||||
listWorktreesMock: vi.fn(),
|
||||
listWorktreesStrictMock: vi.fn(),
|
||||
missingPathMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./local-worktree-filesystem', () => ({ isLocalWorktreePathMissing: missingPathMock }))
|
||||
const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({
|
||||
listWorktreeGraphMock: vi.fn(),
|
||||
listWorktreesMock: vi.fn(),
|
||||
listWorktreesStrictMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./git/worktree', () => ({
|
||||
listWorktreeGraph: listWorktreeGraphMock,
|
||||
@@ -32,7 +28,6 @@ describe('repo-worktrees', () => {
|
||||
listWorktreeGraphMock.mockReset()
|
||||
listWorktreesMock.mockReset()
|
||||
listWorktreesStrictMock.mockReset()
|
||||
missingPathMock.mockReset().mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('creates a stable synthetic worktree for folder repos', () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ 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 { isLocalWorktreePathMissing } from './local-worktree-filesystem'
|
||||
|
||||
type LocalRepoWorktreeListOptions = {
|
||||
wslDistro?: string
|
||||
@@ -62,12 +61,7 @@ export async function listRepoWorktreesForDetectedScan(
|
||||
repo: Repo,
|
||||
options?: LocalRepoWorktreeListOptions
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
return listRoutedRepoWorktrees(repo, options, async (repoPath, localOptions) => {
|
||||
if (await isLocalWorktreePathMissing(repoPath, localOptions)) {
|
||||
return []
|
||||
}
|
||||
return listWorktreesSharedStrictAllowingTrueEmpty(repoPath, localOptions)
|
||||
})
|
||||
return listRoutedRepoWorktrees(repo, options, listWorktreesSharedStrictAllowingTrueEmpty)
|
||||
}
|
||||
|
||||
async function listRoutedRepoWorktrees(
|
||||
|
||||
@@ -2,26 +2,11 @@ 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,6 +1,5 @@
|
||||
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 =
|
||||
@@ -11,13 +10,6 @@ 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)
|
||||
|
||||
Reference in New Issue
Block a user