Skip to content

Commit a74fedd

Browse files
edemaineHona
andauthored
fix(desktop): change detection on Windows, especially Cygwin (#13659)
Co-authored-by: LukeParkerDev <[email protected]>
1 parent eb64ce0 commit a74fedd

9 files changed

Lines changed: 115 additions & 40 deletions

File tree

‎packages/app/src/context/file/path.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ describe("file path helpers", () => {
1313
expect(path.pathFromTab("other://src/app.ts")).toBeUndefined()
1414
})
1515

16+
test("normalizes Windows absolute paths with mixed separators", () => {
17+
const path = createPathHelpers(() => "C:\\repo")
18+
expect(path.normalize("C:\\repo\\src\\app.ts")).toBe("src/app.ts")
19+
expect(path.normalize("C:/repo/src/app.ts")).toBe("src/app.ts")
20+
expect(path.normalize("file://C:/repo/src/app.ts")).toBe("src/app.ts")
21+
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src/app.ts")
22+
})
23+
1624
test("keeps query/hash stripping behavior stable", () => {
1725
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
1826
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")

‎packages/app/src/context/file/path.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,20 @@ export function encodeFilePath(filepath: string): string {
103103

104104
export function createPathHelpers(scope: () => string) {
105105
const normalize = (input: string) => {
106-
const root = scope()
107-
const prefix = root.endsWith("/") ? root : root + "/"
108-
109-
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
110-
111-
if (path.startsWith(prefix)) {
112-
path = path.slice(prefix.length)
113-
}
114-
115-
if (path.startsWith(root)) {
106+
const root = scope().replace(/\\/g, "/")
107+
108+
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(/\\/g, "/")
109+
110+
// Remove initial root prefix, if it's a complete match or followed by /
111+
// (don't want /foo/bar to root of /f).
112+
// For Windows paths, also check for case-insensitive match.
113+
const windows = /^[A-Za-z]:/.test(root)
114+
const canonRoot = windows ? root.toLowerCase() : root
115+
const canonPath = windows ? path.toLowerCase() : path
116+
if (canonPath.startsWith(canonRoot) &&
117+
(canonRoot.endsWith("/") || canonPath === canonRoot ||
118+
canonPath.startsWith(canonRoot + "/"))) {
119+
// If we match canonRoot + "/", the slash will be removed below.
116120
path = path.slice(root.length)
117121
}
118122

‎packages/app/src/i18n/en.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@ export const dict = {
495495
"session.review.change.other": "Changes",
496496
"session.review.loadingChanges": "Loading changes...",
497497
"session.review.empty": "No changes in this session yet",
498+
"session.review.noVcs": "No git VCS detected, so session changes will not be detected",
498499
"session.review.noChanges": "No changes",
499500

500501
"session.files.selectToOpen": "Select a file to open",

‎packages/app/src/pages/session.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,11 @@ export default function Page() {
274274
if (!hasReview()) return true
275275
return sync.data.session_diff[id] !== undefined
276276
})
277+
const reviewEmptyKey = createMemo(() => {
278+
const project = sync.project
279+
if (!project || project.vcs) return "session.review.empty"
280+
return "session.review.noVcs"
281+
})
277282

278283
let inputRef!: HTMLDivElement
279284
let promptDock: HTMLDivElement | undefined
@@ -531,7 +536,7 @@ export default function Page() {
531536
) : (
532537
<div class={input.emptyClass}>
533538
<Mark class="w-14 opacity-10" />
534-
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
539+
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
535540
</div>
536541
)
537542
}

‎packages/opencode/src/file/watcher.ts‎

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ export namespace FileWatcher {
4646

4747
const state = Instance.state(
4848
async () => {
49-
if (Instance.project.vcs !== "git") return {}
5049
log.info("init")
5150
const cfg = await Config.get()
5251
const backend = (() => {
@@ -88,26 +87,28 @@ export namespace FileWatcher {
8887
if (sub) subs.push(sub)
8988
}
9089

91-
const vcsDir = await $`git rev-parse --git-dir`
92-
.quiet()
93-
.nothrow()
94-
.cwd(Instance.worktree)
95-
.text()
96-
.then((x) => path.resolve(Instance.worktree, x.trim()))
97-
.catch(() => undefined)
98-
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
99-
const gitDirContents = await readdir(vcsDir).catch(() => [])
100-
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
101-
const pending = w.subscribe(vcsDir, subscribe, {
102-
ignore: ignoreList,
103-
backend,
104-
})
105-
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
106-
log.error("failed to subscribe to vcsDir", { error: err })
107-
pending.then((s) => s.unsubscribe()).catch(() => {})
108-
return undefined
109-
})
110-
if (sub) subs.push(sub)
90+
if (Instance.project.vcs === "git") {
91+
const vcsDir = await $`git rev-parse --git-dir`
92+
.quiet()
93+
.nothrow()
94+
.cwd(Instance.worktree)
95+
.text()
96+
.then((x) => path.resolve(Instance.worktree, x.trim()))
97+
.catch(() => undefined)
98+
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
99+
const gitDirContents = await readdir(vcsDir).catch(() => [])
100+
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
101+
const pending = w.subscribe(vcsDir, subscribe, {
102+
ignore: ignoreList,
103+
backend,
104+
})
105+
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
106+
log.error("failed to subscribe to vcsDir", { error: err })
107+
pending.then((s) => s.unsubscribe()).catch(() => {})
108+
return undefined
109+
})
110+
if (sub) subs.push(sub)
111+
}
111112
}
112113

113114
return { subs }

‎packages/opencode/src/project/project.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ import { Glob } from "../util/glob"
1717

1818
export namespace Project {
1919
const log = Log.create({ service: "project" })
20+
21+
function gitpath(cwd: string, name: string) {
22+
if (!name) return cwd
23+
// git output includes trailing newlines; keep path whitespace intact.
24+
name = name.replace(/[\r\n]+$/, "")
25+
if (!name) return cwd
26+
27+
name = Filesystem.windowsPath(name)
28+
29+
if (path.isAbsolute(name)) return path.normalize(name)
30+
return path.resolve(cwd, name)
31+
}
32+
2033
export const Info = z
2134
.object({
2235
id: z.string(),
@@ -141,7 +154,7 @@ export namespace Project {
141154
const top = await git(["rev-parse", "--show-toplevel"], {
142155
cwd: sandbox,
143156
})
144-
.then(async (result) => path.resolve(sandbox, (await result.text()).trim()))
157+
.then(async (result) => gitpath(sandbox, await result.text()))
145158
.catch(() => undefined)
146159

147160
if (!top) {
@@ -159,9 +172,9 @@ export namespace Project {
159172
cwd: sandbox,
160173
})
161174
.then(async (result) => {
162-
const dirname = path.dirname((await result.text()).trim())
163-
if (dirname === ".") return sandbox
164-
return dirname
175+
const common = gitpath(sandbox, await result.text())
176+
// Avoid going to parent of sandbox when git-common-dir is empty.
177+
return common === sandbox ? sandbox : path.dirname(common)
165178
})
166179
.catch(() => undefined)
167180

‎packages/opencode/src/tool/bash.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,8 @@ export const BashTool = Tool.define("bash", async () => {
124124
.then((x) => x.trim())
125125
log.info("resolved path", { arg, resolved })
126126
if (resolved) {
127-
// Git Bash on Windows returns Unix-style paths like /c/Users/...
128127
const normalized =
129-
process.platform === "win32" && resolved.match(/^\/[a-z]\//)
130-
? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
131-
: resolved
128+
process.platform === "win32" ? Filesystem.windowsPath(resolved).replace(/\//g, "\\") : resolved
132129
if (!Instance.containsPath(normalized)) {
133130
const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized)
134131
directories.add(dir)

‎packages/opencode/src/util/filesystem.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@ export namespace Filesystem {
113113
}
114114
}
115115

116+
export function windowsPath(p: string): string {
117+
if (process.platform !== "win32") return p
118+
return (
119+
p
120+
// Git Bash for Windows paths are typically /<drive>/...
121+
.replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
122+
// Cygwin git paths are typically /cygdrive/<drive>/...
123+
.replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
124+
// WSL paths are typically /mnt/<drive>/...
125+
.replace(/^\/mnt\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
126+
)
127+
}
116128
export function overlaps(a: string, b: string) {
117129
const relA = relative(a, b)
118130
const relB = relative(b, a)

‎packages/opencode/test/util/filesystem.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,40 @@ describe("filesystem", () => {
286286
})
287287
})
288288

289+
describe("windowsPath()", () => {
290+
test("converts Git Bash paths", () => {
291+
if (process.platform === "win32") {
292+
expect(Filesystem.windowsPath("/c/Users/test")).toBe("C:/Users/test")
293+
expect(Filesystem.windowsPath("/d/dev/project")).toBe("D:/dev/project")
294+
} else {
295+
expect(Filesystem.windowsPath("/c/Users/test")).toBe("/c/Users/test")
296+
}
297+
})
298+
299+
test("converts Cygwin paths", () => {
300+
if (process.platform === "win32") {
301+
expect(Filesystem.windowsPath("/cygdrive/c/Users/test")).toBe("C:/Users/test")
302+
expect(Filesystem.windowsPath("/cygdrive/x/dev/project")).toBe("X:/dev/project")
303+
} else {
304+
expect(Filesystem.windowsPath("/cygdrive/c/Users/test")).toBe("/cygdrive/c/Users/test")
305+
}
306+
})
307+
308+
test("converts WSL paths", () => {
309+
if (process.platform === "win32") {
310+
expect(Filesystem.windowsPath("/mnt/c/Users/test")).toBe("C:/Users/test")
311+
expect(Filesystem.windowsPath("/mnt/z/dev/project")).toBe("Z:/dev/project")
312+
} else {
313+
expect(Filesystem.windowsPath("/mnt/c/Users/test")).toBe("/mnt/c/Users/test")
314+
}
315+
})
316+
317+
test("ignores normal Windows paths", () => {
318+
expect(Filesystem.windowsPath("C:/Users/test")).toBe("C:/Users/test")
319+
expect(Filesystem.windowsPath("D:\\dev\\project")).toBe("D:\\dev\\project")
320+
})
321+
})
322+
289323
describe("writeStream()", () => {
290324
test("writes from Web ReadableStream", async () => {
291325
await using tmp = await tmpdir()

0 commit comments

Comments
 (0)