Переглянути джерело

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

Luke Parker 1 місяць тому
батько
коміт
8a95be492d

+ 2 - 3
packages/app/e2e/actions.ts

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

+ 34 - 26
packages/app/e2e/projects/workspace-new-session.spec.ts

@@ -9,6 +9,26 @@ function slugFromUrl(url: string) {
   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) {
   await openSidebar(page)
   await expect
@@ -31,20 +51,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
   await openSidebar(page)
   await page.getByRole("button", { name: "New workspace" }).first().click()
 
-  await expect
-    .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 slug = await waitSlug(page, [root, ...seen])
   const directory = base64Decode(slug)
   if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
   return { slug, directory }
@@ -60,12 +67,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
   await expect(button).toBeVisible()
   await button.click({ force: true })
 
-  await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
-  await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
+  const next = await waitSlug(page)
+  await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
+  return next
 }
 
 async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
-  await openWorkspaceNewSession(page, slug)
+  const next = await openWorkspaceNewSession(page, slug)
 
   const prompt = page.locator(promptSelector)
   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 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("")
 
   const sessionID = sessionIDFromUrl(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}(?:[/?#]|$)`))
-  return sessionID
+  await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
+  return { sessionID, slug: next }
 }
 
 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)
 
       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()}`)
-      sessions.push(secondSession)
+      sessions.push(secondSession.sessionID)
 
       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(second.directory, secondSession)).toBe(second.directory)
-      await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
+      await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
+      await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
+      await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
     } finally {
       const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
       await Promise.all(

+ 22 - 23
packages/app/e2e/projects/workspaces.spec.ts

@@ -22,24 +22,34 @@ function slugFromUrl(url: string) {
   return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
 }
 
-async function setupWorkspaceTest(page: Page, project: { slug: string }) {
-  const rootSlug = project.slug
-  await openSidebar(page)
-
-  await setWorkspacesEnabled(page, rootSlug, true)
-
-  await page.getByRole("button", { name: "New workspace" }).first().click()
+async function waitSlug(page: Page, skip: string[] = []) {
+  let prev = ""
   await expect
     .poll(
       () => {
         const slug = slugFromUrl(page.url())
-        return slug.length > 0 && slug !== rootSlug
+        if (!slug) return ""
+        if (skip.includes(slug)) return ""
+        if (slug !== prev) {
+          prev = slug
+          return ""
+        }
+        return slug
       },
       { timeout: 45_000 },
     )
-    .toBe(true)
+    .not.toBe("")
+  return slugFromUrl(page.url())
+}
+
+async function setupWorkspaceTest(page: Page, project: { slug: string }) {
+  const rootSlug = project.slug
+  await openSidebar(page)
+
+  await setWorkspacesEnabled(page, rootSlug, true)
 
-  const slug = slugFromUrl(page.url())
+  await page.getByRole("button", { name: "New workspace" }).first().click()
+  const slug = await waitSlug(page, [rootSlug])
   const dir = base64Decode(slug)
 
   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 page.getByRole("button", { name: "New workspace" }).first().click()
-
-    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 workspaceSlug = await waitSlug(page, [slug])
     const workspaceDir = base64Decode(workspaceSlug)
 
     await openSidebar(page)
@@ -279,7 +278,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
     await clickMenuItem(menu, /^Delete$/i, { force: true })
     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
       .poll(

+ 6 - 0
packages/app/e2e/utils.ts

@@ -14,6 +14,12 @@ export function createSdk(directory?: string) {
   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() {
   const sdk = createSdk()
   const result = await sdk.path.get()

+ 58 - 25
packages/app/src/pages/directory-layout.tsx

@@ -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 { useNavigate, useParams } from "@solidjs/router"
+import { useLocation, useNavigate, useParams } from "@solidjs/router"
 import { SDKProvider } from "@/context/sdk"
 import { SyncProvider, useSync } from "@/context/sync"
 import { LocalProvider } from "@/context/local"
+import { useGlobalSDK } from "@/context/global-sdk"
 
 import { DataProvider } from "@opencode-ai/ui/context"
+import { base64Encode } from "@opencode-ai/util/encode"
 import { decode64 } from "@/utils/base64"
 import { showToast } from "@opencode-ai/ui/toast"
 import { useLanguage } from "@/context/language"
-
 function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
-  const params = useParams()
   const navigate = useNavigate()
   const sync = useSync()
+  const slug = createMemo(() => base64Encode(props.directory))
 
   return (
     <DataProvider
       data={sync.data}
       directory={props.directory}
-      onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
-      onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
+      onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
+      onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
     >
       <LocalProvider>{props.children}</LocalProvider>
     </DataProvider>
@@ -30,31 +31,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
 export default function Layout(props: ParentProps) {
   const params = useParams()
   const navigate = useNavigate()
+  const location = useLocation()
   const language = useLanguage()
-  const [store, setStore] = createStore({ invalid: "" })
-  const directory = createMemo(() => {
-    return decode64(params.dir) ?? ""
-  })
+  const globalSDK = useGlobalSDK()
+  const directory = createMemo(() => decode64(params.dir) ?? "")
+  const [state, setState] = createStore({ invalid: "", resolved: "" })
 
   createEffect(() => {
     if (!params.dir) return
-    if (directory()) return
-    if (store.invalid === params.dir) return
-    setStore("invalid", params.dir)
-    showToast({
-      variant: "error",
-      title: language.t("common.requestFailed"),
-      description: language.t("directory.error.invalidUrl"),
-    })
-    navigate("/", { replace: true })
+    const raw = directory()
+    if (!raw) {
+      if (state.invalid === params.dir) return
+      setState("invalid", params.dir)
+      showToast({
+        variant: "error",
+        title: language.t("common.requestFailed"),
+        description: language.t("directory.error.invalidUrl"),
+      })
+      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 (
-    <Show when={directory()}>
-      <SDKProvider directory={directory}>
-        <SyncProvider>
-          <DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
-        </SyncProvider>
-      </SDKProvider>
+    <Show when={state.resolved}>
+      {(resolved) => (
+        <SDKProvider directory={resolved}>
+          <SyncProvider>
+            <DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
+          </SyncProvider>
+        </SDKProvider>
+      )}
     </Show>
   )
 }

+ 4 - 2
packages/opencode/src/cli/cmd/tui/thread.ts

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

+ 11 - 9
packages/opencode/src/project/instance.ts

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

+ 10 - 7
packages/opencode/src/server/server.ts

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

+ 10 - 4
packages/opencode/src/util/filesystem.ts

@@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
 import { createWriteStream, existsSync, statSync } from "fs"
 import { lookup } from "mime-types"
 import { realpathSync } from "fs"
-import { dirname, join, relative } from "path"
+import { dirname, join, relative, resolve as pathResolve } from "path"
 import { Readable } from "stream"
 import { pipeline } from "stream/promises"
 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 {
     if (process.platform !== "win32") return p
     return (
       p
+        .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
         // 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>/...
-        .replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
+        .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
         // 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) {

+ 2 - 2
packages/opencode/src/util/which.ts

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

+ 45 - 0
packages/opencode/test/config/config.test.ts

@@ -25,6 +25,34 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
   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 () => {
   await using tmp = await tmpdir()
   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 () => {
   await using tmp = await tmpdir({
     init: async (dir) => {

+ 63 - 0
packages/opencode/test/util/filesystem.test.ts

@@ -440,4 +440,67 @@ describe("filesystem", () => {
       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()}:/`))
+    })
+  })
 })

+ 18 - 0
packages/opencode/test/util/which.test.ts

@@ -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) {
   if (process.platform === "win32") {
     expect(a?.toLowerCase()).toBe(b.toLowerCase())
@@ -79,4 +86,15 @@ describe("util.which", () => {
 
     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)
+  })
 })