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:
parent
beb62ff580
commit
f6ec69c969
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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(', ')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue