mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
feat(wsl): run Git for Windows for a WSL project on a Windows drive (D1c-2) (#33090)
* feat(wsl): run Git for Windows for a WSL project on a Windows drive A project on C:\ whose runtime is WSL ran every Orca Git command through wsl.exe on /mnt/c: ~300x slower than Windows Git, and blind to Windows-side edits. The project's WSL runtime preference gains an optional `git: 'windows-host'`. With it, Orca's Git (and where new worktrees go) is Windows; terminals, agents, accounts and hooks still resolve to WSL, so nothing else changes. Older builds drop the field and go back to WSL Git. The conversion checks first, then the caller saves the returned preference through the normal project update: - Git for Windows must exist. - New worktrees must not be set to go inside the distro. - No linked worktree may have a Linux-path link. WSL Git made those; Windows Git sees them as prunable, and Orca's prune after a worktree removal would delete their metadata. - Optionally writes core.autocrlf=true into the repo when Windows Git uses it, so WSL Git in the project's terminals stops seeing CRLF changes. Never core.checkStat. IPC is refused off Windows and with the internal WSL hosts gate closed. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl): mock the drive-project IPC in the core handler registration test Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl): pin WSL Git for a drive project that never chose Windows Git, and record which Git ran * fix(wsl): base-ref lookups and the remote-identity sweep run Git where the project's runtime does * fix(wsl): route base-ref and remote-identity reads by runtime only with WSL hosts on These reads ran host Git on main. Routing them into a WSL-runtime project's distro is part of D1c, so with the WSL hosts gate closed they run host Git exactly as before. (cherry picked from commit 2ed82987dc5b82c3a86ea7e03ce05e9efc3e77fb) * refactor(ipc): register the filesystem handlers in one call, keeping core registration under its line cap The optional commit-message resolvers pass straight through, so the call is the same with or without them. #34059 left this file at the 300-line cap and the WSL drive-project wiring adds to it. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl): skip folder repos in the Windows Git check, and read worktree links with the prune guard's reader Folder repos run no Git, so rev-parse on them always failed the preview. The link reader and the WSL-authored test now come from worktree-prune-guard.ts; the copy's startsWith('/') wrongly refused //wsl.localhost links, which Windows Git writes itself. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl): the remote-identity sweep checks the distro a drive project's Git runs in, so it never boots a stopped one --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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<void> | null = null
|
||||
try {
|
||||
|
||||
@@ -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<BaseRefSearchResult[]> {
|
||||
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,
|
||||
|
||||
@@ -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<number> {
|
||||
export async function getRemoteCount(
|
||||
path: string,
|
||||
options: LocalGitExecOptions = {}
|
||||
): Promise<number> {
|
||||
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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, Handler>())
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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<string[]> {
|
||||
return (await readWorktreeAdminLinks(join(commonDir, 'worktrees'))).map(({ link }) => link)
|
||||
}
|
||||
@@ -74,6 +74,16 @@ function resolveLocalProjectRuntime(
|
||||
})
|
||||
}
|
||||
|
||||
/** One local project's runtime, from the current WSL discovery caches. */
|
||||
export function resolveLocalProjectRuntimeForProject(
|
||||
store: ProjectRuntimeResolutionStore,
|
||||
project: Pick<Project, 'id' | 'localWindowsRuntimePreference'>
|
||||
): ProjectExecutionRuntimeResolution | undefined {
|
||||
return canResolveProjectRuntimeForRepo(store)
|
||||
? resolveLocalProjectRuntime(store, project)
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function resolveLocalProjectRuntimeForRepo(
|
||||
store: ProjectRuntimeResolutionStore,
|
||||
repo: Repo
|
||||
|
||||
@@ -49,10 +49,14 @@ function makeProject(overrides: Partial<Project> = {}): 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' } })
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<typeof DistroGate>()),
|
||||
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<void> {
|
||||
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' }))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Repo>
|
||||
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: []
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string>
|
||||
|
||||
export type WslDriveProjectDeps = {
|
||||
getProjects: () => readonly Project[]
|
||||
getRepos: () => readonly Repo[]
|
||||
resolveRuntime: (project: Project) => ProjectExecutionRuntimeResolution
|
||||
runWindowsGit: WindowsGitRunner
|
||||
/** The `gitdir` link of each linked worktree under `<commonDir>/worktrees`. */
|
||||
readWorktreeLinks: (commonDir: string) => Promise<string[]>
|
||||
}
|
||||
|
||||
export async function previewWslDriveProjectWindowsGit(
|
||||
deps: WslDriveProjectDeps,
|
||||
projectId: string
|
||||
): Promise<WslDriveProjectPreview> {
|
||||
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<WslDriveProjectConversionResult> {
|
||||
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<boolean> {
|
||||
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<WslDriveProjectDeps, 'runWindowsGit' | 'readWorktreeLinks'>,
|
||||
repoPath: string
|
||||
): Promise<string[]> {
|
||||
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<string | null> {
|
||||
try {
|
||||
const value = (await run(['config', ...scope, '--get', 'core.autocrlf'], repoPath)).trim()
|
||||
return value || null
|
||||
} catch {
|
||||
// Exit 1 means unset.
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<WslDriveProjectPreview>
|
||||
/** Checks again and prepares the repo; the caller saves the returned runtime preference. */
|
||||
convert: (request: {
|
||||
projectId: string
|
||||
alignLineEndings: boolean
|
||||
}) => Promise<WslDriveProjectConversionResult>
|
||||
}
|
||||
|
||||
export const wslDriveProjectApi: WslDriveProjectApi = {
|
||||
preview: (projectId) => ipcRenderer.invoke('wslDriveProject:preview', projectId),
|
||||
convert: (request) => ipcRenderer.invoke('wslDriveProject:convert', request)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}` : ''}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[] }
|
||||
Reference in New Issue
Block a user