diff --git a/src/main/ipc/workspace-cleanup-local-git-routing.test.ts b/src/main/ipc/workspace-cleanup-local-git-routing.test.ts new file mode 100644 index 00000000000..656b904d0cd --- /dev/null +++ b/src/main/ipc/workspace-cleanup-local-git-routing.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { Repo } from '../../shared/types' + +const { listRepoWorktreesMock, getLocalProjectWorktreeGitOptionsMock } = vi.hoisted(() => ({ + listRepoWorktreesMock: vi.fn(), + getLocalProjectWorktreeGitOptionsMock: vi.fn() +})) + +vi.mock('../repo-worktrees', () => ({ + createFolderWorktree: vi.fn(), + listRepoWorktrees: listRepoWorktreesMock +})) + +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock +})) + +import { scanWorkspaceCleanup } from './workspace-cleanup-scan' + +const REPO: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 0 +} + +describe('workspace cleanup local Git routing', () => { + beforeEach(() => { + listRepoWorktreesMock.mockReset().mockResolvedValue([]) + getLocalProjectWorktreeGitOptionsMock.mockReset() + }) + + it('uses the selected WSL distro while retaining the cleanup timeout signal', async () => { + const store = { + getRepos: () => [REPO] + } as Store + getLocalProjectWorktreeGitOptionsMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + + await scanWorkspaceCleanup(store) + + expect(getLocalProjectWorktreeGitOptionsMock).toHaveBeenCalledWith(store, REPO) + expect(listRepoWorktreesMock).toHaveBeenCalledWith(REPO, { + wslDistro: 'Ubuntu', + signal: expect.any(AbortSignal) + }) + }) +}) diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index a9b6ff704ca..f5deb16a2bd 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -32,6 +32,7 @@ import { toSafeWorkspaceCleanupRepoScanError, withWorkspaceCleanupTimeout } from './workspace-cleanup-scan-primitives' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' const WORKTREE_SCAN_CONCURRENCY = 3 @@ -111,7 +112,7 @@ async function scanRepoWorkspaces( let gitWorktrees: GitWorktreeInfo[] = [] try { - const discovered = await listCleanupGitWorktrees(repo, repoIsFolder) + const discovered = await listCleanupGitWorktrees(store, repo, repoIsFolder) provider = discovered.provider gitWorktrees = discovered.gitWorktrees } catch (error) { @@ -216,6 +217,7 @@ function shouldResolveBroadWorkspaceCleanupActivity( } async function listCleanupGitWorktrees( + store: Store, repo: Repo, repoIsFolder: boolean ): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> { @@ -237,10 +239,11 @@ async function listCleanupGitWorktrees( ) } } + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) return { provider: null, gitWorktrees: await withWorkspaceCleanupTimeout( - (signal) => listRepoWorktrees(repo, { signal }), + (signal) => listRepoWorktrees(repo, { ...localGitOptions, signal }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, 'Timed out listing worktrees.' ) diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 1d02e59db29..731785ad356 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -17,6 +17,7 @@ const { listRepoWorktreesMock, getStatusMock, gitExecFileAsyncMock, + getLocalProjectWorktreeGitOptionsMock, getSshGitProviderMock, getSshPtyProviderMock, listRegisteredPtysMock @@ -26,6 +27,7 @@ const { listRepoWorktreesMock: vi.fn(), getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), + getLocalProjectWorktreeGitOptionsMock: vi.fn(), getSshGitProviderMock: vi.fn(), getSshPtyProviderMock: vi.fn(), listRegisteredPtysMock: vi.fn() @@ -60,6 +62,10 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock +})) + vi.mock('../memory/pty-registry', () => ({ listRegisteredPtys: listRegisteredPtysMock })) @@ -154,6 +160,7 @@ describe('workspace cleanup scan', () => { listRepoWorktreesMock.mockReset() getStatusMock.mockReset() gitExecFileAsyncMock.mockReset() + getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) getSshGitProviderMock.mockReset() getSshPtyProviderMock.mockReset() listRegisteredPtysMock.mockReset() diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index 86af032aefb..b174f45a360 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -5,13 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../shared/types' import type { Store } from './persistence' -const { listRepoWorktreesMock, getSshFilesystemProviderMock, getSshGitProviderMock } = vi.hoisted( - () => ({ - listRepoWorktreesMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn(), - getSshGitProviderMock: vi.fn() - }) -) +const { + listRepoWorktreesMock, + getLocalProjectWorktreeGitOptionsMock, + getSshFilesystemProviderMock, + getSshGitProviderMock +} = vi.hoisted(() => ({ + listRepoWorktreesMock: vi.fn(), + getLocalProjectWorktreeGitOptionsMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn(), + getSshGitProviderMock: vi.fn() +})) vi.mock('./repo-worktrees', () => ({ createFolderWorktree: (repo: Repo) => ({ @@ -32,6 +36,10 @@ vi.mock('./providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) +vi.mock('./project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock +})) + import { analyzeWorkspaceSpace, WorkspaceSpaceScanCancelledError } from './workspace-space-analysis' function createStore(repos: Repo[]): Store { @@ -58,6 +66,7 @@ describe('analyzeWorkspaceSpace', () => { vi.setSystemTime(new Date('2026-05-14T12:00:00Z')) tempDir = await mkdtemp(join(tmpdir(), 'orca-space-')) listRepoWorktreesMock.mockReset() + getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) getSshFilesystemProviderMock.mockReset() getSshGitProviderMock.mockReset() }) @@ -231,6 +240,71 @@ describe('analyzeWorkspaceSpace', () => { expect(listRepoWorktreesMock).not.toHaveBeenCalled() }) + it('aborts every in-flight local worktree list when the scan is cancelled', async () => { + const repos: Repo[] = [ + { + id: 'repo-1', + path: join(tempDir!, 'repo-1'), + displayName: 'one', + badgeColor: '#000', + addedAt: 0 + }, + { + id: 'repo-2', + path: join(tempDir!, 'repo-2'), + displayName: 'two', + badgeColor: '#000', + addedAt: 0 + } + ] + const capturedSignals: AbortSignal[] = [] + let markBothStarted!: () => void + const bothStarted = new Promise((resolve) => { + markBothStarted = resolve + }) + listRepoWorktreesMock.mockImplementation((_repo: Repo, options?: { signal?: AbortSignal }) => { + const signal = options?.signal + if (!signal) { + throw new Error('expected cancellation signal') + } + capturedSignals.push(signal) + if (capturedSignals.length === repos.length) { + markBothStarted() + } + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + const controller = new AbortController() + + const scan = analyzeWorkspaceSpace(createStore(repos), { signal: controller.signal }) + await bothStarted + controller.abort() + + await expect(scan).rejects.toBeInstanceOf(WorkspaceSpaceScanCancelledError) + expect(capturedSignals).toHaveLength(2) + expect(capturedSignals.every((signal) => signal.aborted)).toBe(true) + }) + + it('routes local worktree listing through the selected WSL distro', async () => { + const repo: Repo = { + id: 'repo-1', + path: tempDir!, + displayName: 'orca', + badgeColor: '#000', + addedAt: 0 + } + getLocalProjectWorktreeGitOptionsMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + listRepoWorktreesMock.mockResolvedValue([]) + + await analyzeWorkspaceSpace(createStore([repo])) + + expect(listRepoWorktreesMock).toHaveBeenCalledWith(repo, { + wslDistro: 'Ubuntu', + signal: undefined + }) + }) + it('isolates missing worktrees as row-level scan failures', async () => { const root = tempDir! const repoPath = join(root, 'repo') diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index cc792202fb8..4a7d8a73304 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -24,6 +24,7 @@ import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' import { mergeWorktree } from './ipc/worktree-logic' +import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options' const WORKTREE_SCAN_CONCURRENCY = 3 const LOCAL_FS_CONCURRENCY = 48 @@ -763,6 +764,7 @@ async function scanRemoteWorktree( } async function listWorktreesForSpaceScan( + store: Store, repo: Repo, signal?: AbortSignal ): Promise { @@ -784,7 +786,10 @@ async function listWorktreesForSpaceScan( throwIfAborted(signal) return { ok: true, worktrees } } - const worktrees = await listRepoWorktrees(repo) + const worktrees = await listRepoWorktrees(repo, { + ...getLocalProjectWorktreeGitOptions(store, repo), + signal + }) throwIfAborted(signal) return { ok: true, worktrees } } catch (error) { @@ -826,7 +831,7 @@ async function scanRepo( }, options.onProgress ) - const listed = await listWorktreesForSpaceScan(repo, options.signal) + const listed = await listWorktreesForSpaceScan(store, repo, options.signal) if (!listed.ok) { reportProgress( progress,