diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 93623967e..008ab2eb7 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -1,4 +1,4 @@ -import { isAbsolute, relative, resolve as resolvePath } from 'node:path' +import { resolve as resolvePath } from 'node:path' import type { ComputerAppQuery, RuntimeWorktreeListResult, @@ -43,14 +43,6 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient) ) } -function isWithinPath(parentPath: string, childPath: string): boolean { - if (isPathInsideOrEqual(parentPath, childPath)) { - return true - } - const relativePath = relative(parentPath, childPath) - return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) -} - export async function resolveCurrentWorktreeSelector( cwd: string, client: RuntimeClient @@ -65,7 +57,10 @@ export async function resolveCurrentWorktreeSelector( let enclosingPathLength = -1 for (const worktree of worktrees.result.worktrees) { const worktreePath = resolvePath(worktree.path) - if (!isWithinPath(worktreePath, currentPath) || worktreePath.length <= enclosingPathLength) { + if ( + !isPathInsideOrEqual(worktreePath, currentPath) || + worktreePath.length <= enclosingPathLength + ) { continue } enclosingWorktree = worktree diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 6324e7517..547679df4 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -194,8 +194,15 @@ describe('WslCliInstaller', () => { ) expect(wsl.getBridge()).toBe(_internals.buildWslBridgeScript()) const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"')) + expect(installCommand).toBeDefined() expect(installCommand).toContain("legacy_command_path='/home/alice/.local/bin/orca'") expect(installCommand).toContain('rm -f "$legacy_command_path"') + // Why: the new bridge accepts the old launcher's positional arguments, so + // publishing it first keeps interrupted upgrades usable. + const bridgePublishIndex = installCommand?.indexOf('mv -f "$bridge_tmp"') ?? -1 + const launcherPublishIndex = installCommand?.indexOf('mv -f "$command_tmp"') ?? -1 + expect(bridgePublishIndex).toBeGreaterThan(-1) + expect(bridgePublishIndex).toBeLessThan(launcherPublishIndex) expect(installCommand).toContain('[ ! -L "$legacy_command_path" ]') }) @@ -296,12 +303,34 @@ describe('WslCliInstaller', () => { 'Orca WSL CLI requires Windows interop and could not find powershell.exe.' ) expect(launcher).toContain('"$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File') - expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" "$@"') + expect(launcher).toContain('ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || {') + expect(launcher).toContain('ORCA_WSL_CWD=/') + expect(launcher).toContain('cd /') + expect(launcher).toContain('ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD")') + expect(launcher.indexOf('ORCA_WSL_CWD=$(pwd -P')).toBeLessThan( + launcher.indexOf('ORCA_BRIDGE_PS1_WIN=$(wslpath') + ) + expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@"') expect(launcher).not.toContain('-Command') + expect(bridge).toContain('[CmdletBinding(PositionalBinding=$false)]') + expect(bridge).toContain('[Parameter(Mandatory=$true, Position=0)]') + expect(bridge).toContain('[string]$WslCwd') expect(bridge).toContain('[Parameter(ValueFromRemainingArguments=$true)]') + expect(bridge).toContain('if ([string]::IsNullOrEmpty($WslCwd))') + expect(bridge).toContain('$env:ORCA_CLI_CWD = $WslCwd') + expect(bridge).toContain('Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)') expect(bridge).toContain('& $OrcaLauncher @ForwardArgs') + const nullExitCodeBranch = bridge.indexOf('if ($null -eq $LASTEXITCODE)') + const invocationFailureBranch = bridge.indexOf('if (-not $?)') + expect(nullExitCodeBranch).toBeGreaterThan(-1) + // Why: native launchers can set a non-zero LASTEXITCODE while $? is false; + // checking the native status first preserves that specific exit code. + expect(nullExitCodeBranch).toBeLessThan(invocationFailureBranch) + expect(bridge).toContain('$exitCode = $LASTEXITCODE') + expect(bridge).toContain('Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue') expect(bridge).toContain('catch') - expect(bridge).toContain('exit 1') + expect(bridge).toContain('$exitCode = 1') + expect(bridge).toContain('exit $exitCode') }) it('wraps WSL bash scripts as a single encoded command line', () => { diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 136a2a682..3297b9be9 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -20,34 +20,54 @@ else echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2 exit 1 fi +# Why: a shell can outlive a deleted worktree; keep explicit CLI selectors and +# help usable, and repair cwd before any WSL interop tool tries to resolve it. +ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || { + ORCA_WSL_CWD=/ + cd / +} ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1") -exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@" +ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD") +exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@" ` } export function buildWslBridgeScript(): string { return `${BRIDGE_MANAGED_MARKER} +[CmdletBinding(PositionalBinding=$false)] param( - [Parameter(Mandatory=$true)] + [Parameter(Mandatory=$true, Position=0)] [string]$OrcaLauncher, + [string]$WslCwd, + [Parameter(ValueFromRemainingArguments=$true)] [string[]]$ForwardArgs ) +$exitCode = 0 try { + if ([string]::IsNullOrEmpty($WslCwd)) { + Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue + } else { + $env:ORCA_CLI_CWD = $WslCwd + } + Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher) & $OrcaLauncher @ForwardArgs - if (-not $?) { - exit 1 - } if ($null -eq $LASTEXITCODE) { - exit 0 + if (-not $?) { + $exitCode = 1 + } else { + $exitCode = 0 + } + } else { + $exitCode = $LASTEXITCODE } - exit $LASTEXITCODE } catch { Write-Error $_ - exit 1 + $exitCode = 1 } +exit $exitCode ` } diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts index 085bf5682..15777065b 100644 --- a/src/shared/cross-platform-path.test.ts +++ b/src/shared/cross-platform-path.test.ts @@ -29,6 +29,39 @@ describe('cross-platform path containment', () => { expect(isPathInsideOrEqual('\\\\Server\\Share\\Repo', '\\\\server\\share\\repo2')).toBe(false) }) + it('treats WSL UNC aliases as the same case-sensitive filesystem', () => { + expect( + isPathInsideOrEqual( + '\\\\wsl$\\Ubuntu\\home\\Alice\\repo', + '\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\src' + ) + ).toBe(true) + expect( + relativePathInsideRoot( + '\\\\wsl$\\Ubuntu\\home\\Alice\\repo', + '\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\Src' + ) + ).toBe('Src') + expect( + isPathInsideOrEqual( + '\\\\wsl$\\Ubuntu\\home\\Alice\\repo', + '\\\\wsl.localhost\\ubuntu\\home\\alice\\repo\\src' + ) + ).toBe(false) + expect( + relativePathInsideRoot( + '\\\\wsl$\\Ubuntu\\home\\Alice\\repo', + '\\\\wsl.localhost\\ubuntu\\home\\alice\\repo\\src' + ) + ).toBeNull() + expect( + relativePathInsideRoot( + '\\\\wsl$\\Ubuntu\\home\\Alice\\repo', + '\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\line\nbreak' + ) + ).toBe('line\nbreak') + }) + it('resolves POSIX relative paths without using the process cwd', () => { expect(resolveRuntimePath('/repos/app/repo', '../worktrees/feature')).toBe( '/repos/app/worktrees/feature' diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index 7fb3bd8ca..e82bd9d24 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -12,6 +12,12 @@ export function normalizeRuntimePathSeparators(value: string): string { export function normalizeRuntimePathForComparison(value: string): string { const normalized = trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(value)) + const wslUnc = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(\/[\s\S]*)?$/i) + if (wslUnc) { + // Why: Windows exposes the same case-sensitive WSL filesystem through two + // UNC aliases, while the distro/server portion remains case-insensitive. + return `//wsl/${wslUnc[1].toLowerCase()}${wslUnc[2] ?? ''}` + } return isWindowsAbsolutePathLike(value) ? normalized.toLowerCase() : normalized } @@ -57,16 +63,11 @@ export function isPathInsideOrEqual(rootPath: string, candidatePath: string): bo } export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null { - const normalizedRoot = trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(rootPath)) const normalizedCandidate = trimRuntimePathTrailingSlash( normalizeRuntimePathSeparators(candidatePath) ) - const comparisonRoot = isWindowsAbsolutePathLike(rootPath) - ? normalizedRoot.toLowerCase() - : normalizedRoot - const comparisonCandidate = isWindowsAbsolutePathLike(rootPath) - ? normalizedCandidate.toLowerCase() - : normalizedCandidate + const comparisonRoot = normalizeRuntimePathForComparison(rootPath) + const comparisonCandidate = normalizeRuntimePathForComparison(candidatePath) if (comparisonCandidate === comparisonRoot) { return '' @@ -76,7 +77,11 @@ export function relativePathInsideRoot(rootPath: string, candidatePath: string): if (!comparisonCandidate.startsWith(comparisonPrefix)) { return null } - return normalizedCandidate.slice(comparisonPrefix.length) + // WSL comparison keys fold the UNC alias but preserve Linux path casing, so + // their suffix is both aligned across aliases and safe to return directly. + return comparisonRoot.startsWith('//wsl/') + ? comparisonCandidate.slice(comparisonPrefix.length) + : normalizedCandidate.slice(comparisonPrefix.length) } function trimRuntimePathTrailingSlash(value: string): string {