fix(cli): resolve a WSL worktree by the Linux path its own shell prints (#16628) (#17440)

On a Windows host the runtime stores a WSL worktree as the UNC path Windows
sees, but a user inside the distro types the Linux spelling, so every `path:`
selector missed: `worktree show`, `terminal list --worktree` and
`worktree rm --worktree` all reported selector_not_found for a directory Orca
manages.

Translate once in the CLI, which is the only side that can prove which distro
the typed path belongs to — from its own UNC cwd, never from WSL_DISTRO_NAME,
which a Linux-native CLI also sets. The runtime's `path:` branch stays
exact-spelling-only for the same reason: this resolver feeds delete, so a
tail-only match would remove another distro's copy.
This commit is contained in:
Neil
2026-08-30 14:42:20 -07:00
committed by GitHub
parent 1268fb56f1
commit 5bd66bac8b
6 changed files with 478 additions and 5 deletions
+56 -5
View File
@@ -4,7 +4,8 @@ import type {
RuntimeWorktreeListResult,
RuntimeWorktreeRecord
} from '../shared/runtime-types'
import { isPathInsideOrEqual } from '../shared/cross-platform-path'
import { isPathInsideOrEqual, isWslUncPathForCallerLinuxPath } from '../shared/cross-platform-path'
import { parseWslUncPath } from '../shared/wsl-paths'
import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime/types'
import { getOptionalStringFlag, getRequiredStringFlag } from './flags'
@@ -31,6 +32,56 @@ export function normalizeWorktreeSelector(selector: string, cwd: string): string
return selector
}
/**
* Rewrites the Linux path a WSL shell prints into the UNC path the runtime stored (#16628).
*
* Why here and not in the runtime: only the CLI sits in the distro, so only it can
* prove which distro the typed path belongs to. Translating once at this chokepoint
* covers `worktree show`, `terminal list --worktree` and `worktree rm --worktree`.
*/
export async function resolveCallerDistroPathSelector(
selector: string,
cwd: string,
client: RuntimeClient
): Promise<string> {
// Why not WSL_DISTRO_NAME: it is also set for a Linux-native CLI whose runtime stores
// POSIX paths, so it would name a distro for a caller that has none. ORCA_CLI_CWD, which
// the WSL launcher always sets and which arrives here as the invocation cwd, proves it.
const callerDistro = parseWslUncPath(cwd)?.distro
const linuxPath = selector.startsWith('path:') ? selector.slice(5) : ''
if (
!callerDistro ||
client.isRemote ||
!linuxPath.startsWith('/') ||
linuxPath.startsWith('//') ||
// Backslash is a legal Linux filename character but a separator once a path reads as UNC.
linuxPath.includes('\\')
) {
return selector
}
const worktrees = await client.call<RuntimeWorktreeListResult>('worktree.list', {
limit: 10_000
})
const match = worktrees.result.worktrees.find((worktree) =>
isWslUncPathForCallerLinuxPath(worktree.path, linuxPath, callerDistro)
)
// Why the stored spelling rather than a synthesized UNC path: an unmatched selector must
// reach the runtime verbatim and fail as the caller typed it, never as a guessed distro.
return match ? `path:${match.path}` : selector
}
export async function normalizeWorktreeSelectorForCaller(
selector: string,
cwd: string,
client: RuntimeClient
): Promise<string> {
return await resolveCallerDistroPathSelector(
normalizeWorktreeSelector(selector, cwd),
cwd,
client
)
}
function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient): void {
if (!client.isRemote) {
return
@@ -95,7 +146,7 @@ export async function getOptionalWorktreeSelector(
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
return await normalizeWorktreeSelectorForCaller(value, cwd, client)
}
export async function getRequiredWorktreeSelector(
@@ -109,7 +160,7 @@ export async function getRequiredWorktreeSelector(
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
return await normalizeWorktreeSelectorForCaller(value, cwd, client)
}
// Why: local browser commands default to the current worktree by auto-resolving
@@ -128,7 +179,7 @@ export async function getBrowserWorktreeSelector(
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
return await normalizeWorktreeSelectorForCaller(value, cwd, client)
}
if (client.isRemote) {
return undefined
@@ -184,7 +235,7 @@ export async function getBrowserCommandTarget(
}
return {
page,
worktree: normalizeWorktreeSelector(explicitWorktree, cwd)
worktree: await normalizeWorktreeSelectorForCaller(explicitWorktree, cwd, client)
}
}
@@ -0,0 +1,120 @@
/**
* The CLI half of #16628: inside WSL the user types `/home/neil/qa-repo`, the runtime stored the
* UNC path Windows sees. Only this process sits in the distro, so only it may translate — and the
* same selector reaches `worktree rm`, so an unprovable distro must leave the path alone.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeWorktreeRecord } from '../shared/runtime-types'
import type { RuntimeClient } from './runtime-client'
import { normalizeWorktreeSelectorForCaller } from './selectors'
const UBUNTU = 'Ubuntu-24.04'
const DEBIAN = 'Debian'
const LINUX_PATH = '/home/neil/qa-repo'
function uncPath(distro: string, linuxPath: string): string {
return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
}
function worktreeRecord(path: string): RuntimeWorktreeRecord {
return { id: `repo::${path}`, path } as RuntimeWorktreeRecord
}
function makeClient(paths: readonly string[], isRemote = false) {
const call = vi.fn(async (method: string) => {
if (method !== 'worktree.list') {
throw new Error(`unexpected method ${method}`)
}
return {
result: { worktrees: paths.map(worktreeRecord), totalCount: paths.length, truncated: false }
}
})
return { client: { isRemote, call } as unknown as RuntimeClient, call }
}
afterEach(() => {
vi.unstubAllEnvs()
})
describe('normalizeWorktreeSelectorForCaller in a WSL shell (#16628)', () => {
it.each([
['a backslash UNC registration', uncPath(UBUNTU, LINUX_PATH)],
['a forward-slash UNC registration', `//wsl.localhost/${UBUNTU}${LINUX_PATH}`],
['the wsl$ alias', `\\\\wsl$\\${UBUNTU}${LINUX_PATH.replace(/\//g, '\\')}`]
])('resolves the typed Linux path to %s', async (_label, storedPath) => {
const { client } = makeClient([storedPath])
await expect(
normalizeWorktreeSelectorForCaller(
`path:${LINUX_PATH}`,
uncPath(UBUNTU, '/home/neil'),
client
)
).resolves.toBe(`path:${storedPath}`)
})
it('leaves the path alone when only another distro spells it', async () => {
const { client } = makeClient([uncPath(UBUNTU, LINUX_PATH)])
// Interop forwards WSL_DISTRO_NAME, so it must not outvote the cwd that proves the distro.
vi.stubEnv('WSL_DISTRO_NAME', UBUNTU)
await expect(
normalizeWorktreeSelectorForCaller(
`path:${LINUX_PATH}`,
uncPath(DEBIAN, '/home/neil'),
client
)
).resolves.toBe(`path:${LINUX_PATH}`)
})
it('picks the caller-distro worktree when two distros spell the same Linux path', async () => {
const { client } = makeClient([uncPath(UBUNTU, LINUX_PATH), uncPath(DEBIAN, LINUX_PATH)])
await expect(
normalizeWorktreeSelectorForCaller(
`path:${LINUX_PATH}`,
uncPath(DEBIAN, '/home/neil'),
client
)
).resolves.toBe(`path:${uncPath(DEBIAN, LINUX_PATH)}`)
})
it.each([
['a Linux-native cwd', '/home/neil', `path:${LINUX_PATH}`],
['a Windows drive cwd', 'C:\\Users\\neil', `path:${LINUX_PATH}`]
])('never lists worktrees for %s', async (_label, cwd, selector) => {
const { client, call } = makeClient([uncPath(UBUNTU, LINUX_PATH)])
vi.stubEnv('WSL_DISTRO_NAME', UBUNTU)
await expect(normalizeWorktreeSelectorForCaller(selector, cwd, client)).resolves.toBe(selector)
expect(call).not.toHaveBeenCalled()
})
it.each([
['a branch selector', 'branch:qa'],
['a relative path', 'path:qa-repo'],
// A UNC path is already the runtime's spelling; a backslash inside a Linux path has no UNC form.
['a UNC path selector', `path:${uncPath(UBUNTU, LINUX_PATH)}`],
['a Linux path containing a backslash', 'path:/home/neil/qa\\repo']
])('leaves %s untranslated', async (_label, selector) => {
const { client, call } = makeClient([uncPath(UBUNTU, LINUX_PATH)])
await expect(
normalizeWorktreeSelectorForCaller(selector, uncPath(UBUNTU, '/home/neil'), client)
).resolves.toBe(selector)
expect(call).not.toHaveBeenCalled()
})
it('leaves a remote runtime alone, whose worktrees the caller cwd cannot name', async () => {
const { client, call } = makeClient([uncPath(UBUNTU, LINUX_PATH)], true)
await expect(
normalizeWorktreeSelectorForCaller(
`path:${LINUX_PATH}`,
uncPath(UBUNTU, '/home/neil'),
client
)
).resolves.toBe(`path:${LINUX_PATH}`)
expect(call).not.toHaveBeenCalled()
})
})
+2
View File
@@ -33860,6 +33860,8 @@ export class OrcaRuntimeService {
}
}
} else if (selector.startsWith('path:')) {
// Why exact-spelling-only (#16628): a Linux path names a directory in *some* WSL distro, and
// only the CLI sits in one and can prove which. Guessing here would delete a stranger's.
candidates = worktrees.filter((worktree) =>
runtimePathsEqual(worktree.path, selector.slice(5))
)
@@ -0,0 +1,226 @@
/**
* A WSL shell prints `/home/neil/qa-repo`; the runtime stored the same directory as the UNC path
* Windows sees (#16628). The CLI translates, proving the caller's distro from its own UNC cwd —
* this resolver also feeds `worktree rm`, so a tail-only match would delete another distro's copy.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const electronMocks = vi.hoisted(() => {
const ipcMain = {
on: vi.fn(() => ipcMain),
removeListener: vi.fn(() => ipcMain),
emit: vi.fn(() => true)
}
return {
BrowserWindow: { fromId: vi.fn((): unknown => null) },
webContents: { fromId: vi.fn((): unknown => null) },
ipcMain,
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
}
})
vi.mock('electron', () => electronMocks)
const getSshGitProviderMock = vi.hoisted(() => vi.fn())
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: vi.fn(() => 0),
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable',
requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId)
}))
const listWorktreesStrictMock = vi.hoisted(() => vi.fn())
vi.mock('../git/worktree', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
listWorktreesStrict: listWorktreesStrictMock
}))
import { isWslUncPathForCallerLinuxPath } from '../../shared/cross-platform-path'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { OrcaRuntimeService } from './orca-runtime'
const UBUNTU = 'Ubuntu-24.04'
const DEBIAN = 'Debian'
const LINUX_REPO_PATH = '/home/neil/repo'
const LINUX_WORKTREE_PATH = '/home/neil/qa-repo'
/** The cwd the WSL launcher hands the CLI: the caller's directory, already in UNC form. */
function uncPath(distro: string, linuxPath: string): string {
return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
}
type Registration = { distro: string; repoPath: string; worktreePath: string }
function registration(distro: string, spelling: (linuxPath: string) => string): Registration {
return {
distro,
repoPath: spelling(LINUX_REPO_PATH),
worktreePath: spelling(LINUX_WORKTREE_PATH)
}
}
/** One repo per distro, so an unwanted cross-distro match shows up as the wrong resolved path. */
function makeStore(registrations: readonly Registration[]) {
const repos = registrations.map((entry) => ({
id: `repo-${entry.distro.toLowerCase()}`,
path: entry.repoPath,
displayName: entry.distro,
badgeColor: 'blue',
addedAt: 1
}))
const store = {
getRepo: (id: string) => repos.find((repo) => repo.id === id),
getRepos: () => repos,
getAllWorktreeMeta: vi.fn(() => ({})),
getWorktreeMeta: () => undefined,
setWorktreeMeta: vi.fn(),
removeWorktreeMeta: () => {},
getAllWorktreeLineage: () => ({}),
getAllWorkspaceLineage: () => ({}),
removeWorktreeLineage: vi.fn(),
removeWorkspaceLineage: vi.fn(),
getGitHubCache: () => undefined as never,
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: ''
}),
getProjects: () => []
}
return store
}
/** `git worktree list` for each registered repo: the main checkout plus one workspace. */
function scanReports(registrations: readonly Registration[]): void {
listWorktreesStrictMock.mockImplementation(async (repoPath: string) => {
const entry = registrations.find((candidate) => candidate.repoPath === repoPath)
if (!entry) {
return []
}
return [
{ path: entry.repoPath, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true },
{
path: entry.worktreePath,
head: 'def',
branch: 'qa',
isBare: false,
isMainWorktree: false
}
]
})
}
function makeRuntime(registrations: readonly Registration[]): OrcaRuntimeService {
scanReports(registrations)
return new OrcaRuntimeService(makeStore(registrations) as never)
}
/**
* What `resolveCallerDistroPathSelector` sends: the caller's distro comes from its own UNC cwd,
* and the stored spelling of the one worktree that distro can mean replaces the typed path.
* Mirrored rather than imported — `src/cli` is outside this file's tsconfig project.
*/
async function selectorTheCliWouldSend(
runtime: OrcaRuntimeService,
callerCwd: string,
typedPath: string
): Promise<string> {
const callerDistro = parseWslUncPath(callerCwd)?.distro
if (!callerDistro) {
return `path:${typedPath}`
}
const listed = await runtime.listManagedWorktrees()
const match = listed.worktrees.find((worktree) =>
isWslUncPathForCallerLinuxPath(worktree.path, typedPath, callerDistro)
)
return match ? `path:${match.path}` : `path:${typedPath}`
}
beforeEach(() => {
getSshGitProviderMock.mockReset()
listWorktreesStrictMock.mockReset()
})
describe('a WSL caller typing the Linux path of a UNC-stored worktree (#16628)', () => {
it('resolves the Linux path a WSL shell prints to the UNC-stored worktree', async () => {
const registrations = [registration(UBUNTU, (linuxPath) => uncPath(UBUNTU, linuxPath))]
const runtime = makeRuntime(registrations)
// The live bug: the spelling the runtime stored resolves, the one the user types does not.
await expect(
runtime.showManagedWorktree(`path:${uncPath(UBUNTU, LINUX_WORKTREE_PATH)}`)
).resolves.toMatchObject({ path: uncPath(UBUNTU, LINUX_WORKTREE_PATH) })
await expect(runtime.showManagedWorktree(`path:${LINUX_WORKTREE_PATH}`)).rejects.toThrow(
'selector_not_found'
)
const selector = await selectorTheCliWouldSend(
runtime,
uncPath(UBUNTU, '/home/neil'),
LINUX_WORKTREE_PATH
)
await expect(runtime.showManagedWorktree(selector)).resolves.toMatchObject({
path: uncPath(UBUNTU, LINUX_WORKTREE_PATH)
})
})
it('resolves a forward-slash UNC registration from the same Linux path', async () => {
// Windows records the same share both ways; the caller's spelling never changes.
const forwardSlash = (linuxPath: string) => `//wsl.localhost/${UBUNTU}${linuxPath}`
const runtime = makeRuntime([registration(UBUNTU, forwardSlash)])
const selector = await selectorTheCliWouldSend(
runtime,
uncPath(UBUNTU, '/home/neil'),
LINUX_WORKTREE_PATH
)
await expect(runtime.showManagedWorktree(selector)).resolves.toMatchObject({
path: forwardSlash(LINUX_WORKTREE_PATH)
})
})
it('refuses a Linux path another distro spells, instead of resolving that distro', async () => {
const runtime = makeRuntime([registration(UBUNTU, (linuxPath) => uncPath(UBUNTU, linuxPath))])
const selector = await selectorTheCliWouldSend(
runtime,
uncPath(DEBIAN, '/home/neil'),
LINUX_WORKTREE_PATH
)
// Untranslated, so the runtime refuses rather than deleting Ubuntu's copy on a Debian `rm`.
expect(selector).toBe(`path:${LINUX_WORKTREE_PATH}`)
await expect(runtime.showManagedWorktree(selector)).rejects.toThrow('selector_not_found')
})
it('picks the caller-distro worktree when two distros spell the same Linux path', async () => {
const runtime = makeRuntime([
registration(UBUNTU, (linuxPath) => uncPath(UBUNTU, linuxPath)),
registration(DEBIAN, (linuxPath) => uncPath(DEBIAN, linuxPath))
])
const selector = await selectorTheCliWouldSend(
runtime,
uncPath(DEBIAN, '/home/neil'),
LINUX_WORKTREE_PATH
)
await expect(runtime.showManagedWorktree(selector)).resolves.toMatchObject({
path: uncPath(DEBIAN, LINUX_WORKTREE_PATH)
})
})
it('leaves a Linux-native CLI, whose runtime stores POSIX paths, untouched', async () => {
const posix = (linuxPath: string) => linuxPath
const runtime = makeRuntime([registration(UBUNTU, posix)])
const selector = await selectorTheCliWouldSend(runtime, '/home/neil', LINUX_WORKTREE_PATH)
expect(selector).toBe(`path:${LINUX_WORKTREE_PATH}`)
await expect(runtime.showManagedWorktree(selector)).resolves.toMatchObject({
path: LINUX_WORKTREE_PATH
})
})
})
+49
View File
@@ -4,6 +4,7 @@ import {
isCaseInsensitiveRuntimeRoot,
isPathInsideOrEqual,
isRuntimePathAbsolute,
isWslUncPathForCallerLinuxPath,
normalizeRuntimePathForComparison,
relativePathInsideRoot,
resolveRuntimePath
@@ -50,6 +51,54 @@ describe('isCaseInsensitiveRuntimeRoot', () => {
})
})
describe('isWslUncPathForCallerLinuxPath', () => {
const UBUNTU = 'Ubuntu-24.04'
it('matches the Linux path a WSL shell prints against both UNC spellings of its own distro', () => {
expect(
isWslUncPathForCallerLinuxPath(
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\neil\\qa-repo',
'/home/neil/qa-repo',
UBUNTU
)
).toBe(true)
expect(
isWslUncPathForCallerLinuxPath(
'//wsl$/ubuntu-24.04/home/neil/qa-repo',
'/home/neil/qa-repo',
UBUNTU
)
).toBe(true)
})
// The destructive case: this feeds `worktree rm`, so a tail-only match deletes the wrong distro.
it('refuses a Linux path another distro spells identically', () => {
expect(
isWslUncPathForCallerLinuxPath(
'\\\\wsl.localhost\\Debian\\home\\neil\\qa-repo',
'/home/neil/qa-repo',
UBUNTU
)
).toBe(false)
})
it('keeps the Linux tail case-sensitive and refuses a non-WSL path', () => {
expect(
isWslUncPathForCallerLinuxPath(
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\Neil\\qa-repo',
'/home/neil/qa-repo',
UBUNTU
)
).toBe(false)
expect(isWslUncPathForCallerLinuxPath('/home/neil/qa-repo', '/home/neil/qa-repo', UBUNTU)).toBe(
false
)
expect(isWslUncPathForCallerLinuxPath('C:\\repos\\qa-repo', '/home/neil/qa-repo', UBUNTU)).toBe(
false
)
})
})
describe('cross-platform path containment', () => {
it('keeps POSIX sibling prefixes outside the root', () => {
expect(isPathInsideOrEqual('/repo/app', '/repo/app')).toBe(true)
+25
View File
@@ -56,6 +56,31 @@ export function normalizeRuntimePathForComparison(rawValue: string): string {
return isWindowsPath ? normalized.toLowerCase() : normalized
}
/**
* Whether `uncPath` is the WSL UNC spelling of `linuxPath` as the caller's own distro sees it.
*
* Why the distro must match and not just the Linux tail: every distro spells
* `/home/<user>/repo`, so a tail-only match lets a Debian caller resolve — and
* `worktree rm` then delete — an Ubuntu directory. The distro is proven by the
* caller's UNC cwd, never guessed.
*/
export function isWslUncPathForCallerLinuxPath(
uncPath: string,
linuxPath: string,
callerDistro: string
): boolean {
const parsed = parseWslUncPath(uncPath)
if (!parsed) {
return false
}
// Why the case split: Windows folds the distro name, the Linux tail it fronts is case-sensitive.
return (
parsed.distro.toLowerCase() === callerDistro.toLowerCase() &&
normalizeRuntimePathForComparison(parsed.linuxPath) ===
normalizeRuntimePathForComparison(linuxPath)
)
}
export function areLocalWindowsWslPathAliases(left: string, right: string): boolean {
const leftIdentity = getLocalWindowsWslPathIdentity(left)
const rightIdentity = getLocalWindowsWslPathIdentity(right)