feat: add WSL support for repos on WSL filesystems (#375)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong 2026-04-07 21:02:36 -04:00 committed by GitHub
parent beb62ff580
commit f6ec69c969
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1380 additions and 351 deletions

View File

@ -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
})
})

View File

@ -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')

View File

@ -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 })

View File

@ -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 = {

View File

@ -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<string> {
async function getDefaultBaseRefAsync(path: string): Promise<string> {
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<string>()
@ -280,10 +260,7 @@ function normalizeRefSearchQuery(query: string): string {
async function hasGitRefAsync(path: string, ref: string): Promise<boolean> {
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/<remote>/<branch>. We strip the
// first three segments so that e.g. "feature/dashboard" only matches

258
src/main/git/runner.ts Normal file
View File

@ -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 <distro>`
* 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'

View File

@ -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',

View File

@ -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<GitStatusResult>
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<GitBranchChangeEntry[]> {
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<string> {
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<string> {
}
async function resolveRefOid(worktreePath: string, ref: string): Promise<string> {
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<string> {
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<number> {
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<GitBlobReadResult> {
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<GitBlobReadResult> {
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<string, string> = {
* Stage a file.
*/
export async function stageFile(worktreePath: string, filePath: string): Promise<void> {
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<void> {
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 }))
}

View File

@ -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<GitWorktreeInfo[]> {
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`,

View File

@ -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<typeof data>[]
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<boolean> {
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) {

View File

@ -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<string> {
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<number> {
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<string[]> {
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) {

View File

@ -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) {

View File

@ -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<I
await acquire()
try {
if (ownerRepo) {
const { stdout } = await execFileAsync(
'gh',
const { stdout } = await ghExecFileAsync(
[
'api',
'--cache',
'300s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}`
],
{ cwd: repoPath, encoding: 'utf-8' }
{ cwd: repoPath }
)
const data = JSON.parse(stdout)
return mapIssueInfo(data)
}
// Fallback for non-GitHub remotes
const { stdout } = await execFileAsync(
'gh',
const { stdout } = await ghExecFileAsync(
['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels'],
{ cwd: repoPath, encoding: 'utf-8' }
{ cwd: repoPath }
)
const data = JSON.parse(stdout)
return mapIssueInfo(data)
@ -48,24 +46,22 @@ export async function listIssues(repoPath: string, limit = 20): Promise<IssueInf
await acquire()
try {
if (ownerRepo) {
const { stdout } = await execFileAsync(
'gh',
const { stdout } = await ghExecFileAsync(
[
'api',
'--cache',
'120s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc`
],
{ cwd: repoPath, encoding: 'utf-8' }
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as unknown[]
return data.map((d) => mapIssueInfo(d as Parameters<typeof mapIssueInfo>[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<typeof mapIssueInfo>[0]))

View File

@ -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
})
}
})
})

View File

@ -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', () => {

View File

@ -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<string, strin
}
function getGitPath(cwd: string, relativePath: string): string {
return execFileSync('git', ['rev-parse', '--git-path', relativePath], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
return gitExecFileSync(['rev-parse', '--git-path', relativePath], {
cwd
}).trim()
}
@ -200,27 +200,49 @@ export function createSetupRunnerScript(
script: string
): WorktreeSetupLaunch {
const envVars = getSetupEnvVars(repo, worktreePath)
const isWindows = process.platform === 'win32'
const normalizedScript = isWindows
// Why: WSL worktrees run on a Linux filesystem even though process.platform
// is 'win32'. Use bash scripts for WSL, .cmd for native Windows.
const wslWorktree = isWslPath(worktreePath)
const useWindowsFormat = process.platform === 'win32' && !wslWorktree
const normalizedScript = useWindowsFormat
? script.replace(/\r?\n/g, '\r\n')
: script.replace(/\r\n/g, '\n')
// Why: linked git worktrees use a `.git` file that points at the real gitdir,
// so writing under `${worktreePath}/.git/...` fails. `git rev-parse --git-path`
// resolves the actual per-worktree git storage path safely across platforms.
const runnerScriptPath = getGitPath(
worktreePath,
isWindows ? 'orca/setup-runner.cmd' : 'orca/setup-runner.sh'
)
const gitRelPath = useWindowsFormat ? 'orca/setup-runner.cmd' : 'orca/setup-runner.sh'
let runnerScriptPath = getGitPath(worktreePath, gitRelPath)
// Why: for WSL worktrees, getGitPath returns a Linux path (e.g. /home/user/...)
// because git runs inside WSL. Convert it to a Windows UNC path so mkdirSync
// and writeFileSync (which run on Windows) can access it.
if (wslWorktree) {
const wslInfo = parseWslPath(worktreePath)
if (wslInfo) {
runnerScriptPath = toWindowsWslPath(runnerScriptPath.trim(), wslInfo.distro)
}
}
mkdirSync(dirname(runnerScriptPath), { recursive: true })
if (isWindows) {
if (useWindowsFormat) {
writeFileSync(runnerScriptPath, buildWindowsRunnerScript(normalizedScript), 'utf-8')
} else {
writeFileSync(runnerScriptPath, `#!/usr/bin/env bash\nset -e\n${normalizedScript}\n`, 'utf-8')
// Why: chmod via UNC paths to WSL filesystem is supported by Windows and
// sets the execute bit correctly inside WSL.
chmodSync(runnerScriptPath, 0o755)
}
// Why: when the worktree is on WSL, env vars like ORCA_ROOT_PATH and
// ORCA_WORKTREE_PATH contain Windows UNC paths. The setup script runs
// inside WSL bash, so translate them to Linux paths.
if (wslWorktree) {
for (const key of Object.keys(envVars)) {
envVars[key] = toLinuxPath(envVars[key])
}
}
return { runnerScriptPath, envVars }
}
@ -239,6 +261,53 @@ export function runHook(
return Promise.resolve({ success: true, output: '' })
}
const wslInfo = parseWslPath(cwd)
if (wslInfo) {
// Why: use execFile('wsl.exe', [...]) instead of exec() to bypass the
// Windows shell (cmd.exe). exec() always routes through a shell, and
// cmd.exe doesn't understand single-quote escaping — it would mangle
// paths/scripts containing %, ^, &, |, etc.
const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''")
const escapedScript = script.replace(/'/g, "'\\''")
const bashCmd = `cd '${escapedCwd}' && ${escapedScript}`
// Why: translate ORCA_ROOT_PATH / ORCA_WORKTREE_PATH to Linux paths so
// hook scripts that reference $ORCA_WORKTREE_PATH get usable paths
// inside WSL, not Windows UNC paths.
const envVars = getSetupEnvVars(repo, cwd)
const wslEnv: Record<string, string> = {}
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,

View File

@ -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', () => ({

View File

@ -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<string[]> {
// 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<string[]> {
}
buf = start < buf.length ? buf.substring(start) : ''
})
child.stderr.on('data', () => {
child.stderr!.on('data', () => {
/* drain */
})
child.once('error', () => {

View File

@ -1,3 +1,4 @@
import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
@ -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')
})

View File

@ -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'

View File

@ -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', () => {

View File

@ -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<string, (_event: unknown, args: unknown) => Promise<unknown> | 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')
})
})
})

View File

@ -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<string, ChildProcessByStdio<null, Readable, Readable>>()
const activeTextSearches = new Map<string, ChildProcess>()
// ─── Filesystem ─────────────────────────────────────────
ipcMain.handle('fs:readDir', async (_event, args: { dirPath: string }): Promise<DirEntry[]> => {
@ -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, Readable, Readable> | 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.
})

126
src/main/ipc/pty.test.ts Normal file
View File

@ -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<string, (_event: unknown, args: unknown) => 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
}
}
})
})

View File

@ -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<string, pty.IPty>()
@ -79,16 +80,6 @@ export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRun
(_event, args: { cols: number; rows: number; cwd?: string; env?: Record<string, string> }) => {
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(', ')

View File

@ -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<typeof spawn> | 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<void>((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)

View File

@ -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<boolean> {
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<string, boolean>()
export function checkRgAvailable(searchPath?: string): Promise<boolean> {
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)
})
})
}

View File

@ -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'))
})
})

View File

@ -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(

View File

@ -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
}

View File

@ -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.
}

View File

@ -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 () => {

34
src/main/wsl.test.ts Normal file
View File

@ -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'
)
})
})

150
src/main/wsl.ts Normal file
View File

@ -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\<Distro>\...
* (modern) or \\wsl$\<Distro>\... (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 <distro>` 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/<drive>/... 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<string, string>()
/**
* 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
}

View File

@ -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"'
)
})
})

View File

@ -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