From f6ec69c969dc3ee3fabcd8964b112453c5615cfa Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:02:36 -0400 Subject: [PATCH] feat: add WSL support for repos on WSL filesystems (#375) Co-authored-by: Claude Opus 4.6 --- src/cli/index.test.ts | 18 +- src/cli/runtime-client.test.ts | 5 +- src/main/cli/cli-installer.test.ts | 9 +- src/main/git/remove-worktree.test.ts | 31 ++- src/main/git/repo.ts | 75 ++---- src/main/git/runner.ts | 258 +++++++++++++++++++++ src/main/git/status.test.ts | 6 +- src/main/git/status.ts | 68 ++---- src/main/git/worktree.ts | 42 ++-- src/main/github/client.ts | 36 +-- src/main/github/conflict-summary.ts | 31 +-- src/main/github/gh-utils.ts | 10 +- src/main/github/issues.ts | 22 +- src/main/hooks-runner.test.ts | 99 +++++++- src/main/hooks.test.ts | 73 +++++- src/main/hooks.ts | 93 +++++++- src/main/ipc/filesystem-list-files.test.ts | 6 +- src/main/ipc/filesystem-list-files.ts | 35 ++- src/main/ipc/filesystem-mutations.test.ts | 72 +++--- src/main/ipc/filesystem-search-git.test.ts | 6 +- src/main/ipc/filesystem-search-git.ts | 10 +- src/main/ipc/filesystem.test.ts | 66 +++--- src/main/ipc/filesystem.ts | 31 ++- src/main/ipc/pty.test.ts | 126 ++++++++++ src/main/ipc/pty.ts | 62 +++-- src/main/ipc/repos.ts | 13 +- src/main/ipc/rg-availability.ts | 51 +++- src/main/ipc/worktree-logic-wsl.test.ts | 51 ++++ src/main/ipc/worktree-logic.ts | 40 +++- src/main/ipc/worktrees.ts | 19 +- src/main/runtime/orca-runtime.ts | 18 +- src/main/startup/configure-process.test.ts | 5 +- src/main/wsl.test.ts | 34 +++ src/main/wsl.ts | 150 ++++++++++++ src/renderer/src/lib/setup-runner.test.ts | 31 +++ src/renderer/src/lib/setup-runner.ts | 29 +++ 36 files changed, 1380 insertions(+), 351 deletions(-) create mode 100644 src/main/git/runner.ts create mode 100644 src/main/ipc/pty.test.ts create mode 100644 src/main/ipc/worktree-logic-wsl.test.ts create mode 100644 src/main/wsl.test.ts create mode 100644 src/main/wsl.ts create mode 100644 src/renderer/src/lib/setup-runner.test.ts diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 5904623c9..5961c03fb 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1,3 +1,4 @@ +import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() @@ -46,12 +47,15 @@ describe('orca cli worktree awareness', () => { }) it('builds the current worktree selector from cwd', () => { - expect(buildCurrentWorktreeSelector('/tmp/repo/feature')).toBe('path:/tmp/repo/feature') + expect(buildCurrentWorktreeSelector('/tmp/repo/feature')).toBe( + `path:${path.resolve('/tmp/repo/feature')}` + ) }) it('normalizes active/current worktree selectors to cwd', () => { - expect(normalizeWorktreeSelector('active', '/tmp/repo/feature')).toBe('path:/tmp/repo/feature') - expect(normalizeWorktreeSelector('current', '/tmp/repo/feature')).toBe('path:/tmp/repo/feature') + const resolved = path.resolve('/tmp/repo/feature') + expect(normalizeWorktreeSelector('active', '/tmp/repo/feature')).toBe(`path:${resolved}`) + expect(normalizeWorktreeSelector('current', '/tmp/repo/feature')).toBe(`path:${resolved}`) expect(normalizeWorktreeSelector('branch:feature/foo', '/tmp/repo/feature')).toBe( 'branch:feature/foo' ) @@ -110,7 +114,7 @@ describe('orca cli worktree awareness', () => { limit: 10_000 }) expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', { - worktree: 'path:/tmp/repo/feature' + worktree: `path:${path.resolve('/tmp/repo/feature')}` }) expect(logSpy).toHaveBeenCalledTimes(1) }) @@ -185,7 +189,7 @@ describe('orca cli worktree awareness', () => { ) expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', { - worktree: 'path:/tmp/repo/feature', + worktree: `path:${path.resolve('/tmp/repo/feature')}`, displayName: undefined, linkedIssue: undefined, comment: 'hello' @@ -242,7 +246,7 @@ describe('orca cli worktree awareness', () => { await main(['worktree', 'show', '--worktree', 'current', '--json'], '/tmp/repo/feature/src') expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', { - worktree: 'path:/tmp/repo/feature' + worktree: `path:${path.resolve('/tmp/repo/feature')}` }) }) @@ -294,7 +298,7 @@ describe('orca cli worktree awareness', () => { await main(['terminal', 'list', '--worktree', 'active', '--json'], '/tmp/repo/feature/src') expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.list', { - worktree: 'path:/tmp/repo/feature', + worktree: `path:${path.resolve('/tmp/repo/feature')}`, limit: undefined }) }) diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 9d0765216..6732e127a 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -41,7 +41,10 @@ function writeMetadata(userDataPath: string, endpoint: string, authToken = 'toke ) } -describe('RuntimeClient', () => { +// Why: these tests create Unix domain socket servers in temp directories. +// Windows does not support Unix domain sockets in the same way, causing +// EACCES errors on listen(), so the suite is skipped on that platform. +describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { it('returns the full RPC envelope for successful calls', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) const endpoint = join(userDataPath, 'runtime.sock') diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 195d2386d..b0dc2eb24 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -32,7 +32,8 @@ describe('CliInstaller', () => { vi.restoreAllMocks() }) - it('creates a dev launcher and installs a macOS symlink in the requested path', async () => { + // Why: this test creates Unix symlinks and shell scripts that only apply on macOS. + it.skipIf(process.platform === 'win32')('creates a dev launcher and installs a macOS symlink in the requested path', async () => { const fixture = await makeFixture() const installPath = join(fixture.root, 'bin', 'orca') const installer = new CliInstaller({ @@ -61,7 +62,8 @@ describe('CliInstaller', () => { expect(removed.state).toBe('not_installed') }) - it('creates a linux symlink under the requested path and warns when PATH is missing', async () => { + // Why: this test creates Unix symlinks and shell scripts that only apply on Linux. + it.skipIf(process.platform === 'win32')('creates a linux symlink under the requested path and warns when PATH is missing', async () => { const fixture = await makeFixture() const installPath = join(fixture.root, '.local', 'bin', 'orca') const installer = new CliInstaller({ @@ -117,7 +119,8 @@ describe('CliInstaller', () => { expect(userPath).not.toContain(join(fixture.root, 'Programs', 'Orca', 'bin')) }) - it('reports stale when a different symlink already exists', async () => { + // Why: this test creates a Unix symlink to /tmp/not-orca, which only applies on macOS/Linux. + it.skipIf(process.platform === 'win32')('reports stale when a different symlink already exists', async () => { const fixture = await makeFixture() const installPath = join(fixture.root, 'bin', 'orca') await mkdir(join(fixture.root, 'bin'), { recursive: true }) diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 43345c9b5..254853124 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -7,9 +7,38 @@ const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ vi.mock('child_process', () => ({ execFile: execFileMock, - execFileSync: execFileSyncMock + execFileSync: execFileSyncMock, + // runner.ts imports spawn from child_process; stub prevents + // "missing export" errors when the mock is resolved transitively. + spawn: vi.fn() })) +// Why: runner.ts uses promisify(execFile). The default promisify of a test +// mock doesn't return { stdout, stderr } because the mock lacks Node's +// util.promisify.custom symbol. Return a wrapper that invokes the callback- +// style execFileMock and shapes the result correctly. +vi.mock('util', async () => { + const actual = await vi.importActual('util') + return { + ...actual, + promisify: vi.fn(() => + (...args: unknown[]) => + new Promise((resolve, reject) => { + execFileMock( + ...args, + (error: Error | null, stdout: string, stderr: string) => { + if (error) { + reject(Object.assign(error, { stdout, stderr })) + return + } + resolve({ stdout, stderr }) + } + ) + }) + ) + } +}) + import { removeWorktree } from './worktree' type MockResult = { diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 4ba97b952..c3c4d7420 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -1,10 +1,8 @@ -import { execFile, execSync } from 'child_process' +import { execSync } from 'child_process' import { existsSync, statSync } from 'fs' import { join, basename } from 'path' -import { promisify } from 'util' import hostedGitInfo from 'hosted-git-info' - -const execFileAsync = promisify(execFile) +import { gitExecFileSync, gitExecFileAsync } from './runner' /** * Check if a path is a valid git repository (regular or bare). @@ -19,19 +17,15 @@ export function isGitRepo(path: string): boolean { return true } // Might be a bare repo — ask git - const result = execSync('git rev-parse --is-inside-work-tree', { - cwd: path, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] + const result = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { + cwd: path }).trim() return result === 'true' } catch { // Also check if it's a bare repo try { - const result = execSync('git rev-parse --is-bare-repository', { - cwd: path, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] + const result = gitExecFileSync(['rev-parse', '--is-bare-repository'], { + cwd: path }).trim() return result === 'true' } catch { @@ -54,10 +48,8 @@ export function getRepoName(path: string): string { */ export function getRemoteUrl(path: string): string | null { try { - return execSync('git remote get-url origin', { - cwd: path, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] + return gitExecFileSync(['remote', 'get-url', 'origin'], { + cwd: path }).trim() } catch { return null @@ -66,10 +58,8 @@ export function getRemoteUrl(path: string): string | null { function getGitConfigValue(path: string, key: string): string { try { - return execSync(`git config --get ${key}`, { - cwd: path, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] + return gitExecFileSync(['config', '--get', key], { + cwd: path }).trim() } catch { return '' @@ -107,9 +97,11 @@ function getGhLogin(): string { } try { + // Why: gh auth status writes to stderr; redirect via shell so we can capture it. + // Use platform-appropriate shell — /bin/bash does not exist on Windows. const output = execSync('gh auth status 2>&1', { encoding: 'utf-8', - shell: '/bin/bash', + shell: process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : '/bin/bash', stdio: ['pipe', 'pipe', 'pipe'] }) @@ -148,10 +140,8 @@ export function getGitUsername(path: string): string { function hasGitRef(path: string, ref: string): boolean { try { - execSync(`git rev-parse --verify ${ref}`, { - cwd: path, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] + gitExecFileSync(['rev-parse', '--verify', ref], { + cwd: path }) return true } catch { @@ -165,10 +155,8 @@ function hasGitRef(path: string, ref: string): boolean { */ export function getDefaultBaseRef(path: string): string { try { - const ref = execSync('git symbolic-ref --quiet refs/remotes/origin/HEAD', { - cwd: path, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] + const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { + cwd: path }).trim() if (ref) { @@ -200,13 +188,9 @@ export async function getBaseRefDefault(path: string): Promise { async function getDefaultBaseRefAsync(path: string): Promise { try { - const { stdout } = await execFileAsync( - 'git', + const { stdout } = await gitExecFileAsync( ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], - { - cwd: path, - encoding: 'utf-8' - } + { cwd: path } ) const ref = stdout.trim() if (ref) { @@ -239,8 +223,7 @@ export async function searchBaseRefs(path: string, query: string, limit = 25): P } try { - const { stdout } = await execFileAsync( - 'git', + const { stdout } = await gitExecFileAsync( [ 'for-each-ref', '--format=%(refname:short)', @@ -248,10 +231,7 @@ export async function searchBaseRefs(path: string, query: string, limit = 25): P `refs/remotes/origin/*${normalizedQuery}*`, `refs/heads/*${normalizedQuery}*` ], - { - cwd: path, - encoding: 'utf-8' - } + { cwd: path } ) const seen = new Set() @@ -280,10 +260,7 @@ function normalizeRefSearchQuery(query: string): string { async function hasGitRefAsync(path: string, ref: string): Promise { try { - await execFileAsync('git', ['rev-parse', '--verify', ref], { - cwd: path, - encoding: 'utf-8' - }) + await gitExecFileAsync(['rev-parse', '--verify', ref], { cwd: path }) return true } catch { return false @@ -301,13 +278,9 @@ export async function getBranchConflictKind( } try { - const { stdout } = await execFileAsync( - 'git', + const { stdout } = await gitExecFileAsync( ['for-each-ref', '--format=%(refname)', 'refs/remotes'], - { - cwd: path, - encoding: 'utf-8' - } + { cwd: path } ) // Why: refs have the form refs/remotes//. We strip the // first three segments so that e.g. "feature/dashboard" only matches diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts new file mode 100644 index 000000000..9ed285df9 --- /dev/null +++ b/src/main/git/runner.ts @@ -0,0 +1,258 @@ +/** + * Centralized git/gh/command runner with transparent WSL support. + * + * Why: When a repo lives on a WSL filesystem (UNC path like \\wsl.localhost\Ubuntu\...), + * native Windows binaries (git.exe, gh.exe, rg.exe) are either absent or extremely slow. + * This module detects WSL paths and routes command execution through `wsl.exe -d ` + * with translated Linux paths, so every call site gets WSL support for free. + */ +import { + execFile, + execFileSync, + spawn, + type ChildProcess, + type SpawnOptions +} from 'child_process' +import { promisify } from 'util' +import { parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' + +const execFileAsync = promisify(execFile) + +// ─── Core resolution ──────────────────────────────────────────────── + +type ResolvedCommand = { + binary: string + args: string[] + cwd: string | undefined + /** Non-null when the command was routed through WSL. */ + wsl: WslPathInfo | null +} + +/** + * Translate any Windows-style paths in command arguments to Linux paths + * when the command will execute inside WSL. + * + * Why: callers like worktree-create pass Windows paths (e.g. the workspace + * directory) as git arguments. WSL git doesn't understand Windows paths, + * so we must translate them. WSL UNC paths (\\wsl.localhost\...) are + * converted to their native Linux form; regular Windows drive paths + * (C:\Users\...) are converted to /mnt/c/Users/... + */ +function translateArgsForWsl(args: string[]): string[] { + return args.map((arg) => { + // WSL UNC path → native linux path + const wslInfo = parseWslPath(arg) + if (wslInfo) { + return wslInfo.linuxPath + } + + // Windows drive path (e.g. C:\Users\...) → /mnt/c/Users/... + const driveMatch = arg.match(/^([A-Za-z]):[/\\](.*)$/) + if (driveMatch) { + const driveLetter = driveMatch[1].toLowerCase() + const rest = driveMatch[2].replace(/\\/g, '/') + return `/mnt/${driveLetter}/${rest}` + } + + return arg + }) +} + +/** + * Given a command, its arguments, and a working directory, resolve whether + * the invocation should be routed through wsl.exe. + * + * Why `bash -c "cd ... && ..."` instead of `--cd`: wsl.exe's --cd flag + * does not work reliably when invoked via Node's execFile/spawn (it fails + * with ERROR_PATH_NOT_FOUND in some configurations). Using bash -c with + * an explicit cd is universally supported. + */ +function resolveCommand( + command: string, + args: string[], + cwd: string | undefined +): ResolvedCommand { + if (!cwd || process.platform !== 'win32') { + return { binary: command, args, cwd, wsl: null } + } + + const wsl = parseWslPath(cwd) + if (!wsl) { + return { binary: command, args, cwd, wsl: null } + } + + const translatedArgs = translateArgsForWsl(args) + // Why: shell-escape each argument to prevent word splitting / glob expansion + // inside the bash -c string. Single quotes are safe for all chars except + // single quotes themselves, which we escape as '\'' (end quote, escaped + // literal, reopen quote). + const escapedArgs = translatedArgs.map( + (a) => `'${a.replace(/'/g, "'\\''")}'` + ) + const escapedCwd = wsl.linuxPath.replace(/'/g, "'\\''") + const shellCmd = `cd '${escapedCwd}' && ${command} ${escapedArgs.join(' ')}` + + return { + binary: 'wsl.exe', + args: ['-d', wsl.distro, '--', 'bash', '-c', shellCmd], + // Why: cwd is set to undefined because wsl.exe handles directory switching + // via the cd inside bash -c. Setting a UNC cwd on the Node process would + // be redundant and can cause issues with some Node internals. + cwd: undefined, + wsl + } +} + +// ─── Git-specific runners ─────────────────────────────────────────── + +type GitExecOptions = { + cwd: string + encoding?: BufferEncoding | 'buffer' + maxBuffer?: number + timeout?: number + env?: NodeJS.ProcessEnv +} + +/** + * Async git command execution. Drop-in replacement for + * `execFileAsync('git', args, { cwd, encoding, ... })`. + */ +export async function gitExecFileAsync( + args: string[], + options: GitExecOptions +): Promise<{ stdout: string; stderr: string }> { + const resolved = resolveCommand('git', args, options.cwd) + const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: (options.encoding ?? 'utf-8') as BufferEncoding, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + env: options.env + }) + return { stdout: stdout as string, stderr: stderr as string } +} + +/** + * Async git command execution that returns a Buffer. + * Used for reading binary blobs (git show). + */ +export async function gitExecFileAsyncBuffer( + args: string[], + options: { cwd: string; maxBuffer?: number } +): Promise<{ stdout: Buffer }> { + const resolved = resolveCommand('git', args, options.cwd) + const { stdout } = (await execFileAsync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: 'buffer', + maxBuffer: options.maxBuffer + })) as { stdout: Buffer } + return { stdout } +} + +/** + * Sync git command execution. Drop-in replacement for + * `execFileSync('git', args, { cwd, encoding, ... })`. + * + * Returns trimmed stdout as a string. + */ +export function gitExecFileSync( + args: string[], + options: { + cwd: string + encoding?: BufferEncoding + stdio?: SpawnOptions['stdio'] + } +): string { + const resolved = resolveCommand('git', args, options.cwd) + return execFileSync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: options.encoding ?? 'utf-8', + stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'] + }) as string +} + +/** + * Spawn a git child process. Drop-in replacement for + * `spawn('git', args, { cwd, stdio, ... })`. + */ +export function gitSpawn( + args: string[], + options: SpawnOptions & { cwd: string } +): ChildProcess { + const resolved = resolveCommand('git', args, options.cwd) + return spawn(resolved.binary, resolved.args, { + ...options, + cwd: resolved.cwd + }) +} + +// ─── gh CLI runners ───────────────────────────────────────────────── + +/** + * Async gh CLI execution. Drop-in replacement for + * `execFileAsync('gh', args, { cwd, encoding, ... })`. + */ +export async function ghExecFileAsync( + args: string[], + options: GitExecOptions +): Promise<{ stdout: string; stderr: string }> { + const resolved = resolveCommand('gh', args, options.cwd) + const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: (options.encoding ?? 'utf-8') as BufferEncoding, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + env: options.env + }) + return { stdout: stdout as string, stderr: stderr as string } +} + +// ─── Generic command runner (for rg, etc.) ────────────────────────── + +/** + * Spawn any command with WSL awareness. + * Used for non-git binaries like `rg` that also need WSL routing. + */ +export function wslAwareSpawn( + command: string, + args: string[], + options: SpawnOptions & { cwd?: string } +): ChildProcess { + const resolved = resolveCommand(command, args, options.cwd) + return spawn(resolved.binary, resolved.args, { + ...options, + cwd: resolved.cwd + }) +} + +// ─── Path translation helpers ─────────────────────────────────────── + +/** + * Translate absolute Linux paths in git output back to Windows UNC paths. + * + * Why: when git runs inside WSL, paths in output (e.g. `git worktree list`) + * are Linux-native (/home/user/repo). The rest of Orca needs Windows UNC + * paths (\\wsl.localhost\Ubuntu\home\user\repo) to read files via Node fs. + */ +export function translateWslOutputPaths( + output: string, + originalCwd: string +): string { + const wsl = parseWslPath(originalCwd) + if (!wsl) { + return output + } + + // Replace absolute Linux paths that start with / and look like filesystem + // paths in structured git output (e.g. "worktree /home/user/repo/feature") + return output.replace( + /(?<=worktree )(\/.+)$/gm, + (_match, linuxPath: string) => toWindowsWslPath(linuxPath, wsl.distro) + ) +} + +/** + * Get the WSL info for a path, if applicable. Convenience re-export so + * consumers don't need to import from wsl.ts directly. + */ +export { parseWslPath, toLinuxPath, toWindowsWslPath, isWslPath } from '../wsl' diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index faa581f13..4119a0211 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -74,7 +74,9 @@ describe('discardChanges', () => { await discardChanges('/repo', 'src/new-file.ts') expect(execFileAsyncMock).toHaveBeenCalledTimes(1) - expect(rmMock).toHaveBeenCalledWith('/repo/src/new-file.ts', { + // Why: discardChanges uses path.resolve(worktreePath, filePath) to build + // the absolute rm target, which on Windows prepends a drive letter. + expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'src', 'new-file.ts'), { force: true, recursive: true }) @@ -116,7 +118,7 @@ describe('getDiff', () => { maxBuffer: 10 * 1024 * 1024 }) ) - expect(readFileMock).toHaveBeenCalledWith('/repo/src/file.ts') + expect(readFileMock).toHaveBeenCalledWith(path.join('/repo', 'src/file.ts')) expect(result).toEqual({ kind: 'text', originalContent: 'index-content\n', diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 968673f03..83de313a2 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1,8 +1,6 @@ /* eslint-disable max-lines */ -import { execFile } from 'child_process' import { existsSync } from 'fs' import { readFile, rm } from 'fs/promises' -import { promisify } from 'util' import * as path from 'path' import type { GitBranchChangeEntry, @@ -16,8 +14,8 @@ import type { GitStatusEntry, GitStatusResult } from '../../shared/types' +import { gitExecFileAsync, gitExecFileAsyncBuffer } from './runner' -const execFileAsync = promisify(execFile) const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 /** @@ -28,10 +26,9 @@ export async function getStatus(worktreePath: string): Promise const conflictOperation = await detectConflictOperation(worktreePath) try { - const { stdout } = await execFileAsync( - 'git', + const { stdout } = await gitExecFileAsync( ['status', '--porcelain=v2', '--untracked-files=all'], - { cwd: worktreePath, encoding: 'utf-8' } + { cwd: worktreePath } ) // [Fix]: Split by /\r?\n/ instead of '\n' to correctly parse git output on Windows, @@ -424,14 +421,9 @@ async function loadBranchChanges( mergeBase: string, headOid: string ): Promise { - const { stdout } = await execFileAsync( - 'git', + const { stdout } = await gitExecFileAsync( ['diff', '--name-status', '-M', '-C', mergeBase, headOid], - { - cwd: worktreePath, - encoding: 'utf-8', - maxBuffer: MAX_GIT_SHOW_BYTES - } + { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES } ) const entries: GitBranchChangeEntry[] = [] @@ -473,9 +465,8 @@ function parseBranchChangeLine(line: string): GitBranchChangeEntry | null { async function resolveCompareRef(worktreePath: string): Promise { try { - const { stdout } = await execFileAsync('git', ['branch', '--show-current'], { - cwd: worktreePath, - encoding: 'utf-8' + const { stdout } = await gitExecFileAsync(['branch', '--show-current'], { + cwd: worktreePath }) const branch = stdout.trim() return branch || 'HEAD' @@ -485,9 +476,8 @@ async function resolveCompareRef(worktreePath: string): Promise { } async function resolveRefOid(worktreePath: string, ref: string): Promise { - const { stdout } = await execFileAsync('git', ['rev-parse', '--verify', ref], { - cwd: worktreePath, - encoding: 'utf-8' + const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], { + cwd: worktreePath }) return stdout.trim() } @@ -497,9 +487,8 @@ async function resolveMergeBase( baseOid: string, headOid: string ): Promise { - const { stdout } = await execFileAsync('git', ['merge-base', baseOid, headOid], { - cwd: worktreePath, - encoding: 'utf-8' + const { stdout } = await gitExecFileAsync(['merge-base', baseOid, headOid], { + cwd: worktreePath }) return stdout.trim() } @@ -509,9 +498,8 @@ async function countAheadCommits( baseOid: string, headOid: string ): Promise { - const { stdout } = await execFileAsync('git', ['rev-list', '--count', `${baseOid}..${headOid}`], { - cwd: worktreePath, - encoding: 'utf-8' + const { stdout } = await gitExecFileAsync(['rev-list', '--count', `${baseOid}..${headOid}`], { + cwd: worktreePath }) return Number.parseInt(stdout.trim(), 10) || 0 } @@ -533,11 +521,10 @@ async function readGitBlobAtIndexPath( filePath: string ): Promise { try { - const { stdout } = (await execFileAsync('git', ['show', `:${filePath}`], { + const { stdout } = await gitExecFileAsyncBuffer(['show', `:${filePath}`], { cwd: worktreePath, - encoding: 'buffer', maxBuffer: MAX_GIT_SHOW_BYTES - })) as { stdout: Buffer } + }) return { ...bufferToBlob(stdout, filePath), exists: true } } catch { @@ -551,11 +538,10 @@ async function readGitBlobAtOidPath( filePath: string ): Promise { try { - const { stdout } = (await execFileAsync('git', ['show', `${oid}:${filePath}`], { + const { stdout } = await gitExecFileAsyncBuffer(['show', `${oid}:${filePath}`], { cwd: worktreePath, - encoding: 'buffer', maxBuffer: MAX_GIT_SHOW_BYTES - })) as { stdout: Buffer } + }) return { ...bufferToBlob(stdout, filePath), exists: true } } catch { @@ -654,20 +640,14 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { * Stage a file. */ export async function stageFile(worktreePath: string, filePath: string): Promise { - await execFileAsync('git', ['add', '--', filePath], { - cwd: worktreePath, - encoding: 'utf-8' - }) + await gitExecFileAsync(['add', '--', filePath], { cwd: worktreePath }) } /** * Unstage a file. */ export async function unstageFile(worktreePath: string, filePath: string): Promise { - await execFileAsync('git', ['restore', '--staged', '--', filePath], { - cwd: worktreePath, - encoding: 'utf-8' - }) + await gitExecFileAsync(['restore', '--staged', '--', filePath], { cwd: worktreePath }) } /** @@ -682,9 +662,8 @@ export async function discardChanges(worktreePath: string, filePath: string): Pr let tracked = false try { - await execFileAsync('git', ['ls-files', '--error-unmatch', '--', filePath], { - cwd: worktreePath, - encoding: 'utf-8' + await gitExecFileAsync(['ls-files', '--error-unmatch', '--', filePath], { + cwd: worktreePath }) tracked = true } catch { @@ -692,9 +671,8 @@ export async function discardChanges(worktreePath: string, filePath: string): Pr } await (tracked - ? execFileAsync('git', ['restore', '--worktree', '--source=HEAD', '--', filePath], { - cwd: worktreePath, - encoding: 'utf-8' + ? gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', filePath], { + cwd: worktreePath }) : rm(resolvedTarget, { force: true, recursive: true })) } diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 7902da65e..74acb8748 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -1,24 +1,6 @@ -import { execFile, execFileSync } from 'child_process' import { posix, win32 } from 'path' import type { GitWorktreeInfo } from '../../shared/types' - -function runGit( - repoPath: string, - args: string[] -): Promise<{ - stdout: string - stderr: string -}> { - return new Promise((resolve, reject) => { - execFile('git', args, { cwd: repoPath, encoding: 'utf-8' }, (error, stdout, stderr) => { - if (error) { - reject(Object.assign(error, { stdout, stderr })) - return - } - resolve({ stdout, stderr }) - }) - }) -} +import { gitExecFileAsync, gitExecFileSync, translateWslOutputPaths } from './runner' function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') @@ -89,8 +71,14 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] { */ export async function listWorktrees(repoPath: string): Promise { try { - const { stdout } = await runGit(repoPath, ['worktree', 'list', '--porcelain']) - return parseWorktreeList(stdout) + const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { + cwd: repoPath + }) + // Why: when git runs inside WSL, worktree paths are Linux-native + // (e.g. /home/user/repo). Translate them back to Windows UNC paths + // so the rest of Orca can access them via Node fs APIs. + const translated = translateWslOutputPaths(stdout, repoPath) + return parseWorktreeList(translated) } catch { return [] } @@ -113,11 +101,7 @@ export function addWorktree( if (baseBranch) { args.push(baseBranch) } - execFileSync('git', args, { - cwd: repoPath, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] - }) + gitExecFileSync(args, { cwd: repoPath }) } /** @@ -139,8 +123,8 @@ export async function removeWorktree( args.push('--force') } args.push(worktreePath) - await runGit(repoPath, args) - await runGit(repoPath, ['worktree', 'prune']) + await gitExecFileAsync(args, { cwd: repoPath }) + await gitExecFileAsync(['worktree', 'prune'], { cwd: repoPath }) if (!branchName) { return @@ -161,7 +145,7 @@ export async function removeWorktree( // Why: `git worktree remove` only detaches the filesystem entry. Orca also // drops the now-unused local branch here so delete-worktree does not leave // behind orphaned feature branches unless another worktree still points at it. - await runGit(repoPath, ['branch', '-D', branchName]) + await gitExecFileAsync(['branch', '-D', branchName], { cwd: repoPath }) } catch (error) { console.warn( `[git] Failed to delete local branch "${branchName}" after removing worktree`, diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 1a9f93e6a..bedb4409e 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2,7 +2,7 @@ concurrency acquire/release pattern and error handling consistent across operations. */ import type { PRInfo, PRMergeableState, PRCheckDetail, PRComment } from '../../shared/types' import { getPRConflictSummary } from './conflict-summary' -import { execFileAsync, acquire, release, getOwnerRepo } from './gh-utils' +import { execFileAsync, ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, listIssues } from './issues' import { @@ -88,8 +88,7 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise< } | null = null if (ownerRepo) { - const { stdout } = await execFileAsync( - 'gh', + const { stdout } = await ghExecFileAsync( [ 'pr', 'list', @@ -104,16 +103,12 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise< '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' ], - { - cwd: repoPath, - encoding: 'utf-8' - } + { cwd: repoPath } ) const list = JSON.parse(stdout) as NonNullable[] data = list[0] ?? null } else { - const { stdout } = await execFileAsync( - 'gh', + const { stdout } = await ghExecFileAsync( [ 'pr', 'view', @@ -121,10 +116,7 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise< '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' ], - { - cwd: repoPath, - encoding: 'utf-8' - } + { cwd: repoPath } ) data = JSON.parse(stdout) } @@ -175,14 +167,13 @@ export async function getPRChecks( // user explicitly clicks refresh we must skip it so gh fetches fresh data. const cacheArgs = options?.noCache ? [] : ['--cache', '60s'] try { - const { stdout } = await execFileAsync( - 'gh', + const { stdout } = await ghExecFileAsync( [ 'api', ...cacheArgs, `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodeURIComponent(headSha)}/check-runs?per_page=100` ], - { cwd: repoPath, encoding: 'utf-8' } + { cwd: repoPath } ) const data = JSON.parse(stdout) as { check_runs: { @@ -207,10 +198,9 @@ export async function getPRChecks( } } // Fallback: no branch provided or non-GitHub remote - const { stdout } = await execFileAsync( - 'gh', + const { stdout } = await ghExecFileAsync( ['pr', 'checks', String(prNumber), '--json', 'name,state,link'], - { cwd: repoPath, encoding: 'utf-8' } + { cwd: repoPath } ) const data = JSON.parse(stdout) as { name: string; state: string; link: string }[] return data.map((d) => ({ @@ -490,9 +480,8 @@ export async function mergePR( // Don't use --delete-branch: it tries to delete the local branch which // fails when the user's worktree is checked out on it. Branch cleanup // is handled by worktree deletion (local) and GitHub's auto-delete setting (remote). - await execFileAsync('gh', ['pr', 'merge', String(prNumber), `--${method}`], { + await ghExecFileAsync(['pr', 'merge', String(prNumber), `--${method}`], { cwd: repoPath, - encoding: 'utf-8', env: { ...process.env, GH_PROMPT_DISABLED: '1' } }) return { ok: true } @@ -515,9 +504,8 @@ export async function updatePRTitle( ): Promise { await acquire() try { - await execFileAsync('gh', ['pr', 'edit', String(prNumber), '--title', title], { - cwd: repoPath, - encoding: 'utf-8' + await ghExecFileAsync(['pr', 'edit', String(prNumber), '--title', title], { + cwd: repoPath }) return true } catch (err) { diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index b76e208c6..8c8424319 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -1,8 +1,5 @@ -import { execFile } from 'child_process' -import { promisify } from 'util' import type { PRConflictSummary } from '../../shared/types' - -const execFileAsync = promisify(execFile) +import { gitExecFileAsync } from '../git/runner' export async function getPRConflictSummary( repoPath: string, @@ -47,9 +44,8 @@ async function resolveLatestBaseOid( try { // Why: cap the fetch at 10 s so slow or unreachable remotes don't block // the conflict-summary derivation indefinitely. - await execFileAsync('git', ['fetch', '--quiet', remoteName, baseRefName], { + await gitExecFileAsync(['fetch', '--quiet', remoteName, baseRefName], { cwd: repoPath, - encoding: 'utf-8', timeout: 10_000 }) } catch { @@ -60,9 +56,8 @@ async function resolveLatestBaseOid( for (const ref of [`refs/remotes/${remoteName}/${baseRefName}`, `${remoteName}/${baseRefName}`]) { try { - const { stdout } = await execFileAsync('git', ['rev-parse', '--verify', ref], { - cwd: repoPath, - encoding: 'utf-8' + const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], { + cwd: repoPath }) const oid = stdout.trim() if (oid) { @@ -81,17 +76,15 @@ async function resolveMergeBase( headOid: string, baseOid: string ): Promise { - const { stdout } = await execFileAsync('git', ['merge-base', headOid, baseOid], { - cwd: repoPath, - encoding: 'utf-8' + const { stdout } = await gitExecFileAsync(['merge-base', headOid, baseOid], { + cwd: repoPath }) return stdout.trim() } async function countCommits(repoPath: string, range: string): Promise { - const { stdout } = await execFileAsync('git', ['rev-list', '--count', range], { - cwd: repoPath, - encoding: 'utf-8' + const { stdout } = await gitExecFileAsync(['rev-list', '--count', range], { + cwd: repoPath }) return Number.parseInt(stdout.trim(), 10) || 0 } @@ -104,8 +97,7 @@ async function loadConflictingFiles( ): Promise { let stdout = '' try { - const result = await execFileAsync( - 'git', + const result = await gitExecFileAsync( [ 'merge-tree', '--write-tree', @@ -117,10 +109,7 @@ async function loadConflictingFiles( headOid, baseOid ], - { - cwd: repoPath, - encoding: 'utf-8' - } + { cwd: repoPath } ) stdout = result.stdout } catch (error) { diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 54a90a2ee..16d099e6e 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -1,7 +1,12 @@ import { execFile } from 'child_process' import { promisify } from 'util' +import { gitExecFileAsync, ghExecFileAsync } from '../git/runner' +// Why: legacy generic execFile wrapper — only used by callers that don't need +// WSL-aware routing (e.g. non-repo-scoped gh commands). Repo-scoped callers +// should use ghExecFileAsync or gitExecFileAsync from the runner instead. export const execFileAsync = promisify(execFile) +export { ghExecFileAsync, gitExecFileAsync } // Concurrency limiter - max 4 parallel gh processes const MAX_CONCURRENT = 4 @@ -44,9 +49,8 @@ export async function getOwnerRepo( return ownerRepoCache.get(repoPath)! } try { - const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { - cwd: repoPath, - encoding: 'utf-8' + const { stdout } = await gitExecFileAsync(['remote', 'get-url', 'origin'], { + cwd: repoPath }) const match = stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/) if (match) { diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 3fa3dcbf9..224fce4d9 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -1,6 +1,6 @@ import type { IssueInfo } from '../../shared/types' import { mapIssueInfo } from './mappers' -import { execFileAsync, acquire, release, getOwnerRepo } from './gh-utils' +import { ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils' /** * Get a single issue by number. @@ -11,24 +11,22 @@ export async function getIssue(repoPath: string, issueNumber: number): Promise mapIssueInfo(d as Parameters[0])) } // Fallback for non-GitHub remotes - const { stdout } = await execFileAsync( - 'gh', + const { stdout } = await ghExecFileAsync( ['issue', 'list', '--json', 'number,title,state,url,labels', '--limit', String(limit)], - { cwd: repoPath, encoding: 'utf-8' } + { cwd: repoPath } ) const data = JSON.parse(stdout) as unknown[] return data.map((d) => mapIssueInfo(d as Parameters[0])) diff --git a/src/main/hooks-runner.test.ts b/src/main/hooks-runner.test.ts index 5b22ecb6f..79f000f40 100644 --- a/src/main/hooks-runner.test.ts +++ b/src/main/hooks-runner.test.ts @@ -16,7 +16,11 @@ vi.mock('fs', () => ({ vi.mock('child_process', () => ({ exec: vi.fn(), - execFileSync: execFileSyncMock + execFileSync: execFileSyncMock, + // runner.ts imports these from child_process; stubs prevent + // "missing export" errors when the mock is resolved transitively. + execFile: vi.fn(), + spawn: vi.fn() })) describe('createSetupRunnerScript', () => { @@ -74,4 +78,97 @@ describe('createSetupRunnerScript', () => { }) } }) + + it('translates WSL runner paths and env vars to Linux form on Windows', async () => { + const fs = await import('fs') + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('/home/jin/.git/worktrees/feature/orca/setup-runner.sh') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + { + ...makeRepo(), + path: 'C:\\Users\\jinwo\\git\\orca' + }, + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\feature', + 'pnpm install' + ) + + expect(result).toEqual({ + runnerScriptPath: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca', + ORCA_WORKTREE_PATH: '/home/jin/feature', + CONDUCTOR_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca', + GHOSTX_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca' + }) + }) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + '#!/usr/bin/env bash\nset -e\npnpm install\n', + 'utf-8' + ) + expect(vi.mocked(fs.chmodSync)).toHaveBeenCalledWith( + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + 0o755 + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('translates WSL env vars to Linux paths when the worktree lives on a WSL UNC path', async () => { + const fs = await import('fs') + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('/home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + makeRepo(), + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\feature', + 'pnpm install' + ) + + expect(result).toEqual({ + runnerScriptPath: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: '/test/repo', + ORCA_WORKTREE_PATH: '/home/jin/repo/feature', + CONDUCTOR_ROOT_PATH: '/test/repo', + GHOSTX_ROOT_PATH: '/test/repo' + }) + }) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + '#!/usr/bin/env bash\nset -e\npnpm install\n', + 'utf-8' + ) + expect(vi.mocked(fs.chmodSync)).toHaveBeenCalledWith( + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + 0o755 + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) }) diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index f1b3bb884..0524dbeec 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: hook parsing, shell selection, and execution-path regressions are tightly coupled, so these cases stay in one file to preserve the behavior matrix across platforms. */ import type { Repo } from '../shared/types' import { describe, expect, it, vi } from 'vitest' @@ -9,13 +10,17 @@ vi.mock('fs', () => ({ existsSync: vi.fn() })) -const { execMock } = vi.hoisted(() => ({ - execMock: vi.fn() +const { execMock, execFileMock } = vi.hoisted(() => ({ + execMock: vi.fn(), + execFileMock: vi.fn() })) vi.mock('child_process', () => ({ exec: execMock, - execFileSync: vi.fn() + execFile: execFileMock, + execFileSync: vi.fn(), + // runner.ts imports spawn from child_process transitively. + spawn: vi.fn() })) describe('parseOrcaYaml', () => { @@ -311,6 +316,68 @@ describe('runHook', () => { } } }) + + it('runs WSL hooks through wsl.exe and translates env paths to Linux', async () => { + execMock.mockReset() + execFileMock.mockReset() + execFileMock.mockImplementation((_file, _args, options, callback) => { + callback?.(null, '', '') + expect(options).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca', + ORCA_WORKTREE_PATH: '/home/jin/feature', + CONDUCTOR_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca', + GHOSTX_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca' + }) + }) + ) + return {} as never + }) + + const fs = await import('fs') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n') + + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + try { + const { runHook } = await import('./hooks') + const result = await runHook( + 'setup', + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\feature', + { + ...makeRepo(), + path: 'C:\\Users\\jinwo\\git\\orca' + } + ) + + expect(result).toEqual({ success: true, output: '' }) + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-c', + "cd '/home/jin/feature' && echo hello" + ], + expect.any(Object), + expect.any(Function) + ) + expect(execMock).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) }) describe('shouldRunSetupForCreate', () => { diff --git a/src/main/hooks.ts b/src/main/hooks.ts index e9204d958..ede5e87d6 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -1,7 +1,9 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from 'fs' import { dirname, join } from 'path' -import { exec, execFileSync } from 'child_process' +import { exec, execFile } from 'child_process' import { getDefaultRepoHookSettings } from '../shared/constants' +import { gitExecFileSync } from './git/runner' +import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl' import type { OrcaHooks, Repo, @@ -164,10 +166,8 @@ function getSetupEnvVars(repo: Repo, worktreePath: string): Record = {} + for (const [key, value] of Object.entries(envVars)) { + wslEnv[key] = toLinuxPath(value) + } + + return new Promise((resolve) => { + execFile( + 'wsl.exe', + ['-d', wslInfo.distro, '--', 'bash', '-c', bashCmd], + { + timeout: HOOK_TIMEOUT, + encoding: 'utf-8', + env: { ...process.env, ...wslEnv } + }, + (error, stdout, stderr) => { + if (error) { + console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, error.message) + resolve({ + success: false, + output: `${stdout}\n${stderr}\n${error.message}`.trim() + }) + } else { + console.log(`[hooks] ${hookName} hook completed in ${cwd}`) + resolve({ + success: true, + output: `${stdout}\n${stderr}`.trim() + }) + } + } + ) + }) + } + return new Promise((resolve) => { exec( script, diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index cf65148bb..50b4691ef 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -7,7 +7,11 @@ const { spawnMock, resolveAuthorizedPathMock, checkRgAvailableMock } = vi.hoiste })) vi.mock('child_process', () => ({ - spawn: spawnMock + spawn: spawnMock, + // runner.ts imports these from child_process; stubs prevent + // "missing export" errors when the mock is resolved transitively. + execFile: vi.fn(), + execFileSync: vi.fn() })) vi.mock('./filesystem-auth', () => ({ diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 71bcb651c..85beb819c 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -1,8 +1,9 @@ -import { spawn } from 'child_process' import { relative, sep } from 'path' import type { Store } from '../persistence' import { resolveAuthorizedPath } from './filesystem-auth' import { checkRgAvailable } from './rg-availability' +import { gitSpawn, wslAwareSpawn } from '../git/runner' +import { parseWslPath, toWindowsWslPath } from '../wsl' // Why: We use --hidden to surface dotfiles users commonly edit (e.g. .env, // .github workflows, .eslintrc) but must still exclude non-editable hidden @@ -51,7 +52,7 @@ export async function listQuickOpenFiles(rootPath: string, store: Store): Promis // spawn('rg') emits 'close' before 'error' on some platforms, causing // the handler to resolve with empty results before the git fallback // can run. The result is cached after the first check. - const rgAvailable = await checkRgAvailable() + const rgAvailable = await checkRgAvailable(authorizedRootPath) if (!rgAvailable) { return listFilesWithGit(authorizedRootPath) } @@ -78,6 +79,11 @@ export async function listQuickOpenFiles(rootPath: string, store: Store): Promis resolve() } + // Why: when rg runs inside WSL, output paths are Linux-native + // (e.g. /home/user/repo/src/file.ts). Detect this upfront so we + // can translate them back to Windows UNC paths for prefix matching. + const wslInfo = parseWslPath(authorizedRootPath) + const processLine = (line: string): void => { if (line.charCodeAt(line.length - 1) === 13 /* \r */) { line = line.substring(0, line.length - 1) @@ -85,6 +91,12 @@ export async function listQuickOpenFiles(rootPath: string, store: Store): Promis if (!line) { return } + + // Translate Linux paths from WSL rg output to Windows UNC paths + if (wslInfo) { + line = toWindowsWslPath(line, wslInfo.distro) + } + // Why: Normalize separators to '/' so the prefix check works on all // platforms (Windows rg uses '\', macOS/Linux use '/'). const normalized = line.replace(/\\/g, '/') @@ -105,9 +117,12 @@ export async function listQuickOpenFiles(rootPath: string, store: Store): Promis } } - const child = spawn('rg', args, { stdio: ['ignore', 'pipe', 'pipe'] }) - child.stdout.setEncoding('utf-8') - child.stdout.on('data', (chunk: string) => { + const child = wslAwareSpawn('rg', args, { + cwd: authorizedRootPath, + stdio: ['ignore', 'pipe', 'pipe'] + }) + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', (chunk: string) => { buf += chunk let start = 0 let newlineIdx = buf.indexOf('\n', start) @@ -119,7 +134,7 @@ export async function listQuickOpenFiles(rootPath: string, store: Store): Promis // Keep the incomplete trailing segment for the next chunk buf = start < buf.length ? buf.substring(start) : '' }) - child.stderr.on('data', () => { + child.stderr!.on('data', () => { /* drain */ }) child.once('error', () => { @@ -217,12 +232,12 @@ function listFilesWithGit(rootPath: string): Promise { // Why: git ls-files outputs paths relative to cwd, so we set cwd to // rootPath and use the output directly — no prefix stripping needed. - const child = spawn('git', ['ls-files', ...args], { + const child = gitSpawn(['ls-files', ...args], { cwd: rootPath, stdio: ['ignore', 'pipe', 'pipe'] }) - child.stdout.setEncoding('utf-8') - child.stdout.on('data', (chunk: string) => { + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', (chunk: string) => { buf += chunk let start = 0 let newlineIdx = buf.indexOf('\n', start) @@ -233,7 +248,7 @@ function listFilesWithGit(rootPath: string): Promise { } buf = start < buf.length ? buf.substring(start) : '' }) - child.stderr.on('data', () => { + child.stderr!.on('data', () => { /* drain */ }) child.once('error', () => { diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 84d51c001..a4f00b3e8 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -1,3 +1,4 @@ +import path from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' const handlers = new Map Promise>() @@ -26,11 +27,16 @@ vi.mock('fs/promises', () => ({ import { registerFilesystemMutationHandlers } from './filesystem-mutations' +// Why: paths are resolved via path.resolve() in production code, so test +// data must use resolved paths to avoid Unix-vs-Windows mismatches. +const REPO_PATH = path.resolve('/workspace/repo') +const WORKSPACE_DIR = path.resolve('/workspace') + const store = { getRepos: () => [ - { id: 'repo-1', path: '/workspace/repo', displayName: 'repo', badgeColor: '#000', addedAt: 0 } + { id: 'repo-1', path: REPO_PATH, displayName: 'repo', badgeColor: '#000', addedAt: 0 } ], - getSettings: () => ({ workspaceDir: '/workspace' }) + getSettings: () => ({ workspaceDir: WORKSPACE_DIR }) } function enoent(): Error { @@ -73,10 +79,11 @@ describe('registerFilesystemMutationHandlers', () => { // ── fs:createFile ────────────────────────────────────────────── it('creates an empty file and its parent directories', async () => { - await handlers.get('fs:createFile')!(null, { filePath: '/workspace/repo/src/new.ts' }) + const filePath = path.resolve('/workspace/repo/src/new.ts') + await handlers.get('fs:createFile')!(null, { filePath }) - expect(mkdirMock).toHaveBeenCalledWith('/workspace/repo/src', { recursive: true }) - expect(writeFileMock).toHaveBeenCalledWith('/workspace/repo/src/new.ts', '', { + expect(mkdirMock).toHaveBeenCalledWith(path.resolve('/workspace/repo/src'), { recursive: true }) + expect(writeFileMock).toHaveBeenCalledWith(filePath, '', { encoding: 'utf-8', flag: 'wx' }) @@ -88,17 +95,17 @@ describe('registerFilesystemMutationHandlers', () => { writeFileMock.mockRejectedValue(Object.assign(new Error('EEXIST'), { code: 'EEXIST' })) await expect( - handlers.get('fs:createFile')!(null, { filePath: '/workspace/repo/existing.ts' }) + handlers.get('fs:createFile')!(null, { filePath: path.resolve('/workspace/repo/existing.ts') }) ).rejects.toThrow("A file or folder named 'existing.ts' already exists in this location") }) it('rejects file creation outside allowed roots', async () => { mockRealpath({ - '/workspace/repo/link.ts': '/private/secret.ts' + [path.resolve('/workspace/repo/link.ts')]: path.resolve('/private/secret.ts') }) await expect( - handlers.get('fs:createFile')!(null, { filePath: '/workspace/repo/link.ts' }) + handlers.get('fs:createFile')!(null, { filePath: path.resolve('/workspace/repo/link.ts') }) ).rejects.toThrow('Access denied') expect(writeFileMock).not.toHaveBeenCalled() @@ -107,16 +114,17 @@ describe('registerFilesystemMutationHandlers', () => { // ── fs:createDir ─────────────────────────────────────────────── it('creates a directory recursively', async () => { - await handlers.get('fs:createDir')!(null, { dirPath: '/workspace/repo/src/components' }) + const dirPath = path.resolve('/workspace/repo/src/components') + await handlers.get('fs:createDir')!(null, { dirPath }) - expect(mkdirMock).toHaveBeenCalledWith('/workspace/repo/src/components', { recursive: true }) + expect(mkdirMock).toHaveBeenCalledWith(dirPath, { recursive: true }) }) it('rejects directory creation when path already exists', async () => { lstatMock.mockResolvedValue({ isDirectory: () => true }) await expect( - handlers.get('fs:createDir')!(null, { dirPath: '/workspace/repo/src' }) + handlers.get('fs:createDir')!(null, { dirPath: path.resolve('/workspace/repo/src') }) ).rejects.toThrow("A file or folder named 'src' already exists in this location") expect(mkdirMock).not.toHaveBeenCalled() @@ -124,11 +132,11 @@ describe('registerFilesystemMutationHandlers', () => { it('rejects directory creation outside allowed roots', async () => { mockRealpath({ - '/workspace/repo/escape': '/etc/evil' + [path.resolve('/workspace/repo/escape')]: path.resolve('/etc/evil') }) await expect( - handlers.get('fs:createDir')!(null, { dirPath: '/workspace/repo/escape' }) + handlers.get('fs:createDir')!(null, { dirPath: path.resolve('/workspace/repo/escape') }) ).rejects.toThrow('Access denied') expect(mkdirMock).not.toHaveBeenCalled() @@ -137,17 +145,17 @@ describe('registerFilesystemMutationHandlers', () => { // ── fs:rename ────────────────────────────────────────────────── it('renames a file within the same directory', async () => { - await handlers.get('fs:rename')!(null, { - oldPath: '/workspace/repo/old.ts', - newPath: '/workspace/repo/new.ts' - }) + const oldPath = path.resolve('/workspace/repo/old.ts') + const newPath = path.resolve('/workspace/repo/new.ts') + await handlers.get('fs:rename')!(null, { oldPath, newPath }) - expect(renameMock).toHaveBeenCalledWith('/workspace/repo/old.ts', '/workspace/repo/new.ts') + expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) }) it('rejects rename when destination already exists', async () => { + const resolvedNewPath = path.resolve('/workspace/repo/new.ts') lstatMock.mockImplementation(async (p: string) => { - if (p === '/workspace/repo/new.ts') { + if (p === resolvedNewPath) { return { isDirectory: () => false } } throw enoent() @@ -155,8 +163,8 @@ describe('registerFilesystemMutationHandlers', () => { await expect( handlers.get('fs:rename')!(null, { - oldPath: '/workspace/repo/old.ts', - newPath: '/workspace/repo/new.ts' + oldPath: path.resolve('/workspace/repo/old.ts'), + newPath: resolvedNewPath }) ).rejects.toThrow("A file or folder named 'new.ts' already exists in this location") @@ -165,13 +173,13 @@ describe('registerFilesystemMutationHandlers', () => { it('rejects rename when new path escapes allowed roots', async () => { mockRealpath({ - '/workspace/repo/escape.ts': '/private/escape.ts' + [path.resolve('/workspace/repo/escape.ts')]: path.resolve('/private/escape.ts') }) await expect( handlers.get('fs:rename')!(null, { - oldPath: '/workspace/repo/old.ts', - newPath: '/workspace/repo/escape.ts' + oldPath: path.resolve('/workspace/repo/old.ts'), + newPath: path.resolve('/workspace/repo/escape.ts') }) ).rejects.toThrow('Access denied') @@ -180,13 +188,13 @@ describe('registerFilesystemMutationHandlers', () => { it('rejects rename when old path escapes allowed roots', async () => { mockRealpath({ - '/workspace/repo/symlink.ts': '/private/secret.ts' + [path.resolve('/workspace/repo/symlink.ts')]: path.resolve('/private/secret.ts') }) await expect( handlers.get('fs:rename')!(null, { - oldPath: '/workspace/repo/symlink.ts', - newPath: '/workspace/repo/new.ts' + oldPath: path.resolve('/workspace/repo/symlink.ts'), + newPath: path.resolve('/workspace/repo/new.ts') }) ).rejects.toThrow('Access denied') @@ -199,7 +207,7 @@ describe('registerFilesystemMutationHandlers', () => { lstatMock.mockRejectedValue(new Error('EPERM: operation not permitted')) await expect( - handlers.get('fs:createDir')!(null, { dirPath: '/workspace/repo/locked' }) + handlers.get('fs:createDir')!(null, { dirPath: path.resolve('/workspace/repo/locked') }) ).rejects.toThrow('EPERM') expect(mkdirMock).not.toHaveBeenCalled() @@ -209,7 +217,9 @@ describe('registerFilesystemMutationHandlers', () => { mkdirMock.mockRejectedValue(new Error('EACCES: permission denied')) await expect( - handlers.get('fs:createFile')!(null, { filePath: '/workspace/repo/nowrite/file.ts' }) + handlers.get('fs:createFile')!(null, { + filePath: path.resolve('/workspace/repo/nowrite/file.ts') + }) ).rejects.toThrow('EACCES') expect(writeFileMock).not.toHaveBeenCalled() @@ -220,8 +230,8 @@ describe('registerFilesystemMutationHandlers', () => { await expect( handlers.get('fs:rename')!(null, { - oldPath: '/workspace/repo/gone.ts', - newPath: '/workspace/repo/new.ts' + oldPath: path.resolve('/workspace/repo/gone.ts'), + newPath: path.resolve('/workspace/repo/new.ts') }) ).rejects.toThrow('ENOENT') }) diff --git a/src/main/ipc/filesystem-search-git.test.ts b/src/main/ipc/filesystem-search-git.test.ts index 21b36bccc..7cd3c767a 100644 --- a/src/main/ipc/filesystem-search-git.test.ts +++ b/src/main/ipc/filesystem-search-git.test.ts @@ -5,7 +5,11 @@ const { spawnMock } = vi.hoisted(() => ({ })) vi.mock('child_process', () => ({ - spawn: spawnMock + spawn: spawnMock, + // runner.ts imports these from child_process; stubs prevent + // "missing export" errors when the mock is resolved transitively. + execFile: vi.fn(), + execFileSync: vi.fn() })) import { searchWithGitGrep } from './filesystem-search-git' diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index e3c9a3a62..8f20a86a7 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -1,6 +1,6 @@ -import { spawn } from 'child_process' import { join } from 'path' import type { SearchOptions, SearchResult, SearchFileResult } from '../../shared/types' +import { gitSpawn } from '../git/runner' const SEARCH_TIMEOUT_MS = 15000 @@ -186,12 +186,12 @@ export function searchWithGitGrep( } } - const child = spawn('git', gitArgs, { + const child = gitSpawn(gitArgs, { cwd: rootPath, stdio: ['ignore', 'pipe', 'pipe'] }) - child.stdout.setEncoding('utf-8') - child.stdout.on('data', (chunk: string) => { + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', (chunk: string) => { stdoutBuffer += chunk const lines = stdoutBuffer.split('\n') stdoutBuffer = lines.pop() ?? '' @@ -199,7 +199,7 @@ export function searchWithGitGrep( processLine(l) } }) - child.stderr.on('data', () => { + child.stderr!.on('data', () => { /* drain */ }) child.once('error', () => { diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 08e86590d..fed30426c 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: filesystem authorization and git/file IPC invariants are exercised end-to-end here, so the scenarios stay together to keep the security boundary readable. */ +import path from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' const handlers = new Map Promise | unknown>() @@ -72,19 +74,25 @@ vi.mock('../git/worktree', () => ({ import { registerFilesystemHandlers } from './filesystem' import { invalidateAuthorizedRootsCache } from './filesystem-auth' +// Why: paths are resolved via path.resolve() in production code, so test +// data must use resolved paths to avoid Unix-vs-Windows mismatches. +const REPO_PATH = path.resolve('/workspace/repo') +const WORKSPACE_DIR = path.resolve('/workspace') +const WORKTREE_FEATURE_PATH = path.resolve('/workspace/repo-feature') + describe('registerFilesystemHandlers', () => { const store = { getRepos: () => [ { id: 'repo-1', - path: '/workspace/repo', + path: REPO_PATH, displayName: 'repo', badgeColor: '#000', addedAt: 0 } ], getSettings: () => ({ - workspaceDir: '/workspace' + workspaceDir: WORKSPACE_DIR }) } @@ -122,7 +130,7 @@ describe('registerFilesystemHandlers', () => { realpathMock.mockImplementation(async (targetPath: string) => targetPath) listWorktreesMock.mockResolvedValue([ { - path: '/workspace/repo-feature', + path: WORKTREE_FEATURE_PATH, head: 'abc', branch: '', isBare: false, @@ -135,9 +143,10 @@ describe('registerFilesystemHandlers', () => { }) it('rejects readFile when the real path escapes allowed roots', async () => { + const linkPath = path.resolve('/workspace/repo/link.txt') realpathMock.mockImplementation(async (targetPath: string) => { - if (targetPath === '/workspace/repo/link.txt') { - return '/private/secret.txt' + if (targetPath === linkPath) { + return path.resolve('/private/secret.txt') } return targetPath }) @@ -145,7 +154,7 @@ describe('registerFilesystemHandlers', () => { registerFilesystemHandlers(store as never) await expect( - handlers.get('fs:readFile')!(null, { filePath: '/workspace/repo/link.txt' }) + handlers.get('fs:readFile')!(null, { filePath: linkPath }) ).rejects.toThrow('Access denied: path resolves outside allowed directories') expect(readFileMock).not.toHaveBeenCalled() @@ -158,7 +167,7 @@ describe('registerFilesystemHandlers', () => { await expect( handlers.get('fs:writeFile')!(null, { - filePath: '/workspace/repo/folder', + filePath: path.resolve('/workspace/repo/folder'), content: 'data' }) ).rejects.toThrow('Cannot write to a directory') @@ -180,7 +189,7 @@ describe('registerFilesystemHandlers', () => { readFileMock.mockResolvedValue(buf) registerFilesystemHandlers(store as never) await expect( - handlers.get('fs:readFile')!(null, { filePath: `/workspace/repo/file.${ext}` }) + handlers.get('fs:readFile')!(null, { filePath: path.resolve(`/workspace/repo/file.${ext}`) }) ).resolves.toEqual({ content: buf.toString('base64'), isBinary: true, @@ -191,10 +200,11 @@ describe('registerFilesystemHandlers', () => { it('moves files to trash', async () => { registerFilesystemHandlers(store as never) + const targetPath = path.resolve('/workspace/repo/file.txt') - await handlers.get('fs:deletePath')!(null, { targetPath: '/workspace/repo/file.txt' }) + await handlers.get('fs:deletePath')!(null, { targetPath }) - expect(trashItemMock).toHaveBeenCalledWith('/workspace/repo/file.txt') + expect(trashItemMock).toHaveBeenCalledWith(targetPath) }) it('keeps non-image binaries hidden from the editor payload', async () => { @@ -204,7 +214,7 @@ describe('registerFilesystemHandlers', () => { registerFilesystemHandlers(store as never) await expect( - handlers.get('fs:readFile')!(null, { filePath: '/workspace/repo/archive.zip' }) + handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/archive.zip') }) ).resolves.toEqual({ content: '', isBinary: true @@ -217,11 +227,13 @@ describe('registerFilesystemHandlers', () => { registerFilesystemHandlers(store as never) await handlers.get('git:stage')!(null, { - worktreePath: '/workspace/repo-feature', + worktreePath: WORKTREE_FEATURE_PATH, filePath: './src/../src/file.ts' }) - expect(stageFileMock).toHaveBeenCalledWith('/workspace/repo-feature', 'src/file.ts') + // Why: validateGitRelativeFilePath uses path.relative() which produces + // platform-specific separators (backslashes on Windows). + expect(stageFileMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, path.join('src', 'file.ts')) }) it('rejects git file paths that escape the selected worktree', async () => { @@ -229,7 +241,7 @@ describe('registerFilesystemHandlers', () => { await expect( handlers.get('git:discard')!(null, { - worktreePath: '/workspace/repo-feature', + worktreePath: WORKTREE_FEATURE_PATH, filePath: '../outside.txt' }) ).rejects.toThrow('Access denied: git file path escapes the selected worktree') @@ -244,7 +256,7 @@ describe('registerFilesystemHandlers', () => { await expect( handlers.get('git:status')!(null, { - worktreePath: '/workspace/repo-feature' + worktreePath: WORKTREE_FEATURE_PATH }) ).rejects.toThrow('Access denied: unknown repository or worktree path') @@ -268,11 +280,11 @@ describe('registerFilesystemHandlers', () => { registerFilesystemHandlers(store as never) await handlers.get('git:branchCompare')!(null, { - worktreePath: '/workspace/repo-feature', + worktreePath: WORKTREE_FEATURE_PATH, baseRef: 'origin/main' }) - expect(getBranchCompareMock).toHaveBeenCalledWith('/workspace/repo-feature', 'origin/main') + expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main') }) it('allows git operations on worktrees outside repo/workspace roots', async () => { @@ -280,16 +292,17 @@ describe('registerFilesystemHandlers', () => { // As long as the path matches a worktree reported by `git worktree list` // for a registered repo, it should be allowed — the security boundary is // worktree registration, not directory containment. + const externalWorktreePath = path.resolve('/external/worktrees/feature') listWorktreesMock.mockResolvedValue([ { - path: '/workspace/repo', + path: REPO_PATH, head: 'abc', branch: 'refs/heads/main', isBare: false, isMainWorktree: true }, { - path: '/external/worktrees/feature', + path: externalWorktreePath, head: 'def', branch: 'refs/heads/feature', isBare: false, @@ -312,13 +325,12 @@ describe('registerFilesystemHandlers', () => { registerFilesystemHandlers(store as never) - // /external/worktrees/feature is outside both /workspace/repo and /workspace await handlers.get('git:branchCompare')!(null, { - worktreePath: '/external/worktrees/feature', + worktreePath: externalWorktreePath, baseRef: 'origin/main' }) - expect(getBranchCompareMock).toHaveBeenCalledWith('/external/worktrees/feature', 'origin/main') + expect(getBranchCompareMock).toHaveBeenCalledWith(externalWorktreePath, 'origin/main') }) it('routes branch diff queries through the pinned branch diff helper', async () => { @@ -333,7 +345,7 @@ describe('registerFilesystemHandlers', () => { registerFilesystemHandlers(store as never) await handlers.get('git:branchDiff')!(null, { - worktreePath: '/workspace/repo-feature', + worktreePath: WORKTREE_FEATURE_PATH, compare: { baseRef: 'origin/main', baseOid: 'base-oid', @@ -344,11 +356,13 @@ describe('registerFilesystemHandlers', () => { oldPath: 'src/old-file.ts' }) - expect(getBranchDiffMock).toHaveBeenCalledWith('/workspace/repo-feature', { + // Why: validateGitRelativeFilePath uses path.relative() which produces + // platform-specific separators (backslashes on Windows). + expect(getBranchDiffMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { headOid: 'head-oid', mergeBase: 'merge-base-oid', - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' + filePath: path.join('src', 'file.ts'), + oldPath: path.join('src', 'old-file.ts') }) }) }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index f1a88e221..08ad5578b 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -2,9 +2,9 @@ import { ipcMain, shell } from 'electron' import { readdir, readFile, writeFile, stat, lstat } from 'fs/promises' import { extname, relative } from 'path' -import { spawn } from 'child_process' -import type { ChildProcessByStdio } from 'child_process' -import type { Readable } from 'stream' +import type { ChildProcess } from 'child_process' +import { wslAwareSpawn } from '../git/runner' +import { parseWslPath, toWindowsWslPath } from '../wsl' import type { Store } from '../persistence' import type { DirEntry, @@ -75,7 +75,7 @@ function isBinaryBuffer(buffer: Buffer): boolean { export function registerFilesystemHandlers(store: Store): void { void rebuildAuthorizedRootsCache(store) - const activeTextSearches = new Map>() + const activeTextSearches = new Map() // ─── Filesystem ───────────────────────────────────────── ipcMain.handle('fs:readDir', async (_event, args: { dirPath: string }): Promise => { @@ -193,7 +193,7 @@ export function registerFilesystemHandlers(store: Store): void { // spawn('rg') emits 'close' before 'error' on some platforms, causing // the handler to resolve with empty results before the git-grep // fallback can run. The result is cached after the first check. - const rgAvailable = await checkRgAvailable() + const rgAvailable = await checkRgAvailable(rootPath) if (!rgAvailable) { return searchWithGitGrep(rootPath, args, maxResults) } @@ -250,7 +250,7 @@ export function registerFilesystemHandlers(store: Store): void { let truncated = false let stdoutBuffer = '' let resolved = false - let child: ChildProcessByStdio | null = null + let child: ChildProcess | null = null const resolveOnce = (): void => { if (resolved) { @@ -280,7 +280,13 @@ export function registerFilesystemHandlers(store: Store): void { } const data = msg.data - const absPath: string = data.path.text + // Why: when rg runs inside WSL, output paths are Linux-native + // (e.g. /home/user/repo/src/file.ts). Translate them back to + // Windows UNC paths so path.relative() and Node fs APIs work. + const wslInfo = parseWslPath(rootPath) + const absPath: string = wslInfo + ? toWindowsWslPath(data.path.text, wslInfo.distro) + : data.path.text const relPath = normalizeRelativePath(relative(rootPath, absPath)) let fileResult = fileMap.get(absPath) @@ -308,12 +314,15 @@ export function registerFilesystemHandlers(store: Store): void { } } - const nextChild = spawn('rg', rgArgs, { stdio: ['ignore', 'pipe', 'pipe'] }) + const nextChild = wslAwareSpawn('rg', rgArgs, { + cwd: rootPath, + stdio: ['ignore', 'pipe', 'pipe'] + }) child = nextChild activeTextSearches.set(searchKey, nextChild) - nextChild.stdout.setEncoding('utf-8') - nextChild.stdout.on('data', (chunk: string) => { + nextChild.stdout!.setEncoding('utf-8') + nextChild.stdout!.on('data', (chunk: string) => { stdoutBuffer += chunk const lines = stdoutBuffer.split('\n') stdoutBuffer = lines.pop() ?? '' @@ -321,7 +330,7 @@ export function registerFilesystemHandlers(store: Store): void { processLine(line) } }) - nextChild.stderr.on('data', () => { + nextChild.stderr!.on('data', () => { // Drain stderr so rg cannot block on a full pipe. }) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts new file mode 100644 index 000000000..c10844680 --- /dev/null +++ b/src/main/ipc/pty.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handleMock, + onMock, + removeHandlerMock, + removeAllListenersMock, + existsSyncMock, + statSyncMock, + accessSyncMock, + spawnMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + onMock: vi.fn(), + removeHandlerMock: vi.fn(), + removeAllListenersMock: vi.fn(), + existsSyncMock: vi.fn(), + statSyncMock: vi.fn(), + accessSyncMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock, + on: onMock, + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + } +})) + +vi.mock('fs', () => ({ + existsSync: existsSyncMock, + statSync: statSyncMock, + accessSync: accessSyncMock, + constants: { + X_OK: 1 + } +})) + +vi.mock('node-pty', () => ({ + spawn: spawnMock +})) + +import { registerPtyHandlers } from './pty' + +describe('registerPtyHandlers', () => { + const handlers = new Map unknown>() + const mainWindow = { + isDestroyed: () => false, + webContents: { + on: vi.fn(), + send: vi.fn() + } + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + onMock.mockReset() + removeHandlerMock.mockReset() + removeAllListenersMock.mockReset() + existsSyncMock.mockReset() + statSyncMock.mockReset() + accessSyncMock.mockReset() + spawnMock.mockReset() + mainWindow.webContents.on.mockReset() + mainWindow.webContents.send.mockReset() + + handleMock.mockImplementation((channel, handler) => { + handlers.set(channel, handler) + }) + existsSyncMock.mockReturnValue(true) + statSyncMock.mockReturnValue({ isDirectory: () => true }) + spawnMock.mockReturnValue({ + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn() + }) + }) + + it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', () => { + const originalPlatform = process.platform + const originalUserProfile = process.env.USERPROFILE + + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + process.env.USERPROFILE = 'C:\\Users\\jinwo' + + existsSyncMock.mockImplementation((targetPath: string) => { + if (targetPath === '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') { + return false + } + return true + }) + + try { + registerPtyHandlers(mainWindow as never) + + expect(() => + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing' + }) + ).toThrow( + 'Working directory "\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing" does not exist.' + ) + expect(spawnMock).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + } + }) +}) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 29c3b245b..779c9fb17 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3,6 +3,7 @@ import { existsSync, accessSync, statSync, constants as fsConstants } from 'fs' import { type BrowserWindow, ipcMain } from 'electron' import * as pty from 'node-pty' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { parseWslPath } from '../wsl' let ptyCounter = 0 const ptyProcesses = new Map() @@ -79,16 +80,6 @@ export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRun (_event, args: { cols: number; rows: number; cwd?: string; env?: Record }) => { const id = String(++ptyCounter) - let shellPath: string - let shellArgs: string[] - if (process.platform === 'win32') { - shellPath = process.env.COMSPEC || 'powershell.exe' - shellArgs = [] - } else { - shellPath = process.env.SHELL || '/bin/zsh' - shellArgs = ['-l'] - } - const defaultCwd = process.platform === 'win32' ? process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\' @@ -96,6 +87,43 @@ export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRun const cwd = args.cwd || defaultCwd + // Why: when the working directory is inside a WSL filesystem, spawn a + // WSL shell (wsl.exe) instead of a native Windows shell. This gives the + // user a Linux environment with access to their WSL-installed tools + // (git, node, etc.) rather than a PowerShell with no WSL toolchain. + const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null + + let shellPath: string + let shellArgs: string[] + let effectiveCwd: string + let validationCwd: string + if (wslInfo) { + // Why: use `bash -c "cd ... && exec bash -l"` instead of `--cd` because + // wsl.exe's --cd flag fails with ERROR_PATH_NOT_FOUND in some Node + // spawn configurations. The exec replaces the outer bash with a login + // shell so the user gets their normal shell environment. + const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''") + shellPath = 'wsl.exe' + shellArgs = ['-d', wslInfo.distro, '--', 'bash', '-c', `cd '${escapedCwd}' && exec bash -l`] + // Why: set cwd to a valid Windows directory so node-pty's native + // spawn doesn't fail on the UNC path. + effectiveCwd = process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\' + // Why: still validate the requested WSL UNC path, not the fallback + // Windows cwd. Otherwise a deleted/mistyped WSL worktree silently + // spawns a shell in the home directory and hides the real error. + validationCwd = cwd + } else if (process.platform === 'win32') { + shellPath = process.env.COMSPEC || 'powershell.exe' + shellArgs = [] + effectiveCwd = cwd + validationCwd = cwd + } else { + shellPath = process.env.SHELL || '/bin/zsh' + shellArgs = ['-l'] + effectiveCwd = cwd + validationCwd = cwd + } + // Why: node-pty's posix_spawnp error is opaque (no errno). Pre-validate // the shell binary and cwd so we can surface actionable diagnostics // instead of a bare "posix_spawnp failed" message. @@ -115,14 +143,14 @@ export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRun } } - if (!existsSync(cwd)) { + if (!existsSync(validationCwd)) { throw new Error( - `Working directory "${cwd}" does not exist. ` + + `Working directory "${validationCwd}" does not exist. ` + `It may have been deleted or is on an unmounted volume.` ) } - if (!statSync(cwd).isDirectory()) { - throw new Error(`Working directory "${cwd}" is not a directory.`) + if (!statSync(validationCwd).isDirectory()) { + throw new Error(`Working directory "${validationCwd}" is not a directory.`) } const spawnEnv = { @@ -140,7 +168,7 @@ export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRun name: 'xterm-256color', cols: args.cols, rows: args.rows, - cwd, + cwd: effectiveCwd, env: spawnEnv }) } catch (err) { @@ -166,7 +194,7 @@ export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRun name: 'xterm-256color', cols: args.cols, rows: args.rows, - cwd, + cwd: effectiveCwd, env: spawnEnv }) // Fallback succeeded — update shellPath for the basename tracking below. @@ -184,7 +212,7 @@ export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRun if (!ptyProcess) { const diag = [ `shell: ${shellPath}`, - `cwd: ${cwd}`, + `cwd: ${effectiveCwd}`, `arch: ${process.arch}`, `platform: ${process.platform} ${process.getSystemVersion?.() ?? ''}` ].join(', ') diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 5a3e84099..4b908f1c4 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -6,8 +6,9 @@ import type { Repo } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' import { REPO_COLORS } from '../../shared/constants' import { rebuildAuthorizedRootsCache } from './filesystem-auth' -import { spawn } from 'child_process' +import type { ChildProcess } from 'child_process' import { rm } from 'fs/promises' +import { gitSpawn } from '../git/runner' import { join, basename } from 'path' import { isGitRepo, @@ -20,7 +21,7 @@ import { // Why: module-scoped so the abort handle survives window re-creation on macOS. // registerRepoHandlers is called again when a new BrowserWindow is created, // and a function-scoped variable would lose the reference to an in-flight clone. -let activeCloneProc: ReturnType | null = null +let activeCloneProc: ChildProcess | null = null let activeClonePath: string | null = null export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void { @@ -154,14 +155,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // Why: use --progress to force git to emit progress even when stderr // is not a TTY. Without it, git suppresses progress output when piped. await new Promise((resolve, reject) => { - const proc = spawn('git', ['clone', '--progress', args.url, clonePath], { + // Why: clone destination may be a WSL path (e.g. user picks a WSL + // directory). Use the parent destination as the cwd so the runner + // detects WSL and routes through wsl.exe. + const proc = gitSpawn(['clone', '--progress', args.url, clonePath], { + cwd: args.destination, stdio: ['ignore', 'ignore', 'pipe'] }) activeCloneProc = proc activeClonePath = clonePath let stderrTail = '' - proc.stderr.on('data', (chunk: Buffer) => { + proc.stderr!.on('data', (chunk: Buffer) => { const text = chunk.toString() stderrTail = (stderrTail + text).slice(-4096) diff --git a/src/main/ipc/rg-availability.ts b/src/main/ipc/rg-availability.ts index fafc7d4c9..b6cf20ae6 100644 --- a/src/main/ipc/rg-availability.ts +++ b/src/main/ipc/rg-availability.ts @@ -1,29 +1,60 @@ -import { spawn } from 'child_process' +import { wslAwareSpawn } from '../git/runner' +import { parseWslPath } from '../wsl' // Why: when rg is not installed, spawn('rg', ...) emits both 'error' and // 'close' events but their ordering is non-deterministic across Node versions // and platforms. If 'close' fires first the handler resolves with empty // results before the 'error' handler can trigger the git-grep fallback. // Checking rg availability once upfront (cached) avoids the race entirely. -let rgAvailableCache: boolean | null = null -export function checkRgAvailable(): Promise { - if (rgAvailableCache !== null) { - return Promise.resolve(rgAvailableCache) +// Why: separate caches for native Windows and each WSL distro — rg may be +// installed in one environment but not the other, and different distros +// may have different packages installed. +let rgNativeCache: boolean | null = null +const rgWslCache = new Map() + +export function checkRgAvailable(searchPath?: string): Promise { + const wslInfo = searchPath ? parseWslPath(searchPath) : null + const distro = wslInfo?.distro + + if (distro) { + const cached = rgWslCache.get(distro) + if (cached !== undefined) { + return Promise.resolve(cached) + } + } else if (rgNativeCache !== null) { + return Promise.resolve(rgNativeCache) } + return new Promise((resolve) => { - const child = spawn('rg', ['--version'], { stdio: 'ignore' }) + // Why: pass cwd so wslAwareSpawn routes through wsl.exe when the search + // path is inside a WSL filesystem. This checks whether rg is available + // inside the WSL distro rather than on the Windows PATH. + const child = wslAwareSpawn('rg', ['--version'], { + ...(searchPath ? { cwd: searchPath } : {}), + stdio: 'ignore' + }) child.once('error', () => { - rgAvailableCache = false + if (distro) { + rgWslCache.set(distro, false) + } else { + rgNativeCache = false + } resolve(false) }) child.once('close', (code) => { - if (rgAvailableCache !== null) { + const alreadyCached = distro ? rgWslCache.has(distro) : rgNativeCache !== null + if (alreadyCached) { // error handler already resolved return } - rgAvailableCache = code === 0 - resolve(rgAvailableCache) + const available = code === 0 + if (distro) { + rgWslCache.set(distro, available) + } else { + rgNativeCache = available + } + resolve(available) }) }) } diff --git a/src/main/ipc/worktree-logic-wsl.test.ts b/src/main/ipc/worktree-logic-wsl.test.ts new file mode 100644 index 000000000..12c2f5121 --- /dev/null +++ b/src/main/ipc/worktree-logic-wsl.test.ts @@ -0,0 +1,51 @@ +import { win32 } from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getWslHomeMock, parseWslPathMock } = vi.hoisted(() => ({ + getWslHomeMock: vi.fn(), + parseWslPathMock: vi.fn() +})) + +vi.mock('../wsl', () => ({ + getWslHome: getWslHomeMock, + parseWslPath: parseWslPathMock +})) + +import { computeWorktreePath } from './worktree-logic' + +describe('computeWorktreePath WSL layout', () => { + beforeEach(() => { + getWslHomeMock.mockReset() + parseWslPathMock.mockReset() + }) + + it('places WSL repo worktrees under the distro home workspace root', () => { + parseWslPathMock.mockReturnValue({ + distro: 'Ubuntu', + linuxPath: '/home/jin/src/repo' + }) + getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\jin') + + expect( + computeWorktreePath('feature', '\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\repo', { + nestWorkspaces: true, + workspaceDir: 'C:\\workspaces' + }) + ).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\orca\\workspaces\\repo\\feature') + }) + + it('falls back to the configured Windows workspace when WSL home lookup fails', () => { + parseWslPathMock.mockReturnValue({ + distro: 'Ubuntu', + linuxPath: '/home/jin/src/repo' + }) + getWslHomeMock.mockReturnValue(null) + + expect( + computeWorktreePath('feature', '\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\repo', { + nestWorkspaces: false, + workspaceDir: 'C:\\workspaces' + }) + ).toBe(win32.join('C:\\workspaces', 'feature')) + }) +}) diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index b7945f36e..d6fde883a 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -1,5 +1,6 @@ import { basename, join, resolve, relative, isAbsolute, posix, win32 } from 'path' import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types' +import { getWslHome, parseWslPath } from '../wsl' /** * Sanitize a worktree name for use in branch names and directory paths. @@ -56,17 +57,48 @@ export function computeBranchName( /** * Compute the filesystem path where the worktree directory will be created. + * + * Why WSL special case: when the repo lives on a WSL filesystem, worktrees + * must also live on the WSL filesystem. Creating them on the Windows side + * (/mnt/c/...) would be extremely slow due to cross-filesystem I/O and + * the terminal would open a Windows shell instead of WSL. We mirror the + * Windows workspace layout inside ~/orca/workspaces on the WSL filesystem + * (e.g. \\wsl.localhost\Ubuntu\home\user\orca\workspaces\repo\feature). */ export function computeWorktreePath( sanitizedName: string, repoPath: string, settings: { nestWorkspaces: boolean; workspaceDir: string } ): string { - if (settings.nestWorkspaces) { - const repoName = basename(repoPath).replace(/\.git$/, '') - return join(settings.workspaceDir, repoName, sanitizedName) + const pathOps = + looksLikeWindowsPath(repoPath) || looksLikeWindowsPath(settings.workspaceDir) + ? win32 + : { basename, join } + + const wsl = parseWslPath(repoPath) + if (wsl) { + const wslHome = getWslHome(wsl.distro) + if (wslHome) { + // Why: WSL UNC paths are still Windows paths from Node's perspective. + // On Linux CI, the default path helpers use POSIX semantics and would + // treat `\\wsl.localhost\...` as a plain string, producing mixed-separator + // paths like `\\wsl.localhost\Ubuntu\home\jin/orca/...`. Use win32 path + // operations whenever a Windows/UNC path is involved so behavior matches + // the Windows production runtime. + const wslWorkspaceDir = win32.join(wslHome, 'orca', 'workspaces') + if (settings.nestWorkspaces) { + const repoName = win32.basename(repoPath).replace(/\.git$/, '') + return win32.join(wslWorkspaceDir, repoName, sanitizedName) + } + return win32.join(wslWorkspaceDir, sanitizedName) + } } - return join(settings.workspaceDir, sanitizedName) + + if (settings.nestWorkspaces) { + const repoName = pathOps.basename(repoPath).replace(/\.git$/, '') + return pathOps.join(settings.workspaceDir, repoName, sanitizedName) + } + return pathOps.join(settings.workspaceDir, sanitizedName) } export function areWorktreePathsEqual( diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index b070656d6..80c2e5e26 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1,6 +1,5 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' -import { execFileSync } from 'child_process' import { rm } from 'fs/promises' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' @@ -13,6 +12,9 @@ import type { import { getPRForBranch } from '../github/client' import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree' import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' +import { gitExecFileSync } from '../git/runner' +import { isWslPath, parseWslPath, getWslHome } from '../wsl' +import { join } from 'path' import { listRepoWorktrees } from '../repo-worktrees' import { createSetupRunnerScript, @@ -130,7 +132,14 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store // Compute worktree path let worktreePath = computeWorktreePath(sanitizedName, repo.path, settings) - worktreePath = ensurePathWithinWorkspace(worktreePath, settings.workspaceDir) + // Why: WSL worktrees live under ~/orca/workspaces inside the WSL + // filesystem. Validate against that root, not the Windows workspace dir. + // If WSL home lookup fails, keep using the configured workspace root so + // the path traversal guard still runs on the fallback path. + const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null + const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null + const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir + worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot) // Determine base branch const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) @@ -145,11 +154,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store // Fetch latest from remote so the worktree starts with up-to-date content const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' try { - execFileSync('git', ['fetch', remote], { - cwd: repo.path, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] - }) + gitExecFileSync(['fetch', remote], { cwd: repo.path }) } catch { // Fetch is best-effort — don't block worktree creation if offline } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index eb4110d67..fc5a8c211 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,8 +1,10 @@ /* eslint-disable max-lines -- Why: the Orca runtime is the authoritative live control plane for the CLI, so handle validation, selector resolution, wait state, and summaries are kept together to avoid split-brain behavior. */ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ -import { execFileSync } from 'child_process' +import { gitExecFileSync } from '../git/runner' +import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { randomUUID } from 'crypto' +import { join } from 'path' import { rm } from 'fs/promises' import type { CreateWorktreeResult, Repo } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' @@ -595,16 +597,18 @@ export class OrcaRuntimeService { } let worktreePath = computeWorktreePath(sanitizedName, repo.path, settings) - worktreePath = ensurePathWithinWorkspace(worktreePath, settings.workspaceDir) + // Why: CLI-managed WSL worktrees live under ~/orca/workspaces inside the + // distro filesystem. If home lookup fails, still validate against the + // configured workspace dir so the traversal guard is never bypassed. + const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null + const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null + const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir + worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot) const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' try { - execFileSync('git', ['fetch', remote], { - cwd: repo.path, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] - }) + gitExecFileSync(['fetch', remote], { cwd: repo.path }) } catch { // Why: matching the editor behavior keeps CLI creation usable offline. } diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index d852b9605..e88be2120 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,3 +1,4 @@ +import { join } from 'path' import { describe, expect, it, vi } from 'vitest' vi.mock('electron', () => { @@ -22,7 +23,9 @@ describe('configureDevUserDataPath', () => { configureDevUserDataPath(true) - expect(app.setPath).toHaveBeenCalledWith('userData', '/tmp/app-data/orca-dev') + // Why: production code uses path.join(app.getPath('appData'), 'orca-dev') + // which produces platform-specific separators. + expect(app.setPath).toHaveBeenCalledWith('userData', join('/tmp/app-data', 'orca-dev')) }) it('leaves packaged runs on the default userData path', async () => { diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts new file mode 100644 index 000000000..8ea7cde75 --- /dev/null +++ b/src/main/wsl.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { toLinuxPath, toWindowsWslPath, parseWslPath } from './wsl' + +describe('wsl path helpers', () => { + it('parses WSL UNC paths on Windows', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + try { + expect(parseWslPath('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')).toEqual({ + distro: 'Ubuntu', + linuxPath: '/home/jin/repo' + }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('converts Windows drive paths to /mnt paths for WSL commands', () => { + expect(toLinuxPath('C:\\Users\\jinwo\\git\\orca')).toBe('/mnt/c/Users/jinwo/git/orca') + }) + + it('converts /mnt drive paths back to native Windows form', () => { + expect(toWindowsWslPath('/mnt/c/Users/jinwo/git/orca', 'Ubuntu')).toBe( + 'C:\\Users\\jinwo\\git\\orca' + ) + }) +}) diff --git a/src/main/wsl.ts b/src/main/wsl.ts new file mode 100644 index 000000000..e545ea512 --- /dev/null +++ b/src/main/wsl.ts @@ -0,0 +1,150 @@ +import { execFileSync } from 'child_process' + +export type WslPathInfo = { + distro: string + linuxPath: string +} + +/** + * Detect if a Windows path is a WSL UNC path and extract the distro name + * and equivalent Linux path. + * + * Why: Windows exposes WSL filesystems as UNC paths under \\wsl.localhost\\... + * (modern) or \\wsl$\\... (legacy). When a repo lives on a WSL filesystem, + * native Windows git.exe is either absent or painfully slow — all process spawning + * must be routed through `wsl.exe -d ` with Linux-native paths instead. + */ +export function parseWslPath(windowsPath: string): WslPathInfo | null { + if (process.platform !== 'win32') { + return null + } + + // Normalize backslashes to forward slashes for uniform matching + const normalized = windowsPath.replace(/\\/g, '/') + + // Match //wsl.localhost/Distro/... or //wsl$/Distro/... + const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/) + if (!match) { + return null + } + + return { + distro: match[2], + linuxPath: match[3] || '/' + } +} + +export function isWslPath(path: string): boolean { + return parseWslPath(path) !== 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. + * + * Why: WSL hook/setup environments may need both the worktree UNC path + * (\\wsl.localhost\...) and regular Windows install paths (C:\Users\...) + * translated before passing them to bash. Leaving drive paths untouched + * breaks scripts that read ORCA_ROOT_PATH or similar env vars inside WSL. + */ +export function toLinuxPath(windowsPath: string): string { + const info = parseWslPath(windowsPath) + if (info) { + return info.linuxPath + } + + const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\](.*)$/) + if (!driveMatch) { + return windowsPath + } + + const driveLetter = driveMatch[1].toLowerCase() + const rest = driveMatch[2].replace(/\\/g, '/') + return `/mnt/${driveLetter}/${rest}` +} + +/** + * Convert a Linux path inside a WSL distro to a Windows path. + * + * Why two forms: paths under /mnt//... are Windows-native filesystem + * paths that WSL exposes via the DrvFs mount. These map back to their native + * Windows form (e.g. /mnt/c/Users → C:\Users). All other paths live on the + * WSL virtual filesystem and use the UNC form (\\wsl.localhost\Distro\...). + */ +export function toWindowsWslPath(linuxPath: string, distro: string): string { + // /mnt/c/Users/... → C:\Users\... + const mntMatch = linuxPath.match(/^\/mnt\/([a-z])(\/.*)?$/) + if (mntMatch) { + const driveLetter = mntMatch[1].toUpperCase() + const rest = (mntMatch[2] || '').replace(/\//g, '\\') + return `${driveLetter}:${rest || '\\'}` + } + + return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` +} + +// ─── WSL home directory resolution ────────────────────────────────── + +const wslHomeCache = new Map() + +/** + * Get the home directory for a WSL distro, returned as a Windows UNC path. + * Result is cached per distro for the process lifetime. + * + * Why: worktrees for WSL repos are created under ~/orca/workspaces inside + * the WSL filesystem, mirroring the Windows workspace layout. We need the + * WSL user's $HOME to compute that path. + */ +export function getWslHome(distro: string): string | null { + if (wslHomeCache.has(distro)) { + return wslHomeCache.get(distro)! + } + + try { + const home = execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-c', 'echo $HOME'], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000 + }).trim() + + if (!home || !home.startsWith('/')) { + return null + } + + const uncPath = toWindowsWslPath(home, distro) + wslHomeCache.set(distro, uncPath) + return uncPath + } catch { + return null + } +} + +// Cached WSL availability check — evaluated once per process lifetime +let wslAvailableCache: boolean | null = null + +/** + * Check whether wsl.exe is available and functional on this Windows machine. + * Result is cached for the process lifetime. + */ +export function isWslAvailable(): boolean { + if (wslAvailableCache !== null) { + return wslAvailableCache + } + + if (process.platform !== 'win32') { + wslAvailableCache = false + return false + } + + try { + execFileSync('wsl.exe', ['--status'], { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000 + }) + wslAvailableCache = true + } catch { + wslAvailableCache = false + } + + return wslAvailableCache +} diff --git a/src/renderer/src/lib/setup-runner.test.ts b/src/renderer/src/lib/setup-runner.test.ts new file mode 100644 index 000000000..f78591d11 --- /dev/null +++ b/src/renderer/src/lib/setup-runner.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { buildSetupRunnerCommand } from './setup-runner' + +describe('buildSetupRunnerCommand', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses bash with a Linux path for WSL UNC runner scripts on Windows', () => { + vi.stubGlobal('navigator', { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + }) + + expect( + buildSetupRunnerCommand( + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh' + ) + ).toBe("bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh") + }) + + it('uses cmd.exe for native Windows runner scripts', () => { + vi.stubGlobal('navigator', { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + }) + + expect(buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd')).toBe( + 'cmd.exe /c "C:\\repo\\.git\\orca\\setup-runner.cmd"' + ) + }) +}) diff --git a/src/renderer/src/lib/setup-runner.ts b/src/renderer/src/lib/setup-runner.ts index 7985b4319..1dcf95868 100644 --- a/src/renderer/src/lib/setup-runner.ts +++ b/src/renderer/src/lib/setup-runner.ts @@ -1,11 +1,40 @@ +/** + * Why WSL check: on Windows, worktrees for WSL repos have setup scripts + * written as bash .sh files (not .cmd). The terminal for these worktrees + * runs bash inside WSL, so the command must invoke bash directly with the + * Linux-native path, not cmd.exe with a Windows path. + */ export function buildSetupRunnerCommand(runnerScriptPath: string): string { if (navigator.userAgent.includes('Windows')) { + if (isWslUncPath(runnerScriptPath)) { + const linuxPath = wslUncToLinuxPath(runnerScriptPath) + return `bash ${quotePosixArg(linuxPath)}` + } return `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}` } return `bash ${quotePosixArg(runnerScriptPath)}` } +/** + * Check if a path is a WSL UNC path (\\wsl.localhost\... or \\wsl$\...). + * Lightweight renderer-side check — no Node imports needed. + */ +function isWslUncPath(path: string): boolean { + const normalized = path.replace(/\\/g, '/') + return /^\/\/(wsl\.localhost|wsl\$)\//.test(normalized) +} + +/** + * Convert a WSL UNC path to its Linux equivalent. + * \\wsl.localhost\Ubuntu\home\user\file → /home/user/file + */ +function wslUncToLinuxPath(windowsPath: string): string { + const normalized = windowsPath.replace(/\\/g, '/') + const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/[^/]+(\/.*)?$/) + return match?.[2] || '/' +} + function quotePosixArg(value: string): string { if (/^[A-Za-z0-9_./:-]+$/.test(value)) { return value