diff --git a/src/main/git/commit-object-ref.test.ts b/src/main/git/commit-object-ref.test.ts index e6cace81735..6b71de60bb3 100644 --- a/src/main/git/commit-object-ref.test.ts +++ b/src/main/git/commit-object-ref.test.ts @@ -1,22 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) - -vi.mock('./runner', () => ({ - gitExecFileAsync: gitExecFileAsyncMock -})) - -import { - hasCommitObjectViaGitExec, - hasLocalCommitObject, - isFullGitObjectId -} from './commit-object-ref' +import { describe, expect, it, vi } from 'vitest' +import { hasCommitObjectViaGitExec, isFullGitObjectId } from './commit-object-ref' describe('commit object refs', () => { - beforeEach(() => { - gitExecFileAsyncMock.mockReset() - }) - it('recognizes only complete git object IDs', () => { expect(isFullGitObjectId('a'.repeat(40))).toBe(true) expect(isFullGitObjectId('A'.repeat(40))).toBe(true) @@ -49,15 +34,4 @@ describe('commit object refs', () => { expect(gitExec).not.toHaveBeenCalled() }) - - it('checks local commit objects in the target repo path', async () => { - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'a'.repeat(40), stderr: '' }) - - await expect(hasLocalCommitObject('/repo', 'a'.repeat(40))).resolves.toBe(true) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['rev-parse', '--verify', '--quiet', `${'a'.repeat(40)}^{commit}`], - { cwd: '/repo' } - ) - }) }) diff --git a/src/main/git/commit-object-ref.ts b/src/main/git/commit-object-ref.ts index e27abda6b1c..42058abfb00 100644 --- a/src/main/git/commit-object-ref.ts +++ b/src/main/git/commit-object-ref.ts @@ -1,5 +1,3 @@ -import { gitExecFileAsync } from './runner' - type GitExec = (args: string[]) => Promise const FULL_GIT_OBJECT_ID_PATTERN = /^[0-9a-f]{40}$/i @@ -20,7 +18,3 @@ export async function hasCommitObjectViaGitExec(gitExec: GitExec, ref: string): return false } } - -export function hasLocalCommitObject(repoPath: string, ref: string): Promise { - return hasCommitObjectViaGitExec((args) => gitExecFileAsync(args, { cwd: repoPath }), ref) -} diff --git a/src/main/git/worktree-base-ref-probe.test.ts b/src/main/git/worktree-base-ref-probe.test.ts index d27d26290c8..f2e38067ad5 100644 --- a/src/main/git/worktree-base-ref-probe.test.ts +++ b/src/main/git/worktree-base-ref-probe.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' -import { probeWorktreeBaseRefPresence } from './worktree-base-ref-probe' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const gitExecFileAsync = vi.hoisted(() => vi.fn()) + +vi.mock('./runner', () => ({ gitExecFileAsync })) + +import { hasLocalWorktreeBaseRef, probeWorktreeBaseRefPresence } from './worktree-base-ref-probe' describe('probeWorktreeBaseRefPresence', () => { it('uses an exact show-ref probe and reports a present ref', async () => { @@ -52,3 +57,47 @@ describe('probeWorktreeBaseRefPresence', () => { expect(runGit).not.toHaveBeenCalled() }) }) + +describe('hasLocalWorktreeBaseRef', () => { + const repoPath = String.raw`C:\workspace\repo` + + function resolveOnly(present: string[]): void { + gitExecFileAsync.mockImplementation(async (args: string[]) => ({ + stdout: present.includes(args.at(-1)?.replace('^{commit}', '') ?? '') ? 'f'.repeat(40) : '', + stderr: '' + })) + } + + beforeEach(() => { + gitExecFileAsync.mockReset() + }) + + it('prefers the remote namespace for a slashed short name', async () => { + resolveOnly(['refs/remotes/origin/main']) + + await expect(hasLocalWorktreeBaseRef(repoPath, 'origin/main')).resolves.toBe(true) + expect(gitExecFileAsync).toHaveBeenCalledWith( + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], + { cwd: repoPath } + ) + }) + + it('probes a bare commit id as an object, not as a ref', async () => { + const sha = 'a'.repeat(40) + resolveOnly([sha]) + + await expect(hasLocalWorktreeBaseRef(repoPath, sha, { wslDistro: 'Ubuntu' })).resolves.toBe( + true + ) + expect(gitExecFileAsync).toHaveBeenCalledWith( + ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], + { cwd: repoPath, wslDistro: 'Ubuntu' } + ) + }) + + it('reports a base no namespace resolves as absent', async () => { + resolveOnly([]) + + await expect(hasLocalWorktreeBaseRef(repoPath, 'feature/topic')).resolves.toBe(false) + }) +}) diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts index 6ed19860eb3..8e44877ab76 100644 --- a/src/main/git/worktree-base-ref-probe.ts +++ b/src/main/git/worktree-base-ref-probe.ts @@ -1,6 +1,8 @@ import { gitExecFileAsync } from './runner' import { isShowRefNoMatchError } from './exact-ref-probe' +import { hasCommitObjectViaGitExec } from './commit-object-ref' import { isSafeGitRefName } from '../../shared/git-status-upstream-ref' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' type GitExecOptions = { wslDistro?: string @@ -40,6 +42,34 @@ export async function hasWorktreeBaseCommitRef( return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null } +/** + * Whether a worktree base — a qualified ref, a short branch or remote name, or a + * full commit id — already resolves in this repo's own object/ref store. + * + * Single copy on purpose: the create path, the speculative create prefetch and + * the remote-repo create path must agree on what counts as a local base, or the + * warm-up prepares a checkout create then rejects. + */ +export async function hasLocalWorktreeBaseRef( + repoPath: string, + baseRef: string, + options: GitExecOptions = {} +): Promise { + const refExists = (qualifiedRef: string) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) + const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) + if (resolvedBaseRef !== baseRef) { + return true + } + if (baseRef.startsWith('refs/')) { + return refExists(baseRef) + } + return hasCommitObjectViaGitExec( + (gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...options }), + baseRef + ) +} + export type WorktreeBaseRefPresence = 'present' | 'absent' | 'unknown' /** diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index e023b41dc60..c389eade83d 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -38,7 +38,10 @@ import { import { getBranchConflictKindViaExec } from '../git/repo-branch-conflict' import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' -import { probeWorktreeBaseRefPresence } from '../git/worktree-base-ref-probe' +import { + hasLocalWorktreeBaseRef, + probeWorktreeBaseRefPresence +} from '../git/worktree-base-ref-probe' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' import { getHostedReviewForBranch } from '../source-control/hosted-review' @@ -718,46 +721,6 @@ function hasLocalGitOptions(gitOptions: { wslDistro?: string }): boolean { return Object.keys(gitOptions).length > 0 } -function hasLocalCommitObjectWithOptions( - repoPath: string, - ref: string, - gitOptions: { wslDistro?: string } -): Promise { - return hasCommitObjectViaGitExec( - (gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...gitOptions }), - ref - ) -} - -async function hasLocalWorktreeBaseRefWithOptions( - repoPath: string, - baseRef: string, - gitOptions: { wslDistro?: string } -): Promise { - const refExists = async (qualifiedRef: string) => { - try { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], - { - cwd: repoPath, - ...gitOptions - } - ) - return stdout.trim().length > 0 - } catch { - return false - } - } - const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) - if (resolvedBaseRef !== baseRef) { - return true - } - if (baseRef.startsWith('refs/')) { - return refExists(baseRef) - } - return hasLocalCommitObjectWithOptions(repoPath, baseRef, gitOptions) -} - function getLocalGitHubPrForBranch( repoPath: string, branchName: string, @@ -2066,14 +2029,10 @@ export async function createLocalWorktree( ) { return true } - return hasLocalWorktreeBaseRefWithOptions( - repo.path, - baseBranchCandidate, - localGitExecOptions - ) + return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate, localGitExecOptions) } } - return hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranchCandidate, localGitExecOptions) + return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate, localGitExecOptions) } }) const [username, resolvedBaseBranch] = await Promise.all([usernamePromise, baseBranchPromise]) @@ -2103,15 +2062,11 @@ export async function createLocalWorktree( if (remoteTrackingBase) { const [hasRemoteTrackingBaseRef, hasNamedLocalBaseRef] = await Promise.all([ runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs), - hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localGitExecOptions) + hasLocalWorktreeBaseRef(repo.path, baseBranch, localGitExecOptions) ]) const hasFallbackLocalBaseRef = !hasNamedLocalBaseRef && - (await hasLocalWorktreeBaseRefWithOptions( - repo.path, - remoteTrackingBase.branch, - localGitExecOptions - )) + (await hasLocalWorktreeBaseRef(repo.path, remoteTrackingBase.branch, localGitExecOptions)) const hasLocalBaseRef = hasRemoteTrackingBaseRef || hasNamedLocalBaseRef || hasFallbackLocalBaseRef if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) { @@ -2136,9 +2091,7 @@ export async function createLocalWorktree( ) } } - } else if ( - !(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions)) - ) { + } else if (!(await hasLocalWorktreeBaseRef(repo.path, baseBranch, localWorktreeGitOptions))) { // Why: non-remote-prefix bases (plain main/master/local) keep the legacy best-effort fetch; verified PR SHA bases already have the object. legacyFetchPromise = runtime .fetchRemoteWithCache(repo.path, 'origin', ...localWorktreeGitOptionArgs) @@ -2147,9 +2100,7 @@ export async function createLocalWorktree( emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) } } else { - if ( - !(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions)) - ) { + if (!(await hasLocalWorktreeBaseRef(repo.path, baseBranch, localWorktreeGitOptions))) { legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], { ...localGitExecOptions, timeout: CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index 9552cd51770..f438df48e3d 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -76,6 +76,15 @@ export { type HandlerMap } from './worktrees-test-ipc-surface' +/** The single repo every worktree harness test resolves; exported so a test can vary one field. */ +export const harnessRepo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 +} + /** Registers worktree IPC handlers against freshly reset shared mocks and returns the runtime stub. */ export function setupWorktreeHandlers(): WorktreeRuntimeStub { delete (store as typeof store & { getAllWorktreeMetaForHost?: (...args: unknown[]) => unknown }) @@ -185,15 +194,8 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { handlers[channel] = handler }) - const repo = { - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0 - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null }) + store.getRepos.mockReturnValue([harnessRepo]) + store.getRepo.mockReturnValue({ ...harnessRepo, worktreeBaseRef: null }) store.getProjects.mockReturnValue([]) store.getSparsePresets.mockReturnValue([]) const settings = { diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index 45426a8585a..cb880263d87 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -13,9 +13,11 @@ import { createSetupRunnerScriptMock, getEffectiveHooksFromConfigMock, shouldRunSetupForCreateMock, + getBaseRefDefaultMock, gitExecFileAsyncMock } from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { handlers, harnessRepo, setupWorktreeHandlers, store } from './worktrees-test-harness' +import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import { createdWorktreeList, mockKnownFeatureWorktree, @@ -104,9 +106,65 @@ vi.mock('../runtime/worktree-teardown', async () => ) vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) +/** Every create-path git call, including the base-ref probe now shared with the + * speculative prefetch, must read the distro's ref store rather than host git's. */ +function expectEveryGitCallRoutedTo(wslDistro: string): void { + const callDistros = new Set( + gitExecFileAsyncMock.mock.calls.map( + ([, options]) => (options as { wslDistro?: string } | undefined)?.wslDistro + ) + ) + expect(callDistros).toEqual(new Set([wslDistro])) +} + describe('registerWorktreeHandlers', () => { + let runtimeStub: WorktreeRuntimeStub + beforeEach(() => { - setupWorktreeHandlers() + runtimeStub = setupWorktreeHandlers() + }) + + it('routes the speculative create-base prefetch through the selected WSL project runtime', async () => { + mockSelectedWslProjectRuntime() + const remoteTrackingBase = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteTrackingBase) + + await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' }) + + expect(getBaseRefDefaultMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' }) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], + { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } + ) + expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( + '/workspace/repo', + 'origin/main', + { wslDistro: 'Ubuntu' } + ) + expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith( + '/workspace/repo', + remoteTrackingBase, + { wslDistro: 'Ubuntu' } + ) + }) + + it('routes the prefetch remote-fetch fallback through the selected WSL project runtime', async () => { + mockSelectedWslProjectRuntime() + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null) + + await handlers['worktrees:prefetchCreateBase'](null, { + repoId: 'repo-1', + baseBranch: 'feature/topic' + }) + + expect(runtimeStub.fetchRemoteWithCache).toHaveBeenCalledWith('/workspace/repo', 'origin', { + wslDistro: 'Ubuntu' + }) }) it('routes local worktree creation through the selected WSL project runtime', async () => { @@ -153,6 +211,36 @@ describe('registerWorktreeHandlers', () => { { wslDistro: 'Ubuntu' } ) expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' }) + expectEveryGitCallRoutedTo('Ubuntu') + }) + + it('routes local worktree creation with a remote tracking base through the selected WSL project runtime', async () => { + mockSelectedWslProjectRuntime() + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue({ + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'abc123\n', stderr: '' }) + // A persisted base that differs from the detected default also drives the usability probe. + store.getRepo.mockReturnValue({ ...harnessRepo, worktreeBaseRef: 'custom-base' }) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + }) + + expectEveryGitCallRoutedTo('Ubuntu') }) it('routes fork push target setup through the selected WSL project runtime', async () => { diff --git a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts index 849a1ff66d9..2f1ab7abad9 100644 --- a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts +++ b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch' import { prepareWorktreeCreateForRepo } from '../../../worktree-create-preparation' +import { getWorktreeCreatePrefetchGitOptions } from '../../../project-runtime-git-options' import type { WorktreeIpcContext } from '../worktree-ipc-context' export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): void { @@ -17,7 +18,8 @@ export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): vo const baseBranch = await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, - runtime + runtime, + gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo) }) if (baseBranch) { await prepareWorktreeCreateForRepo(store, repo, baseBranch) diff --git a/src/main/project-runtime-git-options.test.ts b/src/main/project-runtime-git-options.test.ts index 9b247e3bca1..65af40b9550 100644 --- a/src/main/project-runtime-git-options.test.ts +++ b/src/main/project-runtime-git-options.test.ts @@ -1,9 +1,10 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { Store } from './persistence' import type { Project } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import { getLocalProjectGitExecOptions, + getWorktreeCreatePrefetchGitOptions, getWorktreeMirrorDistro, resolveLocalProjectRuntimeForRepo } from './project-runtime-git-options' @@ -176,6 +177,55 @@ describe('project runtime git options', () => { }) }) + describe('getWorktreeCreatePrefetchGitOptions', () => { + it('routes the warm-up through the distro a resolved WSL project runs in', () => { + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + const project = makeProject({ + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + expect( + withPlatform('win32', () => + getWorktreeCreatePrefetchGitOptions(makeStore(project), makeRepo()) + ) + ).toEqual({ wslDistro: 'Ubuntu' }) + }) + + // A speculative warm-up must degrade to the host Git it used before routing + // existed, never surface the repair state git execution raises. + it('falls back to host git instead of throwing when the runtime needs repair', () => { + _setWslCachesForTests({ available: true, distros: ['Debian'] }) + const project = makeProject({ + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + expect( + withPlatform('win32', () => + getWorktreeCreatePrefetchGitOptions(makeStore(project), makeRepo()) + ) + ).toEqual({}) + }) + + it('does not resolve a project runtime for folder workspaces', () => { + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + const project = makeProject({ + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + const store = makeStore(project) + const getProjects = vi.fn(store.getProjects) + + expect( + withPlatform('win32', () => + getWorktreeCreatePrefetchGitOptions( + { ...store, getProjects } as unknown as Store, + makeRepo({ kind: 'folder' }) + ) + ) + ).toEqual({}) + expect(getProjects).not.toHaveBeenCalled() + }) + }) + it('does not apply local Windows runtime routing to runtime-owned repos', () => { const project = makeProject({ localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } diff --git a/src/main/project-runtime-git-options.ts b/src/main/project-runtime-git-options.ts index aafdd38829c..808d31d5fcf 100644 --- a/src/main/project-runtime-git-options.ts +++ b/src/main/project-runtime-git-options.ts @@ -1,5 +1,6 @@ import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' +import { isFolderRepo } from '../shared/repo-kind' import { resolveLocalProjectRuntimeForRepo, type ProjectRuntimeResolutionStore @@ -58,6 +59,28 @@ export function getLocalProjectWorktreeGitOptions( return wslDistro ? { wslDistro } : {} } +/** + * Git routing for the speculative worktree-create warm-up. + * + * Deliberately non-throwing where `getLocalProjectWorktreeGitOptions` throws: an + * optimistic prefetch must not report a repair-required runtime as a failure, so + * an unresolved runtime falls back to the host Git the warm-up used before + * routing existed. + */ +export function getWorktreeCreatePrefetchGitOptions( + store: Store, + repo: Repo +): LocalProjectWorktreeGitOptions { + if (isFolderRepo(repo)) { + return {} + } + const projectRuntime = resolveLocalProjectRuntimeForRepo(store, repo) + if (!projectRuntime || projectRuntime.status !== 'resolved') { + return {} + } + return getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime) +} + export function getLocalProjectWorktreeGitOptionsForRuntime( repo: Repo, projectRuntime: ProjectExecutionRuntimeResolution | undefined diff --git a/src/main/runtime/orca-runtime-create-base-prefetch.test.ts b/src/main/runtime/orca-runtime-create-base-prefetch.test.ts new file mode 100644 index 00000000000..7dcd1092625 --- /dev/null +++ b/src/main/runtime/orca-runtime-create-base-prefetch.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WorktreeCreatePreparation from '../worktree-create-preparation' +import type { Project } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' +import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl' + +const mocks = vi.hoisted(() => ({ + prefetchWorktreeCreateBase: vi.fn(), + prepareWorktreeCreateForRepo: vi.fn() +})) + +vi.mock('../worktree-create-base-prefetch', () => ({ + prefetchWorktreeCreateBase: mocks.prefetchWorktreeCreateBase +})) +vi.mock('../worktree-create-preparation', async (importOriginal) => ({ + ...(await importOriginal()), + prepareWorktreeCreateForRepo: mocks.prepareWorktreeCreateForRepo +})) + +import { OrcaRuntimeService } from './orca-runtime' + +const repo: Repo = { + id: 'repo-1', + displayName: 'Repo', + path: String.raw`C:\workspace\repo`, + badgeColor: '#000000', + addedAt: 0 +} + +const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 0, + updatedAt: 0, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } +} + +function makeStore(overrides: Partial = {}): unknown { + return { + getRepos: () => [repo], + getRepo: (id: string) => (id === repo.id ? repo : undefined), + getProjects: () => [{ ...project, ...overrides }], + getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'windows-host' } }) + } +} + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + +const hostPlatform = process.platform + +beforeEach(() => { + mocks.prefetchWorktreeCreateBase.mockReset().mockResolvedValue(undefined) + mocks.prepareWorktreeCreateForRepo.mockReset().mockResolvedValue(undefined) +}) + +afterEach(() => { + setPlatform(hostPlatform) + _resetWslCachesForTests() +}) + +// The RPC/relay prefetch is the only warm-up a remote client reaches, so it has +// to resolve the same project runtime the IPC handler does. Constructed through +// the barrel because the split chain resolves selectors in a later subclass. +describe('prefetchManagedWorktreeCreateBase (orca-runtime-get-worktree-terminal-provisioning-host)', () => { + it('warms up in the distro a WSL-routed project runs in', async () => { + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + setPlatform('win32') + const runtime = new OrcaRuntimeService(makeStore() as never) + + await runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' }) + + expect(mocks.prefetchWorktreeCreateBase).toHaveBeenCalledWith( + expect.objectContaining({ gitOptions: { wslDistro: 'Ubuntu' } }) + ) + }) + + it('warms up on host git when no project runtime routes the repo', async () => { + setPlatform('darwin') + const runtime = new OrcaRuntimeService(makeStore() as never) + + await runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' }) + + expect(mocks.prefetchWorktreeCreateBase).toHaveBeenCalledWith( + expect.objectContaining({ gitOptions: {} }) + ) + }) + + // A repair-required runtime must degrade to host git, not fail the warm-up. + it('does not surface a repair-required project runtime as a prefetch failure', async () => { + _setWslCachesForTests({ available: true, distros: ['Debian'] }) + setPlatform('win32') + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect( + runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' }) + ).resolves.toBeUndefined() + + expect(mocks.prefetchWorktreeCreateBase).toHaveBeenCalledWith( + expect.objectContaining({ gitOptions: {} }) + ) + }) + + it('prepares the checkout the prefetch resolved', async () => { + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + setPlatform('win32') + mocks.prefetchWorktreeCreateBase.mockResolvedValue('origin/main') + const runtime = new OrcaRuntimeService(makeStore() as never) + + await runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' }) + + expect(mocks.prepareWorktreeCreateForRepo).toHaveBeenCalledWith( + expect.anything(), + repo, + 'origin/main' + ) + }) +}) diff --git a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts index bab517369f4..1591f344bda 100644 --- a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts +++ b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts @@ -8,6 +8,7 @@ import type { TerminalCreateOptions } from './runtime-terminal-contracts' import type { WorktreeStartupReadinessHost } from './runtime-worktree-startup-readiness' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { prepareWorktreeCreateForRepo } from '../worktree-create-preparation' +import { getWorktreeCreatePrefetchGitOptions } from '../project-runtime-git-options' export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRuntimeWithActivateManagedWorktree { protected getWorktreeTerminalProvisioningHost(): WorktreeTerminalProvisioningHost { @@ -48,14 +49,16 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt } const repo = await this.resolveRepoSelector(args.repoSelector) + const store = this.requireStore() const baseBranch = await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, - runtime: this + runtime: this, + gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo) }) if (baseBranch) { try { - await prepareWorktreeCreateForRepo(this.requireStore(), repo, baseBranch) + await prepareWorktreeCreateForRepo(store, repo, baseBranch) } catch { // Why: speculative preparation is an optimistic warm-up; the real create path reports failures. } diff --git a/src/main/runtime/runtime-local-git-worktree-create.ts b/src/main/runtime/runtime-local-git-worktree-create.ts index 3e5fd454c1c..528aa318cd4 100644 --- a/src/main/runtime/runtime-local-git-worktree-create.ts +++ b/src/main/runtime/runtime-local-git-worktree-create.ts @@ -15,7 +15,7 @@ import { import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { RemoteFetchResult, RemoteTrackingBase } from './runtime-remote-fetch-controller' -import { hasLocalWorktreeBaseRef } from './runtime-worktree-create-git' +import { hasLocalWorktreeBaseRef } from '../git/worktree-base-ref-probe' import { isGeneratedWorktreeCreateName } from '../worktree-create-candidates' import { consumePreparedWorktreeCreate } from '../worktree-create-preparation' import { diff --git a/src/main/runtime/runtime-local-worktree-create.ts b/src/main/runtime/runtime-local-worktree-create.ts index b133c95b1ee..438107ea19f 100644 --- a/src/main/runtime/runtime-local-worktree-create.ts +++ b/src/main/runtime/runtime-local-worktree-create.ts @@ -14,7 +14,7 @@ import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktre import type { RemoteFetchResult, RemoteTrackingBase } from './runtime-remote-fetch-controller' import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { hasLocalGitOptions } from './runtime-worktree-selection' -import { hasLocalWorktreeBaseRef } from './runtime-worktree-create-git' +import { hasLocalWorktreeBaseRef } from '../git/worktree-base-ref-probe' import { resolveRuntimeLocalWorktreeCreateCandidate } from './runtime-local-worktree-create-candidate' import { createRuntimeLocalGitWorktree } from './runtime-local-git-worktree-create' import { materializeRuntimeLocalWorktree } from './runtime-local-worktree-materialization' diff --git a/src/main/runtime/runtime-worktree-create-git.ts b/src/main/runtime/runtime-worktree-create-git.ts index acd9c7c7f1f..251488940dd 100644 --- a/src/main/runtime/runtime-worktree-create-git.ts +++ b/src/main/runtime/runtime-worktree-create-git.ts @@ -1,10 +1,7 @@ import type { BranchPrefixStrategy } from '../../shared/ui-chrome-types' import type { Repo } from '../../shared/repo-types' -import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' import { getPRForBranch } from '../github/client' -import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { gitExecFileAsync } from '../git/runner' -import { hasWorktreeBaseCommitRef } from '../git/worktree-base-ref-probe' import { listWorktrees } from '../git/worktree' import { computeValidatedBranchName } from '../ipc/worktree-logic' import { getHostedReviewForBranch } from '../source-control/hosted-review' @@ -114,23 +111,3 @@ export async function getSelectedHostedReviewForBranch( } : null } - -export async function hasLocalWorktreeBaseRef( - repoPath: string, - baseRef: string, - options: { wslDistro?: string } = {} -): Promise { - const refExists = (qualifiedRef: string) => - hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) - const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) - if (resolvedBaseRef !== baseRef) { - return true - } - if (baseRef.startsWith('refs/')) { - return refExists(baseRef) - } - return hasCommitObjectViaGitExec( - (gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...options }), - baseRef - ) -} diff --git a/src/main/worktree-create-base-prefetch.test.ts b/src/main/worktree-create-base-prefetch.test.ts new file mode 100644 index 00000000000..cfe8a76799a --- /dev/null +++ b/src/main/worktree-create-base-prefetch.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getBaseRefDefault: vi.fn(), + gitExecFileAsync: vi.fn(), + getSshGitProvider: vi.fn(), + prefetchRemoteWorktreeCreateBase: vi.fn(), + resolveRemoteTrackingBase: vi.fn(), + hasRemoteTrackingRef: vi.fn(), + getOrStartRemoteTrackingBaseRefresh: vi.fn(), + fetchRemoteWithCache: vi.fn() +})) + +vi.mock('./git/repo', () => ({ getBaseRefDefault: mocks.getBaseRefDefault })) +vi.mock('./git/runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync })) +vi.mock('./providers/ssh-git-dispatch', () => ({ getSshGitProvider: mocks.getSshGitProvider })) +vi.mock('./ipc/worktree-remote', () => ({ + prefetchRemoteWorktreeCreateBase: mocks.prefetchRemoteWorktreeCreateBase +})) + +import { prefetchWorktreeCreateBase } from './worktree-create-base-prefetch' + +const repo = { + id: 'repo-1', + path: String.raw`C:\workspace\repo`, + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 +} + +const WSL = { wslDistro: 'Ubuntu' } + +function runtime() { + return { + resolveRemoteTrackingBase: mocks.resolveRemoteTrackingBase, + hasRemoteTrackingRef: mocks.hasRemoteTrackingRef, + getOrStartRemoteTrackingBaseRefresh: mocks.getOrStartRemoteTrackingBaseRefresh, + fetchRemoteWithCache: mocks.fetchRemoteWithCache + } +} + +/** Resolve only the named refs/objects; every other rev-parse answers "absent". */ +function resolveOnly(present: string[]): void { + mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => { + const rev = args.at(-1)?.replace('^{commit}', '') ?? '' + return present.includes(rev) + ? { stdout: `${'f'.repeat(40)}\n`, stderr: '' } + : { stdout: '', stderr: '' } + }) +} + +function revParse(ref: string): string[] { + return ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`] +} + +beforeEach(() => { + for (const mock of Object.values(mocks)) { + mock.mockReset() + } + mocks.getBaseRefDefault.mockResolvedValue('origin/main') + resolveOnly([]) + mocks.resolveRemoteTrackingBase.mockResolvedValue(null) + mocks.hasRemoteTrackingRef.mockResolvedValue(false) + mocks.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({ ok: true }) + mocks.fetchRemoteWithCache.mockResolvedValue(undefined) +}) + +describe('prefetchWorktreeCreateBase local git routing', () => { + it('resolves the default base and its ref probes inside the selected WSL distro', async () => { + resolveOnly(['refs/remotes/origin/main']) + + await expect( + prefetchWorktreeCreateBase({ repo, runtime: runtime(), gitOptions: WSL }) + ).resolves.toBe('origin/main') + + expect(mocks.getBaseRefDefault).toHaveBeenCalledWith(repo.path, WSL) + expect(mocks.resolveRemoteTrackingBase).toHaveBeenCalledWith(repo.path, 'origin/main', WSL) + expect(mocks.gitExecFileAsync).toHaveBeenCalledWith(revParse('refs/remotes/origin/main'), { + cwd: repo.path, + ...WSL + }) + // A base that is already local needs no fetch. + expect(mocks.fetchRemoteWithCache).not.toHaveBeenCalled() + }) + + it('probes a full commit object inside the selected WSL distro', async () => { + const sha = 'a'.repeat(40) + resolveOnly([sha]) + + await expect( + prefetchWorktreeCreateBase({ repo, baseBranch: sha, runtime: runtime(), gitOptions: WSL }) + ).resolves.toBe(sha) + + expect(mocks.gitExecFileAsync).toHaveBeenCalledWith(revParse(sha), { + cwd: repo.path, + ...WSL + }) + expect(mocks.fetchRemoteWithCache).not.toHaveBeenCalled() + }) + + it('routes the exact remote-base refresh through the selected WSL distro', async () => { + const remoteTrackingBase = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + mocks.resolveRemoteTrackingBase.mockResolvedValue(remoteTrackingBase) + mocks.hasRemoteTrackingRef.mockResolvedValue(true) + + await expect( + prefetchWorktreeCreateBase({ + repo, + baseBranch: 'origin/main', + runtime: runtime(), + gitOptions: WSL + }) + ).resolves.toBe('origin/main') + + expect(mocks.hasRemoteTrackingRef).toHaveBeenCalledWith(repo.path, remoteTrackingBase, WSL) + expect(mocks.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith( + repo.path, + remoteTrackingBase, + WSL + ) + }) + + it('routes the broad remote-fetch fallback through the selected WSL distro', async () => { + await expect( + prefetchWorktreeCreateBase({ + repo, + baseBranch: 'feature/topic', + runtime: runtime(), + gitOptions: WSL + }) + ).resolves.toBe('feature/topic') + + expect(mocks.fetchRemoteWithCache).toHaveBeenCalledWith(repo.path, 'origin', WSL) + }) + + it('leaves host-routed probes on the git host they used before routing existed', async () => { + await expect( + prefetchWorktreeCreateBase({ + repo, + baseBranch: 'feature/topic', + runtime: runtime(), + gitOptions: {} + }) + ).resolves.toBe('feature/topic') + + expect(mocks.gitExecFileAsync).toHaveBeenCalledWith(revParse('refs/remotes/feature/topic'), { + cwd: repo.path + }) + for (const call of mocks.gitExecFileAsync.mock.calls) { + expect(call[1]).toEqual({ cwd: repo.path }) + } + // Runtime calls keep their original arity so host repos stay on the runtime's own defaults. + expect(mocks.resolveRemoteTrackingBase).toHaveBeenCalledWith(repo.path, 'feature/topic') + expect(mocks.fetchRemoteWithCache).toHaveBeenCalledWith(repo.path, 'origin') + }) + + it('does not resolve a local base for SSH repos', async () => { + const provider = { exec: vi.fn() } + mocks.getSshGitProvider.mockReturnValue(provider) + + await expect( + prefetchWorktreeCreateBase({ + repo: { ...repo, connectionId: 'conn-1' }, + baseBranch: 'origin/main', + runtime: runtime(), + gitOptions: WSL + }) + ).resolves.toBeUndefined() + + expect(mocks.prefetchRemoteWorktreeCreateBase).toHaveBeenCalledWith( + provider, + expect.objectContaining({ connectionId: 'conn-1' }), + { baseBranch: 'origin/main' } + ) + expect(mocks.gitExecFileAsync).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index 50c81ed1959..a0ce1822d9c 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -1,12 +1,15 @@ import { isFolderRepo } from '../shared/repo-kind' import type { Repo } from '../shared/repo-types' -import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref' -import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe' +import { isFullGitObjectId } from './git/commit-object-ref' +import { hasLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' import { getBaseRefDefault } from './git/repo' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote' import { resolveWorktreeCreateBase } from './worktree-create-base' -import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' + +type WorktreeCreateBaseGitOptions = { + wslDistro?: string +} type RemoteTrackingBaseForPrefetch = { remote: string @@ -18,49 +21,51 @@ type RemoteTrackingBaseForPrefetch = { type WorktreeCreateBasePrefetchRuntime = { resolveRemoteTrackingBase: ( repoPath: string, - baseBranch: string + baseBranch: string, + options?: WorktreeCreateBaseGitOptions ) => Promise - hasRemoteTrackingRef: (repoPath: string, base: RemoteTrackingBaseForPrefetch) => Promise + hasRemoteTrackingRef: ( + repoPath: string, + base: RemoteTrackingBaseForPrefetch, + options?: WorktreeCreateBaseGitOptions + ) => Promise getOrStartRemoteTrackingBaseRefresh: ( repoPath: string, - base: RemoteTrackingBaseForPrefetch + base: RemoteTrackingBaseForPrefetch, + options?: WorktreeCreateBaseGitOptions ) => Promise - fetchRemoteWithCache: (repoPath: string, remote: string) => Promise -} - -async function hasLocalWorktreeBaseRef(repoPath: string, baseRef: string): Promise { - const refExists = (qualifiedRef: string) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef) - const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) - if (resolvedBaseRef !== baseRef) { - return true - } - if (baseRef.startsWith('refs/')) { - return refExists(baseRef) - } - return hasLocalCommitObject(repoPath, baseRef) + fetchRemoteWithCache: ( + repoPath: string, + remote: string, + options?: WorktreeCreateBaseGitOptions + ) => Promise } async function prefetchLocalWorktreeCreateBase( repo: Repo, baseBranch: string | undefined, - runtime: WorktreeCreateBasePrefetchRuntime + runtime: WorktreeCreateBasePrefetchRuntime, + options: WorktreeCreateBaseGitOptions ): Promise { + // Keep host-routed calls at their original arity so they stay on the runtime's default options. + const optionArgs: [] | [WorktreeCreateBaseGitOptions] = options.wslDistro ? [options] : [] const resolvedBaseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, - resolveDefaultBaseRef: () => getBaseRefDefault(repo.path), + resolveDefaultBaseRef: () => getBaseRefDefault(repo.path, ...optionArgs), isBaseUsable: async (baseBranchCandidate) => { const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( repo.path, - baseBranchCandidate + baseBranchCandidate, + ...optionArgs ) if (remoteTrackingBase) { - if (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) { + if (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase, ...optionArgs)) { return true } - return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate) + return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate, options) } - return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate) + return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate, options) } }) if (!resolvedBaseBranch) { @@ -68,28 +73,37 @@ async function prefetchLocalWorktreeCreateBase( } if ( isFullGitObjectId(resolvedBaseBranch) && - (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) + (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch, options)) ) { return resolvedBaseBranch } - const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, resolvedBaseBranch) + const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( + repo.path, + resolvedBaseBranch, + ...optionArgs + ) if (remoteTrackingBase) { if ( - (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) || - !(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) + (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase, ...optionArgs)) || + !(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch, options)) ) { - await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase) + await runtime.getOrStartRemoteTrackingBaseRefresh( + repo.path, + remoteTrackingBase, + ...optionArgs + ) return resolvedBaseBranch } } - if (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) { + if (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch, options)) { // Why: hosted-review start points and local branch bases are already local; a broad remote fetch cannot make them fresher. return resolvedBaseBranch } - // Why: keep optimistic prefetch on the same best-effort fallback path as - // create so the real create can reuse the runtime's remote fetch cache. - await runtime.fetchRemoteWithCache(repo.path, 'origin') + // Why: same best-effort fallback create takes, on create's own fetch key, so a + // create that lands here reuses this fetch instead of repeating it. A create + // that instead resolves an exact remote base still queues behind it. + await runtime.fetchRemoteWithCache(repo.path, 'origin', ...optionArgs) return resolvedBaseBranch } @@ -97,6 +111,9 @@ export async function prefetchWorktreeCreateBase(args: { repo: Repo baseBranch?: string runtime: WorktreeCreateBasePrefetchRuntime + /** Routing for the project's Git host; required so a caller cannot silently + * warm up the wrong ref store — pass `{}` for host Git. */ + gitOptions: WorktreeCreateBaseGitOptions }): Promise { if (isFolderRepo(args.repo)) { return undefined @@ -109,5 +126,5 @@ export async function prefetchWorktreeCreateBase(args: { await prefetchRemoteWorktreeCreateBase(provider, args.repo, { baseBranch: args.baseBranch }) return undefined } - return prefetchLocalWorktreeCreateBase(args.repo, args.baseBranch, args.runtime) + return prefetchLocalWorktreeCreateBase(args.repo, args.baseBranch, args.runtime, args.gitOptions) }