Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions src/cli/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isAbsolute, relative, resolve as resolvePath } from 'node:path'
import { resolve as resolvePath } from 'node:path'
import type {
ComputerAppQuery,
RuntimeWorktreeListResult,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
33 changes: 31 additions & 2 deletions src/main/cli/wsl-cli-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]')
})

Expand Down Expand Up @@ -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', () => {
Expand Down
36 changes: 28 additions & 8 deletions src/main/cli/wsl-cli-scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
& $OrcaLauncher @ForwardArgs
if (-not $?) {
exit 1
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 ($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
`
}

Expand Down
33 changes: 33 additions & 0 deletions src/shared/cross-platform-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
21 changes: 13 additions & 8 deletions src/shared/cross-platform-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 ''
Expand All @@ -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 {
Expand Down
Loading