fix(windows): git path resolution for modified files across Git Bash, MSYS2, and Cygwin (#16422)

This commit is contained in:
Luke Parker
2026-03-07 15:42:14 +10:00
committed by GitHub
parent c42c5a0cc6
commit 8a95be492d
13 changed files with 286 additions and 102 deletions

View File

@@ -3,7 +3,7 @@ import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { execSync } from "node:child_process" import { execSync } from "node:child_process"
import { modKey, serverUrl } from "./utils" import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import { import {
dropdownMenuTriggerSelector, dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
@@ -18,7 +18,6 @@ import {
workspaceItemSelector, workspaceItemSelector,
workspaceMenuTriggerSelector, workspaceMenuTriggerSelector,
} from "./selectors" } from "./selectors"
import type { createSdk } from "./utils"
export async function defocus(page: Page) { export async function defocus(page: Page) {
await page await page
@@ -190,7 +189,7 @@ export async function createTestProject() {
stdio: "ignore", stdio: "ignore",
}) })
return root return resolveDirectory(root)
} }
export async function cleanupTestProject(directory: string) { export async function cleanupTestProject(directory: string) {

View File

@@ -9,6 +9,26 @@ function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
} }
async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
return ""
}
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return slugFromUrl(page.url())
}
async function waitWorkspaceReady(page: Page, slug: string) { async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page) await openSidebar(page)
await expect await expect
@@ -31,20 +51,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page) await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
await expect const slug = await waitSlug(page, [root, ...seen])
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (slug === root) return ""
if (seen.includes(slug)) return ""
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
const slug = slugFromUrl(page.url())
const directory = base64Decode(slug) const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory } return { slug, directory }
@@ -60,12 +67,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
await expect(button).toBeVisible() await expect(button).toBeVisible()
await button.click({ force: true }) await button.click({ force: true })
await expect.poll(() => slugFromUrl(page.url())).toBe(slug) const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
return next
} }
async function createSessionFromWorkspace(page: Page, slug: string, text: string) { async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
await openWorkspaceNewSession(page, slug) const next = await openWorkspaceNewSession(page, slug)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
@@ -76,13 +84,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter") await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(slug) await expect.poll(() => slugFromUrl(page.url())).toBe(next)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url()) const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
return sessionID return { sessionID, slug: next }
} }
async function sessionDirectory(directory: string, sessionID: string) { async function sessionDirectory(directory: string, sessionID: string) {
@@ -114,17 +122,17 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
await waitWorkspaceReady(page, second.slug) await waitWorkspaceReady(page, second.slug)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
sessions.push(firstSession) sessions.push(firstSession.sessionID)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`) const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
sessions.push(secondSession) sessions.push(secondSession.sessionID)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`) const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
sessions.push(thirdSession) sessions.push(thirdSession.sessionID)
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory) await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory) await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory) await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
} finally { } finally {
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)] const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
await Promise.all( await Promise.all(

View File

@@ -22,6 +22,26 @@ function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
} }
async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
return ""
}
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return slugFromUrl(page.url())
}
async function setupWorkspaceTest(page: Page, project: { slug: string }) { async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug const rootSlug = project.slug
await openSidebar(page) await openSidebar(page)
@@ -29,17 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true) await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
await expect const slug = await waitSlug(page, [rootSlug])
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug) const dir = base64Decode(slug)
await openSidebar(page) await openSidebar(page)
@@ -91,18 +101,7 @@ test("can create a workspace", async ({ page, withProject }) => {
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const workspaceSlug = await waitSlug(page, [slug])
await expect
.poll(
() => {
const currentSlug = slugFromUrl(page.url())
return currentSlug.length > 0 && currentSlug !== slug
},
{ timeout: 45_000 },
)
.toBe(true)
const workspaceSlug = slugFromUrl(page.url())
const workspaceDir = base64Decode(workspaceSlug) const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page) await openSidebar(page)
@@ -279,7 +278,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
await clickMenuItem(menu, /^Delete$/i, { force: true }) await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i) await confirmDialog(page, /^Delete workspace$/i)
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect await expect
.poll( .poll(

View File

@@ -14,6 +14,12 @@ export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true }) return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
} }
export async function resolveDirectory(directory: string) {
return createSdk(directory)
.path.get()
.then((x) => x.data?.directory ?? directory)
}
export async function getWorktree() { export async function getWorktree() {
const sdk = createSdk() const sdk = createSdk()
const result = await sdk.path.get() const result = await sdk.path.get()

View File

@@ -1,26 +1,27 @@
import { createEffect, createMemo, Show, type ParentProps } from "solid-js" import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { useGlobalSDK } from "@/context/global-sdk"
import { DataProvider } from "@opencode-ai/ui/context" import { DataProvider } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const sync = useSync() const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
return ( return (
<DataProvider <DataProvider
data={sync.data} data={sync.data}
directory={props.directory} directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)} onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`} onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
> >
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider> </DataProvider>
@@ -30,31 +31,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const language = useLanguage() const language = useLanguage()
const [store, setStore] = createStore({ invalid: "" }) const globalSDK = useGlobalSDK()
const directory = createMemo(() => { const directory = createMemo(() => decode64(params.dir) ?? "")
return decode64(params.dir) ?? "" const [state, setState] = createStore({ invalid: "", resolved: "" })
})
createEffect(() => { createEffect(() => {
if (!params.dir) return if (!params.dir) return
if (directory()) return const raw = directory()
if (store.invalid === params.dir) return if (!raw) {
setStore("invalid", params.dir) if (state.invalid === params.dir) return
showToast({ setState("invalid", params.dir)
variant: "error", showToast({
title: language.t("common.requestFailed"), variant: "error",
description: language.t("directory.error.invalidUrl"), title: language.t("common.requestFailed"),
}) description: language.t("directory.error.invalidUrl"),
navigate("/", { replace: true }) })
navigate("/", { replace: true })
return
}
const current = params.dir
globalSDK
.createClient({
directory: raw,
throwOnError: true,
})
.path.get()
.then((x) => {
if (params.dir !== current) return
const next = x.data?.directory ?? raw
batch(() => {
setState("invalid", "")
setState("resolved", next)
})
if (next === raw) return
const path = location.pathname.slice(current.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
if (params.dir !== current) return
batch(() => {
setState("invalid", "")
setState("resolved", raw)
})
})
}) })
return ( return (
<Show when={directory()}> <Show when={state.resolved}>
<SDKProvider directory={directory}> {(resolved) => (
<SyncProvider> <SDKProvider directory={resolved}>
<DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider> <SyncProvider>
</SyncProvider> <DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
</SDKProvider> </SyncProvider>
</SDKProvider>
)}
</Show> </Show>
) )
} }

View File

@@ -111,8 +111,10 @@ export const TuiThreadCommand = cmd({
} }
// Resolve relative paths against PWD to preserve behavior when using --cwd flag // Resolve relative paths against PWD to preserve behavior when using --cwd flag
const root = process.env.PWD ?? process.cwd() const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const cwd = args.project ? path.resolve(root, args.project) : process.cwd() const cwd = args.project
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
: root
const file = await target() const file = await target()
try { try {
process.chdir(cwd) process.chdir(cwd)

View File

@@ -62,13 +62,14 @@ function track(directory: string, next: Promise<Context>) {
export const Instance = { export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> { async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
let existing = cache.get(input.directory) const directory = Filesystem.resolve(input.directory)
let existing = cache.get(directory)
if (!existing) { if (!existing) {
Log.Default.info("creating instance", { directory: input.directory }) Log.Default.info("creating instance", { directory })
existing = track( existing = track(
input.directory, directory,
boot({ boot({
directory: input.directory, directory,
init: input.init, init: input.init,
}), }),
) )
@@ -103,11 +104,12 @@ export const Instance = {
return State.create(() => Instance.directory, init, dispose) return State.create(() => Instance.directory, init, dispose)
}, },
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) { async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
Log.Default.info("reloading instance", { directory: input.directory }) const directory = Filesystem.resolve(input.directory)
await State.dispose(input.directory) Log.Default.info("reloading instance", { directory })
cache.delete(input.directory) await State.dispose(directory)
const next = track(input.directory, boot(input)) cache.delete(directory)
emit(input.directory) const next = track(directory, boot({ ...input, directory }))
emit(directory)
return await next return await next
}, },
async dispose() { async dispose() {

View File

@@ -38,6 +38,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun" import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception" import { HTTPException } from "hono/http-exception"
import { errors } from "./error" import { errors } from "./error"
import { Filesystem } from "@/util/filesystem"
import { QuestionRoutes } from "./routes/question" import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission" import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global" import { GlobalRoutes } from "./routes/global"
@@ -198,13 +199,15 @@ export namespace Server {
if (c.req.path === "/log") return next() if (c.req.path === "/log") return next()
const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = (() => { const directory = Filesystem.resolve(
try { (() => {
return decodeURIComponent(raw) try {
} catch { return decodeURIComponent(raw)
return raw } catch {
} return raw
})() }
})(),
)
return WorkspaceContext.provide({ return WorkspaceContext.provide({
workspaceID, workspaceID,

View File

@@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs" import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types" import { lookup } from "mime-types"
import { realpathSync } from "fs" import { realpathSync } from "fs"
import { dirname, join, relative } from "path" import { dirname, join, relative, resolve as pathResolve } from "path"
import { Readable } from "stream" import { Readable } from "stream"
import { pipeline } from "stream/promises" import { pipeline } from "stream/promises"
import { Glob } from "./glob" import { Glob } from "./glob"
@@ -113,16 +113,22 @@ export namespace Filesystem {
} }
} }
// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
export function resolve(p: string): string {
return normalizePath(pathResolve(windowsPath(p)))
}
export function windowsPath(p: string): string { export function windowsPath(p: string): string {
if (process.platform !== "win32") return p if (process.platform !== "win32") return p
return ( return (
p p
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Git Bash for Windows paths are typically /<drive>/... // Git Bash for Windows paths are typically /<drive>/...
.replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Cygwin git paths are typically /cygdrive/<drive>/... // Cygwin git paths are typically /cygdrive/<drive>/...
.replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// WSL paths are typically /mnt/<drive>/... // WSL paths are typically /mnt/<drive>/...
.replace(/^\/mnt\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
) )
} }
export function overlaps(a: string, b: string) { export function overlaps(a: string, b: string) {

View File

@@ -3,8 +3,8 @@ import whichPkg from "which"
export function which(cmd: string, env?: NodeJS.ProcessEnv) { export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const result = whichPkg.sync(cmd, { const result = whichPkg.sync(cmd, {
nothrow: true, nothrow: true,
path: env?.PATH, path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
pathExt: env?.PATHEXT, pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
}) })
return typeof result === "string" ? result : null return typeof result === "string" ? result : null
} }

View File

@@ -25,6 +25,34 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
await Filesystem.write(path.join(dir, name), JSON.stringify(config)) await Filesystem.write(path.join(dir, name), JSON.stringify(config))
} }
async function check(map: (dir: string) => string) {
if (process.platform !== "win32") return
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
Config.global.reset()
try {
await writeConfig(globalTmp.path, {
$schema: "https://opencode.ai/config.json",
snapshot: false,
})
await Instance.provide({
directory: map(tmp.path),
fn: async () => {
const cfg = await Config.get()
expect(cfg.snapshot).toBe(true)
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
expect(Instance.project.id).not.toBe("global")
},
})
} finally {
await Instance.disposeAll()
;(Global.Path as { config: string }).config = prev
Config.global.reset()
}
}
test("loads config with defaults when no files exist", async () => { test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await Instance.provide({ await Instance.provide({
@@ -56,6 +84,23 @@ test("loads JSON config file", async () => {
}) })
}) })
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
await check((dir) => {
const drive = dir[0].toLowerCase()
const rest = dir.slice(2).replaceAll("\\", "/")
return `/${drive}${rest}`
})
})
test("loads project config from Cygwin paths on Windows", async () => {
await check((dir) => {
const drive = dir[0].toLowerCase()
const rest = dir.slice(2).replaceAll("\\", "/")
return `/cygdrive/${drive}${rest}`
})
})
test("ignores legacy tui keys in opencode config", async () => { test("ignores legacy tui keys in opencode config", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {

View File

@@ -440,4 +440,67 @@ describe("filesystem", () => {
expect(await fs.readFile(filepath, "utf-8")).toBe(content) expect(await fs.readFile(filepath, "utf-8")).toBe(content)
}) })
}) })
describe("resolve()", () => {
test("resolves slash-prefixed drive paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const forward = tmp.path.replaceAll("\\", "/")
expect(Filesystem.resolve(`/${forward}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves slash-prefixed drive roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toUpperCase()
expect(Filesystem.resolve(`/${drive}:`)).toBe(Filesystem.resolve(`${drive}:/`))
})
test("resolves Git Bash and MSYS2 paths on Windows", async () => {
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves Git Bash and MSYS2 drive roots on Windows", async () => {
// Git Bash and MSYS2 both use /<drive> paths on Windows.
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
test("resolves Cygwin paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/cygdrive/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves Cygwin drive roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/cygdrive/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
test("resolves WSL mount paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/mnt/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves WSL mount roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
})
}) })

View File

@@ -22,6 +22,13 @@ function env(PATH: string): NodeJS.ProcessEnv {
} }
} }
function envPath(Path: string): NodeJS.ProcessEnv {
return {
Path,
PathExt: process.env["PathExt"] ?? process.env["PATHEXT"],
}
}
function same(a: string | null, b: string) { function same(a: string | null, b: string) {
if (process.platform === "win32") { if (process.platform === "win32") {
expect(a?.toLowerCase()).toBe(b.toLowerCase()) expect(a?.toLowerCase()).toBe(b.toLowerCase())
@@ -79,4 +86,15 @@ describe("util.which", () => {
expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file) expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file)
}) })
test("uses Windows Path casing fallback", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
const file = await cmd(bin, "mixed")
same(which("mixed", envPath(bin)), file)
})
}) })