diff --git a/src/main/git/command-runner/git-admission-span.test.ts b/src/main/git/command-runner/git-admission-span.test.ts index de74c00ec2e..1e510d25743 100644 --- a/src/main/git/command-runner/git-admission-span.test.ts +++ b/src/main/git/command-runner/git-admission-span.test.ts @@ -114,6 +114,7 @@ describe('git admission span coverage', () => { callback?.(null, 'ok', '') await expect(pending).resolves.toEqual({ stdout: 'ok', stderr: '' }) expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37) + expect(span.setAttribute).toHaveBeenCalledWith('git.runs_in', 'host') expect(span.end).toHaveBeenCalledOnce() }) @@ -135,6 +136,7 @@ describe('git admission span coverage', () => { callback?.(null, Buffer.from('blob'), Buffer.alloc(0)) await expect(pending).resolves.toEqual({ stdout: Buffer.from('blob') }) expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37) + expect(span.setAttribute).toHaveBeenCalledWith('git.runs_in', 'host') expect(span.end).toHaveBeenCalledOnce() }) diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index 0f86399060b..f47ea9fdf31 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -77,6 +77,8 @@ async function gitExecFileAsyncUnlocked( signal: options.admissionSignal ?? options.signal }) span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) + // Which Git ran: a project's runtime can route it into a distro whatever its cwd. + span?.setAttribute('git.runs_in', resolved.wsl ? `wsl:${resolved.wsl.distro}` : 'host') const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest) const capture = ( command: ResolvedCommand @@ -240,6 +242,7 @@ async function gitExecFileAsyncBufferInSpan( signal: options.signal }) span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) + span?.setAttribute('git.runs_in', resolved.wsl ? `wsl:${resolved.wsl.distro}` : 'host') const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest) let termination: Promise | null = null try { diff --git a/src/main/git/repo-base-ref-search.ts b/src/main/git/repo-base-ref-search.ts index 2428e6533df..560b312bfa6 100644 --- a/src/main/git/repo-base-ref-search.ts +++ b/src/main/git/repo-base-ref-search.ts @@ -108,9 +108,14 @@ async function runSearchBaseRefsGit( path: string, normalizedQuery: string, limit: number, - options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } + options: { + remoteNames: readonly string[] + patternGroup?: RefSearchPatternGroup + git?: LocalGitExecOptions + } ): Promise<{ stdout: string }> { - return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( + const execOptions = gitExecOptions(path, options.git) + return getLocalGitCapabilityCache(execOptions).runWithFallback( 'for-each-ref-exclude', () => gitExecFileAsync( @@ -118,7 +123,7 @@ async function runSearchBaseRefsGit( remoteNames: options.remoteNames, patternGroup: options.patternGroup }), - { cwd: path } + execOptions ), () => gitExecFileAsync( @@ -127,7 +132,7 @@ async function runSearchBaseRefsGit( remoteNames: options.remoteNames, patternGroup: options.patternGroup }), - { cwd: path } + execOptions ), isForEachRefExcludeUnsupportedError ) @@ -172,7 +177,8 @@ export async function searchBaseRefDetails( path: string, query: string, limit = REPO_SEARCH_REFS_DEFAULT_LIMIT, - includeQualifiedRefs = true + includeQualifiedRefs = true, + git: LocalGitExecOptions = {} ): Promise { if (!isRepoSearchRefsRequestLimit(limit)) { return [] @@ -181,19 +187,16 @@ export async function searchBaseRefDetails( const normalizedQuery = normalizeRefSearchQuery(query) try { - const remotes = await listRemoteNames(path) + const remotes = await listRemoteNames(path, git) + const search = (patternGroup?: RefSearchPatternGroup): Promise<{ stdout: string }> => + runSearchBaseRefsGit(path, normalizedQuery, boundedScanLimit, { + remoteNames: remotes, + patternGroup, + git + }) const tokens = getRefSearchTokens(normalizedQuery) if (tokens.length > 1) { - const results = await Promise.all([ - runSearchBaseRefsGit(path, normalizedQuery, boundedScanLimit, { - remoteNames: remotes, - patternGroup: 'segmented' - }), - runSearchBaseRefsGit(path, normalizedQuery, boundedScanLimit, { - remoteNames: remotes, - patternGroup: 'branchRoot' - }) - ]) + const results = await Promise.all([search('segmented'), search('branchRoot')]) return mergeBaseRefSearchResultGroups( results.map((entry) => parseAndFilterSearchRefDetails( @@ -207,9 +210,7 @@ export async function searchBaseRefDetails( ) } - const result = await runSearchBaseRefsGit(path, normalizedQuery, boundedScanLimit, { - remoteNames: remotes - }) + const result = await search() return parseAndFilterSearchRefDetails( result.stdout, boundedScanLimit, diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index b882e7232fc..5af732258af 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -112,9 +112,12 @@ function parseRemoteCount(stdout: string): number { } /** Count configured remotes; zero means either none or unavailable. */ -export async function getRemoteCount(path: string): Promise { +export async function getRemoteCount( + path: string, + options: LocalGitExecOptions = {} +): Promise { try { - const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) + const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) return parseRemoteCount(stdout) } catch (err) { console.warn('[getRemoteCount] git remote failed', { path, err }) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index e82d9dcf403..10520ab2361 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -330,6 +330,10 @@ vi.mock('../wsl-move-orchestrator-wiring', () => ({ registerWslMoveOrchestrator: vi.fn() })) +vi.mock('../wsl-drive-project-handlers', () => ({ + registerWslDriveProjectHandlers: vi.fn() +})) + vi.mock('../ephemeral-vm', () => ({ registerEphemeralVmHandlers: registerEphemeralVmHandlersMock })) @@ -632,7 +636,7 @@ describe('registerCoreHandlers', () => { }) expect(registerEmulatorFrameStreamHandlersMock).toHaveBeenCalled() expect(registerEmulatorVideoStreamHandlersMock).toHaveBeenCalled() - expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store) + expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store, undefined) expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime) expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store) expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store, undefined) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 48896979682..fa9bc50d6a3 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -2,6 +2,7 @@ import { app } from 'electron' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { registerAppHandlers } from '../app' import { registerWslMoveWiring } from '../wsl-move-driver-wiring' +import { registerWslDriveProjectHandlers } from '../wsl-drive-project-handlers' import { registerCliHandlers } from '../cli' import { registerPreflightHandlers } from '../preflight' import type { Store } from '../../persistence/loading-store/store' @@ -250,11 +251,7 @@ export function registerCoreHandlers( registerWorkspaceSpaceHandlers(store) registerWorkspacePortHandlers(store) registerLocalhostWorktreeLabelHandlers(store) - if (commitMessageAgentEnv) { - registerFilesystemHandlers(store, commitMessageAgentEnv) - } else { - registerFilesystemHandlers(store) - } + registerFilesystemHandlers(store, commitMessageAgentEnv) registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) registerRuntimeEnvironmentHandlers(store) @@ -264,6 +261,7 @@ export function registerCoreHandlers( invalidateTransport: invalidateRuntimeEnvironmentTransport }) registerWslMoveWiring(store, runtime) + registerWslDriveProjectHandlers(store) registerEphemeralVmHandlers(store, pluginService) // Session history and terminal resume are not chats; a refused host leaves nothing to check. const ensureStructuredSessionOwnership = () => diff --git a/src/main/ipc/repos-remote-base-ref-queries.test.ts b/src/main/ipc/repos-remote-base-ref-queries.test.ts index b2c4dd03cf6..0f5100d768a 100644 --- a/src/main/ipc/repos-remote-base-ref-queries.test.ts +++ b/src/main/ipc/repos-remote-base-ref-queries.test.ts @@ -23,10 +23,11 @@ vi.mock('../worktree-root-preparation', () => ) import { registerRepoHandlers } from './repos' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { createRepoHandlerHarness } from './repos-remote-test-harness' -import { getBaseRefDefault } from '../git/repo' +import { getBaseRefDefault, getRemoteCount } from '../git/repo' const { handleMock, mockStore, gitExecFileAsyncMock, prepareLocalWorktreeRootForRepoMock } = reposMocks @@ -162,3 +163,56 @@ describe('repos:searchBaseRefs on SSH repos', () => { expect(result).toEqual([]) }) }) + +describe('base-ref queries on a drive project whose runtime is WSL', () => { + const { handlers, mockWindow, captureHandlers } = createRepoHandlerHarness() + const originalPlatform = process.platform + const repo = { id: 'r1', path: String.raw`C:\repo`, kind: 'git' } + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + captureHandlers(handleMock) + mockStore.getRepo.mockReset().mockReturnValue(repo) + mockStore.getProjects + .mockReset() + .mockReturnValue([{ id: 'p1', sourceRepoIds: ['r1'], createdAt: 0, updatedAt: 0 }]) + Object.assign(mockStore, { + getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } }) + }) + gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + vi.mocked(getBaseRefDefault).mockClear() + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) + return () => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + vi.unstubAllEnvs() + Reflect.deleteProperty(mockStore, 'getSettings') + } + }) + + it('reads the default base and remote count with the distro Git', async () => { + await handlers.get('repos:getBaseRefDefault')!(null, { repoId: 'r1' }) + + expect(getBaseRefDefault).toHaveBeenCalledWith(repo.path, { wslDistro: 'Ubuntu' }) + expect(getRemoteCount).toHaveBeenCalledWith(repo.path, { wslDistro: 'Ubuntu' }) + }) + + it('reads with host Git, as before, while WSL hosts are off', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + await handlers.get('repos:getBaseRefDefault')!(null, { repoId: 'r1' }) + await handlers.get('repos:searchBaseRefDetails')!(null, { repoId: 'r1', query: 'main' }) + + expect(getBaseRefDefault).toHaveBeenCalledWith(repo.path, {}) + expect(getRemoteCount).toHaveBeenCalledWith(repo.path, {}) + const commands = gitExecFileAsyncMock.mock.calls.map(([args, options]) => [args[0], options]) + expect(commands).toContainEqual(['for-each-ref', { cwd: repo.path }]) + }) + + it('searches base refs with the distro Git', async () => { + await handlers.get('repos:searchBaseRefDetails')!(null, { repoId: 'r1', query: 'main' }) + + const commands = gitExecFileAsyncMock.mock.calls.map(([args, options]) => [args[0], options]) + expect(commands).toContainEqual(['remote', { cwd: repo.path, wslDistro: 'Ubuntu' }]) + expect(commands).toContainEqual(['for-each-ref', { cwd: repo.path, wslDistro: 'Ubuntu' }]) + }) +}) diff --git a/src/main/ipc/repos/base-ref-query-handlers.ts b/src/main/ipc/repos/base-ref-query-handlers.ts index d86bd97be30..fa6bc294fa6 100644 --- a/src/main/ipc/repos/base-ref-query-handlers.ts +++ b/src/main/ipc/repos/base-ref-query-handlers.ts @@ -13,6 +13,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { getBaseRefDefault, getRemoteCount, searchBaseRefDetails } from '../../git/repo' +import { getProjectRuntimeReadGitOptions } from '../../project-runtime-git-options' export function registerBaseRefQueryHandlers(store: Store): void { ipcMain.handle( @@ -30,9 +31,11 @@ export function registerBaseRefQueryHandlers(store: Store): void { if (getRepoRoutingConnectionId(repo)) { return { defaultBaseRef: null, remoteCount: 0 } } + // Where the project's runtime runs Git; a runtime needing repair reads with host Git. + const git = getProjectRuntimeReadGitOptions(store, repo) const [defaultBaseRef, remoteCount] = await Promise.all([ - getBaseRefDefault(repo.path), - getRemoteCount(repo.path) + getBaseRefDefault(repo.path, git), + getRemoteCount(repo.path, git) ]) return { defaultBaseRef, remoteCount } } @@ -76,7 +79,13 @@ async function searchBaseRefDetailsForRepo( if (getRepoRoutingConnectionId(repo)) { return [] } - return searchBaseRefDetails(repo.path, args.query, limit) + return searchBaseRefDetails( + repo.path, + args.query, + limit, + true, + getProjectRuntimeReadGitOptions(store, repo) + ) } function getRepoForExecutionHost( diff --git a/src/main/ipc/repos/repo-ipc-arg-schemas.ts b/src/main/ipc/repos/repo-ipc-arg-schemas.ts index d8c2b84d6ed..1cac2d6fb5e 100644 --- a/src/main/ipc/repos/repo-ipc-arg-schemas.ts +++ b/src/main/ipc/repos/repo-ipc-arg-schemas.ts @@ -49,7 +49,11 @@ export const ProjectHostSetupExistingFolderIpcArgs = z.object({ const LocalWindowsRuntimePreferenceIpcArgs = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('inherit-global') }), z.object({ kind: z.literal('windows-host') }), - z.object({ kind: z.literal('wsl'), distro: z.string().min(1) }) + z.object({ + kind: z.literal('wsl'), + distro: z.string().min(1), + git: z.literal('windows-host').optional() + }) ]) export const ProjectUpdateIpcArgs = z.object({ diff --git a/src/main/ipc/wsl-drive-project-handlers.test.ts b/src/main/ipc/wsl-drive-project-handlers.test.ts new file mode 100644 index 00000000000..5c724cf9aef --- /dev/null +++ b/src/main/ipc/wsl-drive-project-handlers.test.ts @@ -0,0 +1,89 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' + +type Handler = (event: unknown, request?: unknown) => unknown + +const handlers = vi.hoisted(() => new Map()) +vi.mock('electron', () => ({ + ipcMain: { + removeHandler: (channel: string) => handlers.delete(channel), + handle: (channel: string, handler: Handler) => handlers.set(channel, handler) + } +})) +const conversion = vi.hoisted(() => ({ + previewWslDriveProjectWindowsGit: vi.fn(async () => ({ blockers: [] })), + convertWslDriveProjectToWindowsGit: vi.fn(async () => ({ ok: false, blockers: [] })) +})) +vi.mock('../wsl/wsl-drive-project-windows-git', () => conversion) + +import { registerWslDriveProjectHandlers, readWorktreeLinks } from './wsl-drive-project-handlers' + +const invoke = (channel: string, request?: unknown) => handlers.get(channel)?.({}, request) +const originalPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + +beforeEach(() => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + setPlatform('win32') + conversion.previewWslDriveProjectWindowsGit.mockClear() + conversion.convertWslDriveProjectToWindowsGit.mockClear() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The conversion is mocked; the handler only builds deps. + registerWslDriveProjectHandlers({} as never) +}) +afterEach(() => { + vi.unstubAllEnvs() + setPlatform(originalPlatform) +}) + +describe('WSL drive project IPC', () => { + it('refuses with the gate closed or off Windows, before reading anything', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + await expect(invoke('wslDriveProject:preview', 'p1')).rejects.toThrow( + 'wsl_drive_project_unavailable' + ) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + setPlatform('darwin') + await expect( + invoke('wslDriveProject:convert', { projectId: 'p1', alignLineEndings: true }) + ).rejects.toThrow('wsl_drive_project_unavailable') + expect(conversion.previewWslDriveProjectWindowsGit).not.toHaveBeenCalled() + expect(conversion.convertWslDriveProjectToWindowsGit).not.toHaveBeenCalled() + }) + + it('passes the project and an explicit line-ending opt-in only', async () => { + await invoke('wslDriveProject:convert', { projectId: 'p1', alignLineEndings: 'yes' }) + expect(conversion.convertWslDriveProjectToWindowsGit).toHaveBeenCalledWith( + expect.anything(), + 'p1', + { alignLineEndings: false } + ) + await expect(invoke('wslDriveProject:preview', '')).rejects.toThrow( + 'wsl_drive_project_invalid_args' + ) + }) +}) + +describe('readWorktreeLinks', () => { + let commonDir: string + beforeEach(() => { + commonDir = mkdtempSync(join(tmpdir(), 'orca-wsl-drive-links-')) + }) + afterEach(() => rmSync(commonDir, { recursive: true, force: true })) + + it('reads each linked worktree link and skips entries without one', async () => { + mkdirSync(join(commonDir, 'worktrees', 'fix'), { recursive: true }) + writeFileSync(join(commonDir, 'worktrees', 'fix', 'gitdir'), '/home/me/trees/fix/.git\n') + mkdirSync(join(commonDir, 'worktrees', 'broken'), { recursive: true }) + await expect(readWorktreeLinks(commonDir)).resolves.toEqual(['/home/me/trees/fix/.git']) + }) + + it('reads none for a repo with no linked worktrees', async () => { + await expect(readWorktreeLinks(commonDir)).resolves.toEqual([]) + }) +}) diff --git a/src/main/ipc/wsl-drive-project-handlers.ts b/src/main/ipc/wsl-drive-project-handlers.ts new file mode 100644 index 00000000000..54d0b465549 --- /dev/null +++ b/src/main/ipc/wsl-drive-project-handlers.ts @@ -0,0 +1,70 @@ +/** + * IPC for converting a WSL-runtime project on a Windows drive to Windows Git (D1c). Refused while + * the internal WSL hosts gate is closed or off Windows, before anything is read. + */ +import { join } from 'node:path' +import { ipcMain } from 'electron' +import type { Store } from '../persistence' +import { gitExecFileAsync } from '../git/command-runner/git-exec-file' +import { readWorktreeLinks as readWorktreeAdminLinks } from '../git/worktree-prune-guard' +import { resolveLocalProjectRuntimeForProject } from '../local-project-runtime-resolution' +import { wslHostsGateOpen } from '../wsl/wsl-host-gate' +import { + convertWslDriveProjectToWindowsGit, + previewWslDriveProjectWindowsGit, + type WslDriveProjectDeps +} from '../wsl/wsl-drive-project-windows-git' + +export const WSL_DRIVE_PROJECT_HANDLER_CHANNELS = [ + 'wslDriveProject:preview', + 'wslDriveProject:convert' +] as const + +export function registerWslDriveProjectHandlers(store: Store): void { + for (const channel of WSL_DRIVE_PROJECT_HANDLER_CHANNELS) { + ipcMain.removeHandler(channel) + } + ipcMain.handle('wslDriveProject:preview', async (_event, projectId: unknown) => + previewWslDriveProjectWindowsGit(gatedDeps(store), requireId(projectId)) + ) + ipcMain.handle('wslDriveProject:convert', async (_event, args: unknown) => { + const request = typeof args === 'object' && args !== null ? args : {} + const alignLineEndings = 'alignLineEndings' in request && request.alignLineEndings === true + return convertWslDriveProjectToWindowsGit( + gatedDeps(store), + requireId('projectId' in request ? request.projectId : undefined), + { alignLineEndings } + ) + }) +} + +function requireId(value: unknown): string { + if (typeof value !== 'string' || !value) { + throw new Error('wsl_drive_project_invalid_args') + } + return value +} + +function gatedDeps(store: Store): WslDriveProjectDeps { + if (process.platform !== 'win32' || !wslHostsGateOpen()) { + throw new Error('wsl_drive_project_unavailable') + } + return { + getProjects: () => store.getProjects(), + getRepos: () => store.getRepos(), + resolveRuntime: (project) => { + const runtime = resolveLocalProjectRuntimeForProject(store, project) + if (!runtime) { + throw new Error('wsl_drive_project_runtime_unresolved') + } + return runtime + }, + // No wslDistro: this runs the host's Git even for a WSL-runtime project. + runWindowsGit: async (args, cwd) => (await gitExecFileAsync(args, { cwd })).stdout, + readWorktreeLinks + } +} + +export async function readWorktreeLinks(commonDir: string): Promise { + return (await readWorktreeAdminLinks(join(commonDir, 'worktrees'))).map(({ link }) => link) +} diff --git a/src/main/local-project-runtime-resolution.ts b/src/main/local-project-runtime-resolution.ts index 6024d963a7e..5009887ee0f 100644 --- a/src/main/local-project-runtime-resolution.ts +++ b/src/main/local-project-runtime-resolution.ts @@ -74,6 +74,16 @@ function resolveLocalProjectRuntime( }) } +/** One local project's runtime, from the current WSL discovery caches. */ +export function resolveLocalProjectRuntimeForProject( + store: ProjectRuntimeResolutionStore, + project: Pick +): ProjectExecutionRuntimeResolution | undefined { + return canResolveProjectRuntimeForRepo(store) + ? resolveLocalProjectRuntime(store, project) + : undefined +} + export function resolveLocalProjectRuntimeForRepo( store: ProjectRuntimeResolutionStore, repo: Repo diff --git a/src/main/project-runtime-git-options.test.ts b/src/main/project-runtime-git-options.test.ts index 3d043ef70c7..b37b16adbea 100644 --- a/src/main/project-runtime-git-options.test.ts +++ b/src/main/project-runtime-git-options.test.ts @@ -49,10 +49,14 @@ function makeProject(overrides: Partial = {}): Project { } } -function makeStore(project: Project): Store { +function makeStore( + project: Project, + localWindowsRuntimeDefault: unknown = { kind: 'windows-host' } +): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: runtime resolution reads only getProjects and getSettings. return { getProjects: () => [project], - getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'windows-host' } }) + getSettings: () => ({ localWindowsRuntimeDefault }) } as unknown as Store } @@ -73,6 +77,26 @@ describe('project runtime git options', () => { expect(options).toEqual({ cwd: String.raw`C:\repo`, wslDistro: 'Ubuntu' }) }) + it('keeps WSL Git for a drive project that inherits WSL and never chose Windows Git', () => { + const store = makeStore(makeProject(), { kind: 'wsl', distro: 'Ubuntu' }) + + const options = withPlatform('win32', () => getLocalProjectGitExecOptions(store, makeRepo())) + + expect(options).toEqual({ cwd: String.raw`C:\repo`, wslDistro: 'Ubuntu' }) + }) + + it('runs Windows Git for a WSL project converted to Windows Git', () => { + const project = makeProject({ + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu', git: 'windows-host' } + }) + + const options = withPlatform('win32', () => + getLocalProjectGitExecOptions(makeStore(project), makeRepo()) + ) + + expect(options).toEqual({ cwd: String.raw`C:\repo` }) + }) + it('returns repair state for missing cached WSL distro before local git execution', () => { _setWslCachesForTests({ available: true, distros: ['Debian'] }) const project = makeProject({ @@ -149,6 +173,17 @@ describe('project runtime git options', () => { ).toBe('Ubuntu') }) + it('names no distro for a project converted to Windows Git', () => { + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + const project = makeProject({ + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu', git: 'windows-host' } + }) + + expect( + withPlatform('win32', () => getWorktreeMirrorDistro(makeStore(project), makeRepo())) + ).toBeUndefined() + }) + it('names no distro for a host-runtime project', () => { const project = makeProject({ localWindowsRuntimePreference: { kind: 'windows-host' } }) diff --git a/src/main/project-runtime-git-options.ts b/src/main/project-runtime-git-options.ts index dc1fe67a0b4..f98a6a3ebc3 100644 --- a/src/main/project-runtime-git-options.ts +++ b/src/main/project-runtime-git-options.ts @@ -5,6 +5,7 @@ import { type ProjectRuntimeResolutionStore } from './local-project-runtime-resolution' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' +import { wslHostsGateOpen } from './wsl/wsl-host-gate' export { resolveLocalProjectRuntimeForRepo, @@ -47,7 +48,7 @@ function getLocalProjectGitExecOptionsForRuntime( `Project runtime requires repair before git execution: ${projectRuntime.repair.reason}` ) } - if (projectRuntime.runtime.kind === 'wsl') { + if (projectRuntime.runtime.kind === 'wsl' && projectRuntime.runtime.git !== 'windows-host') { return { cwd: repo.path, wslDistro: projectRuntime.runtime.distro } } return { cwd: repo.path } @@ -87,6 +88,14 @@ export function getWorktreeCreatePrefetchGitOptions( return getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime) } +/** Base-ref and remote-identity reads follow the runtime only with WSL hosts on; before, host Git. */ +export function getProjectRuntimeReadGitOptions( + store: ProjectRuntimeResolutionStore, + repo: Repo +): LocalProjectWorktreeGitOptions { + return wslHostsGateOpen() ? getWorktreeCreatePrefetchGitOptions(store, repo) : {} +} + export function getLocalProjectWorktreeGitOptionsForRuntime( repo: Repo, projectRuntime: ProjectExecutionRuntimeResolution | undefined @@ -110,5 +119,8 @@ export function getWorktreeMirrorDistroForRuntime( if (!projectRuntime || projectRuntime.status !== 'resolved') { return undefined } - return projectRuntime.runtime.kind === 'wsl' ? projectRuntime.runtime.distro : undefined + // Windows Git can't use a worktree WSL Git would create inside the distro. + return projectRuntime.runtime.kind === 'wsl' && projectRuntime.runtime.git !== 'windows-host' + ? projectRuntime.runtime.distro + : undefined } diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts index 4c66bbe16e8..de131f743ab 100644 --- a/src/main/repo-git-remote-identity-enrichment.test.ts +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -5,6 +5,9 @@ import type { Repo } from '../shared/repo-types' import type { RepoHostTarget } from '../shared/repo-host-identity' import { repoTarget } from './persistence/repo-host-target-matcher.test-fixture' import { type GitRemoteIdentityProbe, probeGitRemoteIdentity } from './repo-git-remote-identity' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../shared/wsl-host-internal-gate' +import type * as DistroGate from './wsl-background-distro-gate' +import type { WslDistroRunningState } from './wsl-background-distro-gate' import { enrichMissingRepoGitRemoteIdentities, flushRepoGitRemoteIdentityEnrichmentForTests, @@ -14,6 +17,11 @@ import { vi.mock('./repo-git-remote-identity', () => ({ probeGitRemoteIdentity: vi.fn() })) +const distroGate = vi.hoisted((): { state: WslDistroRunningState } => ({ state: 'running' })) +vi.mock('./wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal()), + readWslDistroRunningState: async () => distroGate.state +})) type RepoIdentityStore = { getRepos: () => Repo[] @@ -123,6 +131,61 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { expect(onChanged).toHaveBeenCalledTimes(1) }) + function drivePathRepoUnderWslRuntime(): { repo: Repo; store: RepoIdentityStore } { + const repo = makeRepo({ path: String.raw`C:\src\app` }) + const store = { + ...makeStore(repo), + getProjects: () => [ + { + id: 'p1', + displayName: 'app', + badgeColor: '', + sourceRepoIds: [repo.id], + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + localWindowsRuntimeDefault: { kind: 'wsl' as const, distro: 'Ubuntu' } + }) + } + return { repo, store } + } + + async function sweepOnWindows(store: RepoIdentityStore, gate: string): Promise { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, gate) + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + await sweep(store) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + vi.unstubAllEnvs() + distroGate.state = 'running' + } + } + + it.each([ + ['with the distro Git', '1', 'Ubuntu'], + ['with host Git, as before, while WSL hosts are off', '', undefined] + ])('probes a drive project whose runtime is WSL %s', async (_case, gate, distro) => { + vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) + const { repo, store } = drivePathRepoUnderWslRuntime() + await sweepOnWindows(store, gate) + + expect(probeGitRemoteIdentity).toHaveBeenCalledWith(repo.path, 'local', expect.any(Object)) + expect(vi.mocked(probeGitRemoteIdentity).mock.calls[0]?.[2]?.wslDistro).toBe(distro) + }) + + it("never probes a drive project whose WSL runtime's distro is stopped, so the sweep can't boot it", async () => { + vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) + distroGate.state = 'stopped' + const { store } = drivePathRepoUnderWslRuntime() + await sweepOnWindows(store, '1') + + expect(probeGitRemoteIdentity).not.toHaveBeenCalled() + }) + it('probes an SSH row that carries only executionHostId on its own host', async () => { vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) const store = makeStore(makeRepo({ executionHostId: 'ssh:builder' })) diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts index 57de7661662..93d135cdd68 100644 --- a/src/main/repo-git-remote-identity-enrichment.ts +++ b/src/main/repo-git-remote-identity-enrichment.ts @@ -11,6 +11,8 @@ import { isUnresolvedSshHostAlias } from '../shared/git-remote-host-alias' import { getProjectProviderIdentity } from '../shared/project-host-setup-projection' import { getStoredRepoExecutionHostId } from './repo-execution-host' import { probeGitRemoteIdentity } from './repo-git-remote-identity' +import { getProjectRuntimeReadGitOptions } from './project-runtime-git-options' +import type { ProjectRuntimeResolutionStore } from './local-project-runtime-resolution' import { isWslMoveRepoBlocked } from './persistence/moving-wsl-projects/wsl-move-background-backoff' import { blockedWslDistroForPath, wslDistroForPath } from './wsl/wsl-unc-access-gate' @@ -22,7 +24,7 @@ const RESOLVED_IDENTITY_REFRESH_STARTUP_DELAY_MS = 5 * 60 * 1000 // Bound speculative probes so large repo lists do not stall a sweep. const MAX_IDENTITY_REFRESHES_PER_SWEEP = 4 -type RepoIdentityStore = { +type RepoIdentityStore = ProjectRuntimeResolutionStore & { getRepos(): Repo[] getRepo?(id: string, hostId?: ExecutionHostId): Repo | undefined updateRepo( @@ -160,13 +162,19 @@ async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo) : NO_IDENTITY_RETRY_TTL_MS const controller = new AbortController() const promise = (async () => { + // A local project's runtime may run its Git in a distro, whatever its path. + const git = getProjectRuntimeReadGitOptions(store, repo) // E9: git on a stopped distro's repo boots it; no deadline, so the next sweep retries once it runs. // Checked synchronously first so host repos keep probing without an extra await. - if (wslDistroForPath(repo.path) && (await blockedWslDistroForPath(repo.path))) { + if ( + wslDistroForPath(repo.path, git.wslDistro) && + (await blockedWslDistroForPath(repo.path, git.wslDistro)) + ) { return false } const result = await probeGitRemoteIdentity(repo.path, hostId, { - signal: controller.signal + signal: controller.signal, + ...git }) // Aborted probes resolve as unavailable; prevent retired probes from re-seeding deadlines. if (controller.signal.aborted) { diff --git a/src/main/repo-git-remote-identity.ts b/src/main/repo-git-remote-identity.ts index 27c5a99b7ee..56ad4e246f0 100644 --- a/src/main/repo-git-remote-identity.ts +++ b/src/main/repo-git-remote-identity.ts @@ -10,6 +10,8 @@ const LOCAL_PROBE_TIMEOUT_MS = 5000 export type GitRemoteIdentityProbeOptions = { signal?: AbortSignal timeoutMs?: number + /** The distro a local project's runtime runs Git in. */ + wslDistro?: string } /** no-remote proves absence; unavailable leaves the repo identity unknown. */ @@ -32,7 +34,8 @@ export async function runGitProbeOnHost( return gitExecFileAsync(args, { cwd: repoPath, timeout: options.timeoutMs ?? LOCAL_PROBE_TIMEOUT_MS, - signal: options.signal + signal: options.signal, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}) }) } diff --git a/src/main/wsl/wsl-drive-project-windows-git.test.ts b/src/main/wsl/wsl-drive-project-windows-git.test.ts new file mode 100644 index 00000000000..6c94c7591f1 --- /dev/null +++ b/src/main/wsl/wsl-drive-project-windows-git.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi } from 'vitest' +import { + resolveProjectExecutionRuntime, + type LocalWindowsRuntimePreference +} from '../../shared/project-execution-runtime' +import type { Project } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' +import { + convertWslDriveProjectToWindowsGit, + previewWslDriveProjectWindowsGit, + type WslDriveProjectDeps +} from './wsl-drive-project-windows-git' + +const REPO_PATH = String.raw`C:\src\app` + +function setup( + options: { + preference?: LocalWindowsRuntimePreference + globalDistro?: string | null + repo?: Partial + links?: string[] + windowsAutocrlf?: string | null + localAutocrlf?: string | null + gitMissing?: boolean + distros?: string[] + } = {} +) { + const project: Project = { + id: 'p1', + displayName: 'app', + badgeColor: '#000', + sourceRepoIds: ['r1'], + createdAt: 0, + updatedAt: 0, + ...(options.preference ? { localWindowsRuntimePreference: options.preference } : {}) + } + const repo: Repo = { + id: 'r1', + displayName: 'app', + path: REPO_PATH, + badgeColor: '#000', + addedAt: 0, + ...options.repo + } + const runWindowsGit = vi.fn(async (args: string[], _cwd: string) => { + if (options.gitMissing) { + throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }) + } + const key = args.join(' ') + if (key === '--version') { + return 'git version 2.47.1.windows.1\n' + } + if (key === 'rev-parse --git-common-dir') { + return '.git\n' + } + const configValue = + key === 'config --get core.autocrlf' + ? options.windowsAutocrlf + : key === 'config --local --get core.autocrlf' + ? options.localAutocrlf + : undefined + if (configValue !== undefined) { + if (configValue === null) { + throw Object.assign(new Error('exit 1'), { code: 1 }) + } + return `${configValue}\n` + } + return '' + }) + const readWorktreeLinks = vi.fn(async () => options.links ?? []) + const deps: WslDriveProjectDeps = { + getProjects: () => [project], + getRepos: () => [repo], + resolveRuntime: (entry) => + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: entry.id, + projectRuntimePreference: entry.localWindowsRuntimePreference, + globalWindowsRuntimeDefault: + options.globalDistro === undefined + ? { kind: 'windows-host' } + : { kind: 'wsl', distro: options.globalDistro }, + wslAvailable: true, + availableWslDistros: options.distros ?? ['Ubuntu'] + }), + runWindowsGit, + readWorktreeLinks + } + return { deps, runWindowsGit, readWorktreeLinks } +} + +describe('converting a WSL project on a Windows drive to Windows Git', () => { + it('keeps WSL for terminals and switches only Git, for a project override', async () => { + const { deps } = setup({ preference: { kind: 'wsl', distro: 'Ubuntu' } }) + await expect( + convertWslDriveProjectToWindowsGit(deps, 'p1', { alignLineEndings: false }) + ).resolves.toEqual({ + ok: true, + preference: { kind: 'wsl', distro: 'Ubuntu', git: 'windows-host' }, + alignedLineEndingRepoIds: [] + }) + }) + + it('pins the global default distro on a project that inherits it', async () => { + const { deps } = setup({ globalDistro: 'Ubuntu' }) + await expect( + convertWslDriveProjectToWindowsGit(deps, 'p1', { alignLineEndings: false }) + ).resolves.toMatchObject({ + preference: { kind: 'wsl', distro: 'Ubuntu', git: 'windows-host' } + }) + }) + + it('refuses a repo with worktrees WSL Git made, naming them, and changes nothing', async () => { + const { deps, runWindowsGit, readWorktreeLinks } = setup({ + preference: { kind: 'wsl', distro: 'Ubuntu' }, + links: [ + 'C:/src/app-feature/.git', + // Windows Git reaches a worktree inside the distro over UNC; that link is its own. + '//wsl.localhost/Ubuntu/home/me/trees/app-unc/.git', + '/home/me/orca/workspaces/app/fix/.git', + '/mnt/c/src/app-wsl/.git' + ] + }) + await expect( + convertWslDriveProjectToWindowsGit(deps, 'p1', { alignLineEndings: true }) + ).resolves.toEqual({ + ok: false, + blockers: [ + { + kind: 'wsl-authored-worktrees', + repoId: 'r1', + paths: ['/home/me/orca/workspaces/app/fix', '/mnt/c/src/app-wsl'] + } + ] + }) + expect(readWorktreeLinks).toHaveBeenCalledWith(String.raw`C:\src\app\.git`) + expect(runWindowsGit).not.toHaveBeenCalledWith( + ['config', '--local', 'core.autocrlf', 'true'], + REPO_PATH + ) + }) + + it.each([ + [ + 'a folder inside the distro', + { repo: { path: String.raw`\\wsl.localhost\Ubuntu\home\me\app` } }, + { kind: 'not-windows-drive', repoId: 'r1' } + ], + [ + 'a worktree folder inside the distro', + { repo: { worktreeBasePath: String.raw`\\wsl.localhost\Ubuntu\home\me\trees` } }, + { kind: 'worktree-folder-in-distro', repoId: 'r1' } + ], + ['no Windows Git', { gitMissing: true }, { kind: 'windows-git-missing' }] + ])('refuses %s', async (_case, extra, blocker) => { + const { deps } = setup({ + preference: { kind: 'wsl', distro: 'Ubuntu' }, + ...extra + }) + await expect(previewWslDriveProjectWindowsGit(deps, 'p1')).resolves.toMatchObject({ + blockers: [blocker] + }) + await expect( + convertWslDriveProjectToWindowsGit(deps, 'p1', { alignLineEndings: false }) + ).resolves.toEqual({ ok: false, blockers: [blocker] }) + }) + + it('leaves a folder repo out: it runs no Git, so there is nothing to check', async () => { + const { deps, runWindowsGit } = setup({ + preference: { kind: 'wsl', distro: 'Ubuntu' }, + repo: { kind: 'folder', path: String.raw`\\wsl.localhost\Ubuntu\home\me\notes` } + }) + await expect(previewWslDriveProjectWindowsGit(deps, 'p1')).resolves.toMatchObject({ + repos: [], + blockers: [] + }) + expect(runWindowsGit).not.toHaveBeenCalled() + }) + + it.each([ + ['a Windows runtime', { preference: { kind: 'windows-host' } as const }, 'not-wsl-runtime'], + [ + 'an already converted project', + { preference: { kind: 'wsl', distro: 'Ubuntu', git: 'windows-host' } as const }, + 'not-wsl-runtime' + ], + [ + 'a distro that is gone', + { preference: { kind: 'wsl', distro: 'Ubuntu' } as const, distros: ['Debian'] }, + 'runtime-needs-repair' + ] + ])('has nothing to convert for %s', async (_case, extra, kind) => { + const { deps } = setup(extra) + await expect(previewWslDriveProjectWindowsGit(deps, 'p1')).resolves.toMatchObject({ + blockers: [{ kind }] + }) + }) + + it('writes autocrlf into the repo only when asked and Windows Git uses it', async () => { + const asked = setup({ + preference: { kind: 'wsl', distro: 'Ubuntu' }, + windowsAutocrlf: 'true', + localAutocrlf: null + }) + await expect( + convertWslDriveProjectToWindowsGit(asked.deps, 'p1', { alignLineEndings: true }) + ).resolves.toMatchObject({ ok: true, alignedLineEndingRepoIds: ['r1'] }) + expect(asked.runWindowsGit).toHaveBeenCalledWith( + ['config', '--local', 'core.autocrlf', 'true'], + REPO_PATH + ) + + const notAsked = setup({ + preference: { kind: 'wsl', distro: 'Ubuntu' }, + windowsAutocrlf: 'true', + localAutocrlf: null + }) + await convertWslDriveProjectToWindowsGit(notAsked.deps, 'p1', { alignLineEndings: false }) + const alreadySet = setup({ + preference: { kind: 'wsl', distro: 'Ubuntu' }, + windowsAutocrlf: 'true', + localAutocrlf: 'false' + }) + await convertWslDriveProjectToWindowsGit(alreadySet.deps, 'p1', { alignLineEndings: true }) + for (const { runWindowsGit } of [notAsked, alreadySet]) { + expect(runWindowsGit).not.toHaveBeenCalledWith( + ['config', '--local', 'core.autocrlf', 'true'], + REPO_PATH + ) + } + }) + + it('previews the line-ending settings the dialog offers to align', async () => { + const { deps } = setup({ + preference: { kind: 'wsl', distro: 'Ubuntu' }, + windowsAutocrlf: 'true', + localAutocrlf: null + }) + await expect(previewWslDriveProjectWindowsGit(deps, 'p1')).resolves.toEqual({ + projectId: 'p1', + distro: 'Ubuntu', + repos: [{ repoId: 'r1', path: REPO_PATH, windowsAutocrlf: 'true', localAutocrlf: null }], + blockers: [] + }) + }) +}) diff --git a/src/main/wsl/wsl-drive-project-windows-git.ts b/src/main/wsl/wsl-drive-project-windows-git.ts new file mode 100644 index 00000000000..b9f7776beb4 --- /dev/null +++ b/src/main/wsl/wsl-drive-project-windows-git.ts @@ -0,0 +1,164 @@ +/** + * Converts a WSL-runtime project on a Windows drive (`C:\…`) to Windows Git, keeping its terminals + * and agents in WSL. Paths, worktree ids, chats and live terminals are untouched; only where Orca + * runs Git changes. Refused when Windows Git couldn't safely own the repo's worktrees. + */ +import { win32 } from 'node:path' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import type { Project } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' +import { isFolderRepo } from '../../shared/repo-kind' +import { isWslUncPath } from '../../shared/wsl-paths' +import { isForeignLink } from '../git/worktree-prune-guard' +import type { + WslDriveProjectConversionResult, + WslDriveProjectPreview +} from '../../shared/wsl-drive-project-types' + +/** Windows Git only: the runner never routes through WSL. */ +export type WindowsGitRunner = (args: string[], cwd: string) => Promise + +export type WslDriveProjectDeps = { + getProjects: () => readonly Project[] + getRepos: () => readonly Repo[] + resolveRuntime: (project: Project) => ProjectExecutionRuntimeResolution + runWindowsGit: WindowsGitRunner + /** The `gitdir` link of each linked worktree under `/worktrees`. */ + readWorktreeLinks: (commonDir: string) => Promise +} + +export async function previewWslDriveProjectWindowsGit( + deps: WslDriveProjectDeps, + projectId: string +): Promise { + const project = deps.getProjects().find((entry) => entry.id === projectId) + if (!project) { + throw new Error('wsl_drive_project_missing') + } + const preview: WslDriveProjectPreview = { projectId, distro: null, repos: [], blockers: [] } + const runtime = deps.resolveRuntime(project) + if (runtime.status === 'repair-required') { + preview.blockers.push({ kind: 'runtime-needs-repair' }) + return preview + } + if (runtime.runtime.kind !== 'wsl' || runtime.runtime.git === 'windows-host') { + preview.blockers.push({ kind: 'not-wsl-runtime' }) + return preview + } + preview.distro = runtime.runtime.distro + // Folder repos run no Git, so the Git choice never reaches them. + const repos = deps + .getRepos() + .filter((repo) => project.sourceRepoIds.includes(repo.id) && !isFolderRepo(repo)) + for (const repo of repos) { + if ( + getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID || + !isWindowsDrivePath(repo.path) + ) { + preview.blockers.push({ kind: 'not-windows-drive', repoId: repo.id }) + continue + } + if (repo.worktreeBasePath && isWslUncPath(repo.worktreeBasePath)) { + preview.blockers.push({ kind: 'worktree-folder-in-distro', repoId: repo.id }) + } + } + if (preview.blockers.length > 0) { + return preview + } + if (!(await windowsGitAvailable(deps.runWindowsGit, repos[0]?.path))) { + preview.blockers.push({ kind: 'windows-git-missing' }) + return preview + } + for (const repo of repos) { + const wslAuthored = await wslAuthoredWorktreePaths(deps, repo.path) + if (wslAuthored.length > 0) { + preview.blockers.push({ kind: 'wsl-authored-worktrees', repoId: repo.id, paths: wslAuthored }) + } + preview.repos.push({ + repoId: repo.id, + path: repo.path, + windowsAutocrlf: await readConfig(deps.runWindowsGit, repo.path, []), + localAutocrlf: await readConfig(deps.runWindowsGit, repo.path, ['--local']) + }) + } + return preview +} + +/** + * `alignLineEndings` writes Windows Git's `core.autocrlf=true` into the repo, so plain WSL Git in + * the project's terminals stops seeing every file as modified and committing CRLF. Opt-in: it + * writes to the user's repo. Never `core.checkStat`, which hides same-size edits from Windows Git. + */ +export async function convertWslDriveProjectToWindowsGit( + deps: WslDriveProjectDeps, + projectId: string, + options: { alignLineEndings: boolean } +): Promise { + const preview = await previewWslDriveProjectWindowsGit(deps, projectId) + if (preview.blockers.length > 0 || !preview.distro) { + return { ok: false, blockers: preview.blockers } + } + const alignedLineEndingRepoIds: string[] = [] + if (options.alignLineEndings) { + for (const repo of preview.repos) { + if (repo.windowsAutocrlf === 'true' && repo.localAutocrlf === null) { + await deps.runWindowsGit(['config', '--local', 'core.autocrlf', 'true'], repo.path) + alignedLineEndingRepoIds.push(repo.repoId) + } + } + } + return { + ok: true, + preference: { kind: 'wsl', distro: preview.distro, git: 'windows-host' }, + alignedLineEndingRepoIds + } +} + +function isWindowsDrivePath(path: string): boolean { + return /^[A-Za-z]:[\\/]/.test(path) +} + +async function windowsGitAvailable( + run: WindowsGitRunner, + cwd: string | undefined +): Promise { + if (!cwd) { + return true + } + try { + await run(['--version'], cwd) + return true + } catch { + return false + } +} + +/** + * Linked worktrees whose recorded link is a Linux path, i.e. WSL Git wrote it. Read from the link + * files themselves: Windows Git may print a Linux path as a drive path under the current drive. + */ +async function wslAuthoredWorktreePaths( + deps: Pick, + repoPath: string +): Promise { + const commonDir = (await deps.runWindowsGit(['rev-parse', '--git-common-dir'], repoPath)).trim() + const links = await deps.readWorktreeLinks(win32.resolve(repoPath, commonDir)) + return links + .filter((link) => isForeignLink(link, false)) + .map((link) => link.replace(/\/\.git$/, '')) +} + +async function readConfig( + run: WindowsGitRunner, + repoPath: string, + scope: string[] +): Promise { + try { + const value = (await run(['config', ...scope, '--get', 'core.autocrlf'], repoPath)).trim() + return value || null + } catch { + // Exit 1 means unset. + return null + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3a93dacf8cb..44d306c8eed 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -54,6 +54,7 @@ import type { PtyApi } from './api/pty-api' import type { ProjectGroupsApi, ProjectsApi, RepositoryApi } from './api/repository-api' import type { RuntimeApi } from './api/runtime-api' import type { WslMoveApi } from './api/wsl-move-bridge' +import type { WslDriveProjectApi } from './api/wsl-drive-project-bridge' import type { KeybindingsApi, SettingsApi } from './api/settings-api' import type { ShellApi } from './api/shell-api' import type { SpeechApi } from './api/speech-api' @@ -161,6 +162,7 @@ export type PreloadApi = { automations: AutomationsApi wsl: RuntimeApi['wsl'] wslMove: WslMoveApi + wslDriveProject: WslDriveProjectApi pwsh: RuntimeApi['pwsh'] gitBash: RuntimeApi['gitBash'] plugins: PluginsApi diff --git a/src/preload/api/wsl-drive-project-bridge.ts b/src/preload/api/wsl-drive-project-bridge.ts new file mode 100644 index 00000000000..dbefb68f3df --- /dev/null +++ b/src/preload/api/wsl-drive-project-bridge.ts @@ -0,0 +1,20 @@ +import { ipcRenderer } from 'electron' +import type { + WslDriveProjectConversionResult, + WslDriveProjectPreview +} from '../../shared/wsl-drive-project-types' + +/** Converting a WSL project on a Windows drive to Windows Git; main gates every call. */ +export type WslDriveProjectApi = { + preview: (projectId: string) => Promise + /** Checks again and prepares the repo; the caller saves the returned runtime preference. */ + convert: (request: { + projectId: string + alignLineEndings: boolean + }) => Promise +} + +export const wslDriveProjectApi: WslDriveProjectApi = { + preview: (projectId) => ipcRenderer.invoke('wslDriveProject:preview', projectId), + convert: (request) => ipcRenderer.invoke('wslDriveProject:convert', request) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 942a5e24c8e..e621375d6ed 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,6 +8,7 @@ import { orcaProfilesApi } from './api/orca-profiles-bridge' import { platformApi } from './api/platform-bridge' import { wslApi } from './api/wsl-bridge' import { wslMoveApi } from './api/wsl-move-bridge' +import { wslDriveProjectApi } from './api/wsl-drive-project-bridge' import { pwshApi } from './api/pwsh-bridge' import { gitBashApi } from './api/git-bash-bridge' import { pluginsApi } from './api/plugins-bridge' @@ -105,6 +106,7 @@ const api = { platform: platformApi, wsl: wslApi, wslMove: wslMoveApi, + wslDriveProject: wslDriveProjectApi, pwsh: pwshApi, gitBash: gitBashApi, plugins: pluginsApi, diff --git a/src/shared/project-execution-runtime.test.ts b/src/shared/project-execution-runtime.test.ts index ca8dc795bd6..9b659248305 100644 --- a/src/shared/project-execution-runtime.test.ts +++ b/src/shared/project-execution-runtime.test.ts @@ -19,6 +19,15 @@ describe('normalizeProjectRuntimePreference', () => { }) }) + it('keeps Windows Git on a WSL preference and drops any other Git value', () => { + expect( + normalizeProjectRuntimePreference({ kind: 'wsl', distro: 'Ubuntu', git: 'windows-host' }) + ).toEqual({ kind: 'wsl', distro: 'Ubuntu', git: 'windows-host' }) + expect( + normalizeProjectRuntimePreference({ kind: 'wsl', distro: 'Ubuntu', git: 'wsl' }) + ).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + }) + it('falls back malformed project runtime preferences to inherit-global', () => { expect(normalizeProjectRuntimePreference(null)).toEqual({ kind: 'inherit-global' }) expect(normalizeProjectRuntimePreference({ kind: 'wsl', distro: ' ' })).toEqual({ @@ -213,6 +222,28 @@ describe('resolveProjectExecutionRuntime', () => { }) }) + it('carries Windows Git into the resolved WSL runtime and its cache key', () => { + expect( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: 'project-1', + projectRuntimePreference: { kind: 'wsl', distro: 'Debian', git: 'windows-host' }, + globalWindowsRuntimeDefault: { kind: 'windows-host' } + }) + ).toEqual({ + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-1', + distro: 'Debian', + reason: 'project-override', + git: 'windows-host', + cacheKey: 'project-1:wsl:Debian:git-windows-host' + } + }) + }) + it('lets a project force WSL when the global default is host', () => { expect( resolveProjectExecutionRuntime({ diff --git a/src/shared/project-execution-runtime.ts b/src/shared/project-execution-runtime.ts index 9a9eade6c49..8cc856a4354 100644 --- a/src/shared/project-execution-runtime.ts +++ b/src/shared/project-execution-runtime.ts @@ -1,9 +1,15 @@ import { parseWslUncPath } from './wsl-paths' +/** + * `git: 'windows-host'` keeps terminals and agents in WSL but runs Orca's own Git on Windows: for a + * project on a Windows drive, WSL Git is ~300x slower and misses Windows-side edits. + */ +export type ProjectGitRuntime = 'windows-host' + export type LocalWindowsRuntimePreference = | { kind: 'inherit-global' } | { kind: 'windows-host' } - | { kind: 'wsl'; distro: string } + | { kind: 'wsl'; distro: string; git?: ProjectGitRuntime } export type GlobalWindowsRuntimeDefault = | { kind: 'windows-host' } @@ -36,6 +42,7 @@ export type ResolvedProjectExecutionRuntime = projectId: string distro: string reason: 'project-override' | 'global-default' + git?: ProjectGitRuntime cacheKey: string } @@ -103,7 +110,12 @@ export function normalizeProjectRuntimePreference(value: unknown): LocalWindowsR if (value.kind === 'wsl') { const distro = normalizeDistro(value.distro) - return distro ? { kind: 'wsl', distro } : { kind: 'inherit-global' } + if (!distro) { + return { kind: 'inherit-global' } + } + return value.git === 'windows-host' + ? { kind: 'wsl', distro, git: 'windows-host' } + : { kind: 'wsl', distro } } return { kind: 'inherit-global' } @@ -178,7 +190,12 @@ export function resolveProjectExecutionRuntime( } if (projectPreference.kind === 'wsl') { - return resolveWslRuntime(args, projectPreference.distro, 'project-override') + return resolveWslRuntime( + args, + projectPreference.distro, + 'project-override', + projectPreference.git + ) } const globalDefault = normalizeGlobalWindowsRuntimeDefault(args.globalWindowsRuntimeDefault) @@ -192,7 +209,8 @@ export function resolveProjectExecutionRuntime( function resolveWslRuntime( args: ResolveProjectExecutionRuntimeArgs, distro: string | null, - source: RuntimeSource + source: RuntimeSource, + git?: ProjectGitRuntime ): ProjectExecutionRuntimeResolution { if (!distro) { return { @@ -233,7 +251,8 @@ function resolveWslRuntime( projectId: args.projectId, distro: resolvedDistro, reason: source, - cacheKey: `${args.projectId}:wsl:${resolvedDistro}` + ...(git ? { git } : {}), + cacheKey: `${args.projectId}:wsl:${resolvedDistro}${git ? `:git-${git}` : ''}` } } } diff --git a/src/shared/rpc-contract/project-runtime-params.ts b/src/shared/rpc-contract/project-runtime-params.ts index 425a299fe19..895dfb04e3c 100644 --- a/src/shared/rpc-contract/project-runtime-params.ts +++ b/src/shared/rpc-contract/project-runtime-params.ts @@ -47,7 +47,11 @@ export const ProjectHostSetupClone = z.object({ export const LocalWindowsRuntimePreference = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('inherit-global') }), z.object({ kind: z.literal('windows-host') }), - z.object({ kind: z.literal('wsl'), distro: requiredString('Missing WSL distro') }) + z.object({ + kind: z.literal('wsl'), + distro: requiredString('Missing WSL distro'), + git: z.literal('windows-host').optional() + }) ]) export const ProjectUpdate = z.object({ diff --git a/src/shared/wsl-drive-project-types.ts b/src/shared/wsl-drive-project-types.ts new file mode 100644 index 00000000000..878d8c6e422 --- /dev/null +++ b/src/shared/wsl-drive-project-types.ts @@ -0,0 +1,43 @@ +/** A WSL-runtime project on a Windows drive, converted to Windows Git (D1c); desktop-only IPC. */ +import type { LocalWindowsRuntimePreference } from './project-execution-runtime' + +export type WslDriveProjectBlocker = + /** Its runtime isn't WSL (or already uses Windows Git), so there is nothing to convert. */ + | { kind: 'not-wsl-runtime' } + /** WSL is missing or the distro is gone; the runtime must be repaired first. */ + | { kind: 'runtime-needs-repair' } + /** A folder of the project isn't on a Windows drive (inside a distro, or on another host). */ + | { kind: 'not-windows-drive'; repoId: string } + | { kind: 'windows-git-missing' } + /** + * WSL Git made these worktrees, so their links are Linux paths. Windows Git lists them as + * prunable, and the `git worktree prune` after any removal would delete their metadata. + */ + | { kind: 'wsl-authored-worktrees'; repoId: string; paths: string[] } + /** New worktrees would be created inside the distro, where Windows Git is slow. */ + | { kind: 'worktree-folder-in-distro'; repoId: string } + +export type WslDriveProjectRepoPreview = { + repoId: string + path: string + /** Windows Git's effective `core.autocrlf`; WSL Git needs the same value to see a clean tree. */ + windowsAutocrlf: string | null + /** Set in the repo itself, so WSL Git already reads it. */ + localAutocrlf: string | null +} + +export type WslDriveProjectPreview = { + projectId: string + distro: string | null + repos: WslDriveProjectRepoPreview[] + blockers: WslDriveProjectBlocker[] +} + +export type WslDriveProjectConversionResult = + | { + ok: true + /** Saved through the normal project update, so the window and the move fence see it. */ + preference: LocalWindowsRuntimePreference + alignedLineEndingRepoIds: string[] + } + | { ok: false; blockers: WslDriveProjectBlocker[] }