From 71f030fcd6ee5961a45a1a81996bf24dd241b6d7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:32:19 -0700 Subject: [PATCH] feat(wsl): default a new Windows-drive project under a WSL default to Git for Windows (D1c-5) (#33122) * feat(wsl): default a new Windows-drive project under a WSL default to Git for Windows * fix(wsl): judge a new project's worktree links with the prune guard's test, so a //wsl.localhost link counts as Windows Git's Co-Authored-By: Claude --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: Claude --- .../repo-lifecycle-operations.ts | 5 + .../wsl/wsl-drive-project-new-default.test.ts | 133 ++++++++++++++++++ src/main/wsl/wsl-drive-project-new-default.ts | 108 ++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 src/main/wsl/wsl-drive-project-new-default.test.ts create mode 100644 src/main/wsl/wsl-drive-project-new-default.ts diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 319a64f61f3..c95aa92ba74 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -32,6 +32,7 @@ import { pruneDeregisteredRepoUiResidue } from './repo-lifecycle-ui-residue' import { isWslMoveProjectSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard' import { dropOrcadTerminalRestoreHintsForWorktrees } from '../migrating-orcad-catalog/orcad-terminal-restore-hint-store' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { applyNewWindowsDriveProjectDefault } from '../../wsl/wsl-drive-project-new-default' // A paused WSL move keeps its local rows for a downgrade. function removeLocalRepoSession( @@ -68,6 +69,10 @@ export class RepoLifecycleOperations { addRepo(repo: Repo): void { bumpLocalWorktreeScanGeneration(repo.id) getRepoOrderOperations(this).addRepo(repo) + const context = this[repoLifecycleOperationsContext] + if (applyNewWindowsDriveProjectDefault(context.runtime.state, repo)) { + scheduleSave(context.scheduling) + } } reorderRepos(orderedIds: string[]): boolean { diff --git a/src/main/wsl/wsl-drive-project-new-default.test.ts b/src/main/wsl/wsl-drive-project-new-default.test.ts new file mode 100644 index 00000000000..891aa6c0548 --- /dev/null +++ b/src/main/wsl/wsl-drive-project-new-default.test.ts @@ -0,0 +1,133 @@ +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 type { LocalWindowsRuntimePreference } from '../../shared/project-execution-runtime' +import { + applyNewWindowsDriveProjectDefault, + newWindowsDriveProjectRuntimePreference, + readMainCheckoutWorktreeLinks, + type NewWindowsDriveProjectDefaultArgs +} from './wsl-drive-project-new-default' + +const gate = vi.hoisted(() => ({ open: true })) +vi.mock('./wsl-host-gate', () => ({ wslHostsGateOpen: () => gate.open })) + +const WSL_DEFAULT = { kind: 'wsl', distro: 'Ubuntu' } + +function args(overrides: Partial = {}) { + return { + platform: 'win32', + repo: { path: 'C:\\src\\app', kind: 'git' as const }, + globalWindowsRuntimeDefault: WSL_DEFAULT, + readWorktreeLinks: () => [], + ...overrides + } +} + +describe('newWindowsDriveProjectRuntimePreference', () => { + it('pins the global distro with Git for Windows for a drive project', () => { + expect(newWindowsDriveProjectRuntimePreference(args())).toEqual({ + kind: 'wsl', + distro: 'Ubuntu', + git: 'windows-host' + }) + }) + + it.each([ + ['off Windows', { platform: 'darwin' }], + ['under a Windows default', { globalWindowsRuntimeDefault: { kind: 'windows-host' } }], + ['with no default distro', { globalWindowsRuntimeDefault: { kind: 'wsl', distro: null } }], + ['inside a distro', { repo: { path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\app' } }], + ['for a folder project', { repo: { path: 'C:\\src\\app', kind: 'folder' as const } }], + ['on another host', { repo: { path: 'C:\\src\\app', executionHostId: 'ssh:box' as const } }], + ['with WSL-authored worktrees', { readWorktreeLinks: () => ['/mnt/c/src/feature/.git'] }], + ['when its worktree links are unreadable', { readWorktreeLinks: () => null }] + ])('leaves the project inheriting %s', (_name, overrides) => { + expect(newWindowsDriveProjectRuntimePreference(args(overrides))).toBeNull() + }) + + it('accepts worktrees Windows Git made', () => { + expect( + newWindowsDriveProjectRuntimePreference( + args({ + readWorktreeLinks: () => [ + 'C:/src/feature/.git', + '//wsl.localhost/Ubuntu/home/me/fix/.git' + ] + }) + ) + ).not.toBeNull() + }) +}) + +describe('applyNewWindowsDriveProjectDefault', () => { + beforeEach(() => { + gate.open = true + }) + + function state(localWindowsRuntimePreference?: LocalWindowsRuntimePreference) { + return { + projects: [{ sourceRepoIds: ['r1'], localWindowsRuntimePreference }], + settings: { + localWindowsRuntimeDefault: { kind: 'wsl' as const, distro: 'Ubuntu' } + } + } + } + const repo = { id: 'r1', path: 'C:\\src\\app', kind: 'git' as const } + const env = { platform: 'win32', readWorktreeLinks: () => [] } + + it("writes the default onto the repo's new project", () => { + const current = state() + expect(applyNewWindowsDriveProjectDefault(current, repo, env)).toBe(true) + expect(current.projects[0].localWindowsRuntimePreference).toEqual({ + kind: 'wsl', + distro: 'Ubuntu', + git: 'windows-host' + }) + }) + + it('keeps a preference the project already has', () => { + const current = state({ kind: 'windows-host' }) + expect(applyNewWindowsDriveProjectDefault(current, repo, env)).toBe(false) + expect(current.projects[0].localWindowsRuntimePreference).toEqual({ + kind: 'windows-host' + }) + }) + + it('does nothing while the WSL hosts gate is closed', () => { + gate.open = false + const current = state() + expect(applyNewWindowsDriveProjectDefault(current, repo, env)).toBe(false) + expect(current.projects[0].localWindowsRuntimePreference).toBeUndefined() + }) +}) + +describe('readMainCheckoutWorktreeLinks', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-drive-default-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it("reads each linked worktree's gitdir link", () => { + mkdirSync(join(dir, '.git', 'worktrees', 'feature'), { recursive: true }) + writeFileSync(join(dir, '.git', 'worktrees', 'feature', 'gitdir'), '/mnt/c/src/feature/.git\n') + expect(readMainCheckoutWorktreeLinks(dir)).toEqual(['/mnt/c/src/feature/.git']) + }) + + it('reads a checkout with no linked worktrees as none', () => { + mkdirSync(join(dir, '.git')) + expect(readMainCheckoutWorktreeLinks(dir)).toEqual([]) + }) + + it('reads a linked worktree or missing checkout as unknown', () => { + expect(readMainCheckoutWorktreeLinks(dir)).toBeNull() + writeFileSync(join(dir, '.git'), 'gitdir: C:/src/app/.git/worktrees/x\n') + expect(readMainCheckoutWorktreeLinks(dir)).toBeNull() + }) +}) diff --git a/src/main/wsl/wsl-drive-project-new-default.ts b/src/main/wsl/wsl-drive-project-new-default.ts new file mode 100644 index 00000000000..ce46f122599 --- /dev/null +++ b/src/main/wsl/wsl-drive-project-new-default.ts @@ -0,0 +1,108 @@ +/** + * A brand-new project on a Windows drive (`C:\…`) added while the global default runtime is WSL + * gets Git for Windows, keeping its terminals and agents in WSL: WSL Git there is ~300x slower and + * misses Windows-side edits. Existing projects convert through wsl-drive-project-windows-git.ts. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { + normalizeGlobalWindowsRuntimeDefault, + type LocalWindowsRuntimePreference +} from '../../shared/project-execution-runtime' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Project } from '../../shared/project-types' +import { isFolderRepo } from '../../shared/repo-kind' +import type { Repo } from '../../shared/repo-types' +import { isForeignLink } from '../git/worktree-prune-guard' +import { wslHostsGateOpen } from './wsl-host-gate' + +export type NewWindowsDriveProjectDefaultArgs = { + platform: string + repo: Pick + globalWindowsRuntimeDefault: unknown + /** `gitdir` links of the repo's linked worktrees; null when they can't be read. */ + readWorktreeLinks: (repoPath: string) => string[] | null +} + +export function newWindowsDriveProjectRuntimePreference( + args: NewWindowsDriveProjectDefaultArgs +): LocalWindowsRuntimePreference | null { + const { repo } = args + if ( + args.platform !== 'win32' || + getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID || + isFolderRepo(repo) || + !/^[A-Za-z]:[\\/]/.test(repo.path) + ) { + return null + } + const globalDefault = normalizeGlobalWindowsRuntimeDefault(args.globalWindowsRuntimeDefault) + if (globalDefault.kind !== 'wsl' || !globalDefault.distro) { + return null + } + // WSL Git wrote Linux-path links that Windows Git can't follow; the preflight refuses these too. + const links = args.readWorktreeLinks(repo.path) + if (!links || links.some((link) => isForeignLink(link, false))) { + return null + } + // Pinned like a converted project, so a later global change can't silently switch its Git. + return { kind: 'wsl', distro: globalDefault.distro, git: 'windows-host' } +} + +/** Sets the default on the project `addRepo` just made for `repo`; true when it wrote. */ +export function applyNewWindowsDriveProjectDefault( + state: { + projects: Pick[] + settings: Pick + }, + repo: NewWindowsDriveProjectDefaultArgs['repo'] & Pick, + env: Pick = { + platform: process.platform, + readWorktreeLinks: readMainCheckoutWorktreeLinks + } +): boolean { + if (!wslHostsGateOpen()) { + return false + } + const project = state.projects.find((entry) => entry.sourceRepoIds.includes(repo.id)) + if (!project || project.localWindowsRuntimePreference) { + return false + } + const preference = newWindowsDriveProjectRuntimePreference({ + ...env, + repo, + globalWindowsRuntimeDefault: state.settings.localWindowsRuntimeDefault + }) + if (!preference) { + return false + } + project.localWindowsRuntimePreference = preference + return true +} + +/** Only a main checkout's `.git` folder; a linked worktree or submodule reads as unknown. */ +export function readMainCheckoutWorktreeLinks(repoPath: string): string[] | null { + try { + const gitDir = join(repoPath, '.git') + if (!statSync(gitDir).isDirectory()) { + return null + } + const root = join(gitDir, 'worktrees') + let names: string[] + try { + names = readdirSync(root) + } catch { + return [] + } + return names.flatMap((name) => { + try { + return [readFileSync(join(root, name, 'gitdir'), 'utf8').trim()] + } catch { + return [] + } + }) + } catch { + return null + } +}