From a74fedd23beecc70cb7cf7f07c6a14186787c960 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Sun, 22 Feb 2026 18:49:05 -0500 Subject: [PATCH] fix(desktop): change detection on Windows, especially Cygwin (#13659) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> --- packages/app/src/context/file/path.test.ts | 8 ++++ packages/app/src/context/file/path.ts | 20 +++++---- packages/app/src/i18n/en.ts | 1 + packages/app/src/pages/session.tsx | 7 ++- packages/opencode/src/file/watcher.ts | 43 ++++++++++--------- packages/opencode/src/project/project.ts | 21 +++++++-- packages/opencode/src/tool/bash.ts | 5 +-- packages/opencode/src/util/filesystem.ts | 12 ++++++ .../opencode/test/util/filesystem.test.ts | 34 +++++++++++++++ 9 files changed, 113 insertions(+), 38 deletions(-) diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index f2a3c44b6..7eb5e8b2a 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 859fdc040..6be7588f9 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 + "/" + const root = scope().replace(/\\/g, "/") - let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))) + let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(/\\/g, "/") - if (path.startsWith(prefix)) { - path = path.slice(prefix.length) - } - - if (path.startsWith(root)) { + // 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) } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 0fa3777dd..992509fcf 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -495,6 +495,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 a3f4b7164..e0ef92682 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -274,6 +274,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" + }) let inputRef!: HTMLDivElement let promptDock: HTMLDivElement | undefined @@ -531,7 +536,7 @@ export default function Page() { ) : (
-
{language.t("session.review.empty")}
+
{language.t(reviewEmptyKey())}
) } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index c4a474777..626a746c8 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 } diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index e49d96861..adbe2b9fb 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -17,6 +17,19 @@ import { Glob } from "../util/glob" 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 + + name = Filesystem.windowsPath(name) + + if (path.isAbsolute(name)) return path.normalize(name) + return path.resolve(cwd, name) + } + export const Info = z .object({ id: z.string(), @@ -141,7 +154,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) { @@ -159,9 +172,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) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index ee2279bbf..ee20d20c5 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 3a1e8b8ec..a87aaeb98 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -113,6 +113,18 @@ export namespace Filesystem { } } + 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) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 0f5447937..a6255db88 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -286,6 +286,40 @@ describe("filesystem", () => { }) }) + 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") + }) + }) + describe("writeStream()", () => { test("writes from Web ReadableStream", async () => { await using tmp = await tmpdir()