* fix(cli): preserve WSL cwd through the Windows bridge (#6965) # Conflicts: # src/cli/index.test.ts # src/cli/index.ts * fix(cli): preserve bridge exit codes (#6965) * fix(cli): harden WSL cwd bridge compatibility * chore(cli): align cwd tests with main * fix(cli): repair deleted WSL cwd before path conversion * chore: preserve main formatting after merge --------- Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
parent
53a09afbef
commit
f90cd6ebc9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue