From a338d96f026896808cdf1feff83581eef80f417d Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Sat, 14 Feb 2026 10:17:27 -0500 Subject: [PATCH 1/7] Fix watcher path normalization on Windows --- packages/app/src/context/file/path.test.ts | 8 ++++++++ packages/app/src/context/file/path.ts | 24 +++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index f2a3c44b6c4a..6cad6acf34e0 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -13,6 +13,14 @@ describe("file path helpers", () => { expect(path.pathFromTab("other://src/app.ts")).toBeUndefined() }) + test("normalizes Windows absolute paths with mixed separators", () => { + const path = createPathHelpers(() => "C:\\repo") + expect(path.normalize("C:\\repo\\src\\app.ts")).toBe("src/app.ts") + expect(path.normalize("C:/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("file:///C:/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src/app.ts") + }) + test("keeps query/hash stripping behavior stable", () => { expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 859fdc04062f..6be7588f93c6 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -103,16 +103,20 @@ export function encodeFilePath(filepath: string): string { export function createPathHelpers(scope: () => string) { const normalize = (input: string) => { - const root = scope() - const prefix = root.endsWith("/") ? root : root + "/" - - let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))) - - if (path.startsWith(prefix)) { - path = path.slice(prefix.length) - } - - if (path.startsWith(root)) { + const root = scope().replace(/\\/g, "/") + + let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(/\\/g, "/") + + // Remove initial root prefix, if it's a complete match or followed by / + // (don't want /foo/bar to root of /f). + // For Windows paths, also check for case-insensitive match. + const windows = /^[A-Za-z]:/.test(root) + const canonRoot = windows ? root.toLowerCase() : root + const canonPath = windows ? path.toLowerCase() : path + if (canonPath.startsWith(canonRoot) && + (canonRoot.endsWith("/") || canonPath === canonRoot || + canonPath.startsWith(canonRoot + "/"))) { + // If we match canonRoot + "/", the slash will be removed below. path = path.slice(root.length) } From 0ace14f23b49bb5f87887574f548d68c1b4b9681 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Sat, 14 Feb 2026 10:20:04 -0500 Subject: [PATCH 2/7] Tell user if no git/VCS detected, so changes are disabled --- packages/app/src/i18n/en.ts | 1 + packages/app/src/pages/session.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 99513edaa173..1af70803daaa 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -491,6 +491,7 @@ export const dict = { "session.review.change.other": "Changes", "session.review.loadingChanges": "Loading changes...", "session.review.empty": "No changes in this session yet", + "session.review.noVcs": "No git VCS detected, so session changes will not be detected", "session.review.noChanges": "No changes", "session.files.selectToOpen": "Select a file to open", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5ce6202eef9b..dd062a1fafad 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -649,6 +649,11 @@ export default function Page() { if (!hasReview()) return true return sync.data.session_diff[id] !== undefined }) + const reviewEmptyKey = createMemo(() => { + const project = sync.project + if (!project || project.vcs) return "session.review.empty" + return "session.review.noVcs" + }) const idle = { type: "idle" as const } let inputRef!: HTMLDivElement @@ -1028,7 +1033,7 @@ export default function Page() { ) : (
-
{language.t("session.review.empty")}
+
{language.t(reviewEmptyKey())}
) } From a77728dcf3d596d16956fef99384956a88ce20e7 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Sat, 14 Feb 2026 10:21:44 -0500 Subject: [PATCH 3/7] Enable file watcher in non-VCS projects --- packages/opencode/src/file/watcher.ts | 43 ++++++++++++++------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index c4a4747777e2..626a746c832d 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -46,7 +46,6 @@ export namespace FileWatcher { const state = Instance.state( async () => { - if (Instance.project.vcs !== "git") return {} log.info("init") const cfg = await Config.get() const backend = (() => { @@ -88,26 +87,28 @@ export namespace FileWatcher { if (sub) subs.push(sub) } - const vcsDir = await $`git rev-parse --git-dir` - .quiet() - .nothrow() - .cwd(Instance.worktree) - .text() - .then((x) => path.resolve(Instance.worktree, x.trim())) - .catch(() => undefined) - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { - const gitDirContents = await readdir(vcsDir).catch(() => []) - const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") - const pending = w.subscribe(vcsDir, subscribe, { - ignore: ignoreList, - backend, - }) - const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { - log.error("failed to subscribe to vcsDir", { error: err }) - pending.then((s) => s.unsubscribe()).catch(() => {}) - return undefined - }) - if (sub) subs.push(sub) + if (Instance.project.vcs === "git") { + const vcsDir = await $`git rev-parse --git-dir` + .quiet() + .nothrow() + .cwd(Instance.worktree) + .text() + .then((x) => path.resolve(Instance.worktree, x.trim())) + .catch(() => undefined) + if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { + const gitDirContents = await readdir(vcsDir).catch(() => []) + const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") + const pending = w.subscribe(vcsDir, subscribe, { + ignore: ignoreList, + backend, + }) + const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { + log.error("failed to subscribe to vcsDir", { error: err }) + pending.then((s) => s.unsubscribe()).catch(() => {}) + return undefined + }) + if (sub) subs.push(sub) + } } return { subs } From e244b57de222b22dafcc012e6c217d857311803a Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Sat, 14 Feb 2026 10:28:11 -0500 Subject: [PATCH 4/7] Support Cygwin/WSL paths when working with git --- packages/opencode/src/project/project.ts | 29 ++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 8fa0f6c6f00d..ecff5c1c6fab 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -16,6 +16,27 @@ import { git } from "../util/git" export namespace Project { const log = Log.create({ service: "project" }) + + function gitpath(cwd: string, name: string) { + if (!name) return cwd + // git output includes trailing newlines; keep path whitespace intact. + name = name.replace(/[\r\n]+$/, "") + if (!name) return cwd + + if (process.platform === "win32") { + name = name + // Git Bash for Windows paths are typically //... + .replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + // Cygwin git paths are typically /cygdrive//... + .replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + // WSL paths are typically /mnt//... + .replace(/^\/mnt\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + } + + if (path.isAbsolute(name)) return path.normalize(name) + return path.resolve(cwd, name) + } + export const Info = z .object({ id: z.string(), @@ -143,7 +164,7 @@ export namespace Project { const top = await git(["rev-parse", "--show-toplevel"], { cwd: sandbox, }) - .then(async (result) => path.resolve(sandbox, (await result.text()).trim())) + .then(async (result) => gitpath(sandbox, await result.text())) .catch(() => undefined) if (!top) { @@ -161,9 +182,9 @@ export namespace Project { cwd: sandbox, }) .then(async (result) => { - const dirname = path.dirname((await result.text()).trim()) - if (dirname === ".") return sandbox - return dirname + const common = gitpath(sandbox, await result.text()) + // Avoid going to parent of sandbox when git-common-dir is empty. + return common === sandbox ? sandbox : path.dirname(common) }) .catch(() => undefined) From 388a6d1f7971197916580313c4dc24a4afba61bc Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Sat, 14 Feb 2026 17:24:13 -0500 Subject: [PATCH 5/7] Refactor Windows path handling into shared helper --- packages/opencode/src/project/project.ts | 10 +--------- packages/opencode/src/tool/bash.ts | 5 +---- packages/opencode/src/util/filesystem.ts | 12 ++++++++++++ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index ecff5c1c6fab..f64889c7eccb 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -23,15 +23,7 @@ export namespace Project { name = name.replace(/[\r\n]+$/, "") if (!name) return cwd - if (process.platform === "win32") { - name = name - // Git Bash for Windows paths are typically //... - .replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) - // Cygwin git paths are typically /cygdrive//... - .replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) - // WSL paths are typically /mnt//... - .replace(/^\/mnt\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) - } + name = Filesystem.windowsPath(name) if (path.isAbsolute(name)) return path.normalize(name) return path.resolve(cwd, name) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 67559b78c085..f3b3d41684b5 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -124,11 +124,8 @@ export const BashTool = Tool.define("bash", async () => { .then((x) => x.trim()) log.info("resolved path", { arg, resolved }) if (resolved) { - // Git Bash on Windows returns Unix-style paths like /c/Users/... const normalized = - process.platform === "win32" && resolved.match(/^\/[a-z]\//) - ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\") - : resolved + process.platform === "win32" ? Filesystem.windowsPath(resolved).replace(/\//g, "\\") : resolved if (!Instance.containsPath(normalized)) { const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized) directories.add(dir) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 7aff6bd1d302..376cf6267bd9 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -26,6 +26,18 @@ export namespace Filesystem { return p } } + + export function windowsPath(p: string): string { + if (process.platform !== "win32") return p + return p + // Git Bash for Windows paths are typically //... + .replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + // Cygwin git paths are typically /cygdrive//... + .replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + // WSL paths are typically /mnt//... + .replace(/^\/mnt\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + } + export function overlaps(a: string, b: string) { const relA = relative(a, b) const relB = relative(b, a) From 9c8f7c242510b5f81941a9cad0077836a5e689c6 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Sat, 14 Feb 2026 20:37:28 -0500 Subject: [PATCH 6/7] Test fix --- packages/app/src/context/file/path.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 6cad6acf34e0..7eb5e8b2a358 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -17,7 +17,7 @@ describe("file path helpers", () => { const path = createPathHelpers(() => "C:\\repo") expect(path.normalize("C:\\repo\\src\\app.ts")).toBe("src/app.ts") expect(path.normalize("C:/repo/src/app.ts")).toBe("src/app.ts") - expect(path.normalize("file:///C:/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("file://C:/repo/src/app.ts")).toBe("src/app.ts") expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src/app.ts") }) From 31a9cc666b7652aa4a2f472455a5fd8c8b33f0f6 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:09:01 +1000 Subject: [PATCH 7/7] test: add coverage for WSL/Cygwin path conversions --- .../opencode/test/util/filesystem.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 3c3da0fc7916..521043ee4aad 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -285,4 +285,38 @@ describe("filesystem", () => { expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream") }) }) + + describe("windowsPath()", () => { + test("converts Git Bash paths", () => { + if (process.platform === "win32") { + expect(Filesystem.windowsPath("/c/Users/test")).toBe("C:/Users/test") + expect(Filesystem.windowsPath("/d/dev/project")).toBe("D:/dev/project") + } else { + expect(Filesystem.windowsPath("/c/Users/test")).toBe("/c/Users/test") + } + }) + + test("converts Cygwin paths", () => { + if (process.platform === "win32") { + expect(Filesystem.windowsPath("/cygdrive/c/Users/test")).toBe("C:/Users/test") + expect(Filesystem.windowsPath("/cygdrive/x/dev/project")).toBe("X:/dev/project") + } else { + expect(Filesystem.windowsPath("/cygdrive/c/Users/test")).toBe("/cygdrive/c/Users/test") + } + }) + + test("converts WSL paths", () => { + if (process.platform === "win32") { + expect(Filesystem.windowsPath("/mnt/c/Users/test")).toBe("C:/Users/test") + expect(Filesystem.windowsPath("/mnt/z/dev/project")).toBe("Z:/dev/project") + } else { + expect(Filesystem.windowsPath("/mnt/c/Users/test")).toBe("/mnt/c/Users/test") + } + }) + + test("ignores normal Windows paths", () => { + expect(Filesystem.windowsPath("C:/Users/test")).toBe("C:/Users/test") + expect(Filesystem.windowsPath("D:\\dev\\project")).toBe("D:\\dev\\project") + }) + }) })