fix(desktop): change detection on Windows, especially Cygwin (#13659)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
Erik Demaine
2026-02-22 18:49:05 -05:00
committed by GitHub
parent eb64ce08b8
commit a74fedd23b
9 changed files with 113 additions and 38 deletions

View File

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

View File

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

View File

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

View File

@@ -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 /<drive>/...
.replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
// Cygwin git paths are typically /cygdrive/<drive>/...
.replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
// WSL paths are typically /mnt/<drive>/...
.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)

View File

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