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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 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) {

View File

@ -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(

View File

@ -22,6 +22,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 setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
@ -29,17 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
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(

View File

@ -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()

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 { 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>
)
}

View File

@ -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)

View File

@ -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() {

View File

@ -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,

View File

@ -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) {

View File

@ -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
}

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))
}
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) => {

View File

@ -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()}:/`))
})
})
})

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) {
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)
})
})