diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 49114e38c3c..65180036740 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -74,7 +74,10 @@ vi.mock('../wsl', () => ({ toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), toWindowsWslPath: (path: string, distro: string) => `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`, - isWslAvailable: () => true + isWslAvailable: () => true, + // Why: WSL worktree validation now asks the distro; these tests use WSL UNC + // cwds that are meant to exist, so report them present without spawning wsl.exe. + wslUncDirectoryExists: () => true })) import { LocalPtyProvider } from './local-pty-provider' diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts new file mode 100644 index 00000000000..92379ec691d --- /dev/null +++ b/src/main/providers/local-pty-utils.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as fs from 'fs' +import type { Stats } from 'fs' + +const { existsSyncMock, statSyncMock, wslUncDirectoryExistsMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + statSyncMock: vi.fn(), + wslUncDirectoryExistsMock: vi.fn() +})) + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + existsSync: existsSyncMock, + statSync: statSyncMock + } +}) + +function dirStats(isDirectory: boolean): Stats { + return { isDirectory: () => isDirectory } as Stats +} + +vi.mock('../wsl', () => ({ + wslUncDirectoryExists: wslUncDirectoryExistsMock +})) + +import { validateWorkingDirectory } from './local-pty-utils' + +const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' +const NATIVE_DIR = 'C:\\Users\\jin\\repo' + +describe('validateWorkingDirectory', () => { + beforeEach(() => { + existsSyncMock.mockReset() + statSyncMock.mockReset() + wslUncDirectoryExistsMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('accepts a WSL UNC worktree that exists inside the distro even when fs.statSync would fail', () => { + // Why: the Win32 9P stat is the exact path that falsely reported ENOENT and + // broke opening WSL worktrees. The distro answer must win. + wslUncDirectoryExistsMock.mockReturnValue(true) + existsSyncMock.mockReturnValue(false) + + expect(() => validateWorkingDirectory(WSL_UNC_DIR)).not.toThrow() + expect(wslUncDirectoryExistsMock).toHaveBeenCalledWith(WSL_UNC_DIR) + // The fs fallback must not run when the distro confirmed existence. + expect(existsSyncMock).not.toHaveBeenCalled() + }) + + it('rejects a WSL UNC worktree that does not exist inside the distro', () => { + wslUncDirectoryExistsMock.mockReturnValue(false) + + expect(() => validateWorkingDirectory(WSL_UNC_DIR)).toThrow(/does not exist/) + expect(existsSyncMock).not.toHaveBeenCalled() + }) + + it('falls back to the fs check when the distro answer is inconclusive', () => { + wslUncDirectoryExistsMock.mockReturnValue(null) + existsSyncMock.mockReturnValue(true) + statSyncMock.mockReturnValue(dirStats(true)) + + expect(() => validateWorkingDirectory(WSL_UNC_DIR)).not.toThrow() + expect(wslUncDirectoryExistsMock).toHaveBeenCalledWith(WSL_UNC_DIR) + expect(existsSyncMock).toHaveBeenCalledWith(WSL_UNC_DIR) + }) + + it('validates native Windows paths via fs without consulting the distro', () => { + existsSyncMock.mockReturnValue(true) + statSyncMock.mockReturnValue(dirStats(true)) + + expect(() => validateWorkingDirectory(NATIVE_DIR)).not.toThrow() + expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + expect(existsSyncMock).toHaveBeenCalledWith(NATIVE_DIR) + }) + + it('rejects a missing native Windows path', () => { + existsSyncMock.mockReturnValue(false) + + expect(() => validateWorkingDirectory(NATIVE_DIR)).toThrow(/does not exist/) + expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + }) + + it('rejects a native Windows path that exists but is not a directory', () => { + existsSyncMock.mockReturnValue(true) + statSyncMock.mockReturnValue(dirStats(false)) + + expect(() => validateWorkingDirectory(NATIVE_DIR)).toThrow(/is not a directory/) + }) +}) diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 5e7bac78153..ea92782fa2f 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,6 +1,8 @@ import { basename, join } from 'path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'fs' import type * as pty from 'node-pty' +import { isWslUncPath } from '../../shared/wsl-paths' +import { wslUncDirectoryExists } from '../wsl' let didEnsureSpawnHelperExecutable = false @@ -75,16 +77,33 @@ export function ensureNodePtySpawnHelperExecutable(): void { } } +function throwMissingWorkingDirectory(cwd: string): never { + throw new Error( + `Working directory "${cwd}" does not exist. ` + + `It may have been deleted or is on an unmounted volume.` + ) +} + /** * Validate that a working directory exists and is a directory. * Throws a descriptive Error if not. */ export function validateWorkingDirectory(cwd: string): void { + // Why: Win32 fs.statSync against the WSL 9P share (\\wsl.localhost\...) can + // falsely report ENOENT for directories that exist on the Linux side. Ask the + // distro itself; only fall back to the fs check when wsl.exe is inconclusive. + if (isWslUncPath(cwd)) { + const existsInDistro = wslUncDirectoryExists(cwd) + if (existsInDistro === false) { + throwMissingWorkingDirectory(cwd) + } + if (existsInDistro === true) { + return + } + } + if (!existsSync(cwd)) { - throw new Error( - `Working directory "${cwd}" does not exist. ` + - `It may have been deleted or is on an unmounted volume.` - ) + throwMissingWorkingDirectory(cwd) } if (!statSync(cwd).isDirectory()) { throw new Error(`Working directory "${cwd}" is not a directory.`) diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index 8ea7cde752a..c141e3f050c 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -1,5 +1,29 @@ -import { describe, expect, it } from 'vitest' -import { toLinuxPath, toWindowsWslPath, parseWslPath } from './wsl' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as childProcess from 'child_process' + +const { execFileSyncMock } = vi.hoisted(() => ({ + execFileSyncMock: vi.fn() +})) + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + execFileSync: execFileSyncMock + } +}) + +import { toLinuxPath, toWindowsWslPath, parseWslPath, wslUncDirectoryExists } from './wsl' + +function withPlatform(value: NodeJS.Platform, fn: () => T): T { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value }) + try { + return fn() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} describe('wsl path helpers', () => { it('parses WSL UNC paths on Windows', () => { @@ -32,3 +56,56 @@ describe('wsl path helpers', () => { ) }) }) + +describe('wslUncDirectoryExists', () => { + afterEach(() => { + execFileSyncMock.mockReset() + }) + + it('returns true when the distro reports the directory exists', () => { + execFileSyncMock.mockReturnValue('') + const result = withPlatform('win32', () => + wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + ) + expect(result).toBe(true) + expect(execFileSyncMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'test', '-d', '/home/jin/repo'], + expect.objectContaining({ timeout: 5000 }) + ) + }) + + it('returns false when test -d exits non-zero (directory missing)', () => { + execFileSyncMock.mockImplementation(() => { + // Why: child_process surfaces a non-zero exit as an Error with `status`. + const error = new Error('Command failed') as Error & { status: number } + error.status = 1 + throw error + }) + const result = withPlatform('win32', () => + wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') + ) + expect(result).toBe(false) + }) + + it('returns null when wsl.exe is unavailable (inconclusive)', () => { + execFileSyncMock.mockImplementation(() => { + // No numeric `status` -> spawn failure (ENOENT), not a missing directory. + const error = new Error('spawn wsl.exe ENOENT') as Error & { code: string } + error.code = 'ENOENT' + throw error + }) + const result = withPlatform('win32', () => + wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + ) + expect(result).toBeNull() + }) + + it('returns null for non-WSL paths and off Windows', () => { + expect(withPlatform('win32', () => wslUncDirectoryExists('C:\\Users\\jin\\repo'))).toBeNull() + expect( + withPlatform('linux', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin')) + ).toBeNull() + expect(execFileSyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl.ts b/src/main/wsl.ts index b6c82a96c93..cf83aa46224 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -27,6 +27,43 @@ export function isWslPath(path: string): boolean { return parseWslPath(path) !== null } +/** + * Check whether a WSL UNC working directory exists by testing it inside the + * distro itself, returning null when the answer can't be determined. + * + * Why: Win32 fs.statSync against the WSL 9P filesystem (\\wsl.localhost\...) + * is unreliable for repos that live on the WSL side — it can report ENOENT for + * directories that exist, which made opening a WSL worktree fail with + * "Working directory ... does not exist". `wsl.exe -d test -d` asks + * the distro directly, which is the authoritative answer. Returns null (rather + * than false) when wsl.exe is unavailable or errors so callers can fall back to + * the fs check instead of falsely rejecting a valid directory. + */ +export function wslUncDirectoryExists(uncPath: string): boolean | null { + if (process.platform !== 'win32') { + return null + } + const info = parseWslUncPath(uncPath) + if (!info) { + return null + } + try { + execFileSync('wsl.exe', ['-d', info.distro, '--', 'test', '-d', info.linuxPath], { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000 + }) + return true + } catch (error) { + // A non-zero exit (directory missing) surfaces as an error with a numeric + // `status`; treat that as a definitive "does not exist". Any other failure + // (wsl.exe missing, distro not running, timeout) is inconclusive -> null. + if (typeof (error as { status?: unknown })?.status === 'number') { + return false + } + return null + } +} + /** * Convert a Windows path to a Linux path for commands that will execute inside WSL. * Returns the path unchanged if it is already POSIX-style.