From 30b767b69fb8260e704f3ce3a584e7f37653da75 Mon Sep 17 00:00:00 2001
From: ops
Date: Sat, 17 Jan 2026 08:29:48 +0100
Subject: [PATCH 1/2] fix(windows): normalize all paths to forward slashes, add
Cygwin/Git Bash support
---
packages/app/src/custom-elements.d.ts | 2 +-
packages/desktop/src/index.tsx | 11 ++-
packages/enterprise/src/custom-elements.d.ts | 2 +-
packages/opencode/src/agent/agent.ts | 8 +--
.../src/cli/cmd/tui/routes/session/index.tsx | 8 +--
packages/opencode/src/config/config.ts | 5 +-
packages/opencode/src/file/ignore.ts | 9 +--
packages/opencode/src/lsp/client.ts | 4 +-
packages/opencode/src/patch/index.ts | 9 +--
packages/opencode/src/project/instance.ts | 11 +--
packages/opencode/src/project/project.ts | 6 +-
.../opencode/src/server/routes/session.ts | 5 +-
packages/opencode/src/session/index.ts | 3 +-
packages/opencode/src/skill/skill.ts | 2 +-
packages/opencode/src/snapshot/index.ts | 3 +-
packages/opencode/src/tool/bash.ts | 17 ++---
packages/opencode/src/tool/edit.ts | 11 +--
.../opencode/src/tool/external-directory.ts | 6 +-
packages/opencode/src/tool/glob.ts | 9 +--
packages/opencode/src/tool/grep.ts | 3 +-
packages/opencode/src/tool/ls.ts | 9 +--
packages/opencode/src/tool/lsp.ts | 5 +-
packages/opencode/src/tool/multiedit.ts | 3 +-
packages/opencode/src/tool/patch.ts | 13 ++--
packages/opencode/src/tool/plan.ts | 5 +-
packages/opencode/src/tool/read.ts | 11 +--
packages/opencode/src/tool/skill.ts | 3 +-
packages/opencode/src/tool/truncation.ts | 9 +--
packages/opencode/src/tool/write.ts | 9 +--
packages/opencode/src/util/filesystem.ts | 39 ++++++++---
packages/opencode/test/config/config.test.ts | 10 ++-
packages/opencode/test/fixture/fixture.ts | 3 +-
packages/opencode/test/ide/ide.test.ts | 2 +-
.../opencode/test/project/project.test.ts | 27 ++++----
.../opencode/test/snapshot/snapshot.test.ts | 10 ++-
packages/opencode/test/tool/bash.test.ts | 5 +-
.../test/tool/external-directory.test.ts | 7 +-
.../opencode/test/util/filesystem.test.ts | 69 +++++++++++++++++++
packages/util/src/path.ts | 2 +-
39 files changed, 252 insertions(+), 123 deletions(-)
diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts
index e4ea0d6cebd..525c6dd6e31 120000
--- a/packages/app/src/custom-elements.d.ts
+++ b/packages/app/src/custom-elements.d.ts
@@ -1 +1 @@
-../../ui/src/custom-elements.d.ts
\ No newline at end of file
+export * from "../../ui/src/custom-elements.d.ts"
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index 8398f457766..1e1a81945ed 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -55,7 +55,9 @@ const createPlatform = (password: Accessor): Platform => ({
multiple: opts?.multiple ?? false,
title: opts?.title ?? "Choose a folder",
})
- return result
+ if (!result) return result
+ if (Array.isArray(result)) return result.map((p) => p.replace(/\\/g, "/"))
+ return result.replace(/\\/g, "/")
},
async openFilePickerDialog(opts) {
@@ -64,7 +66,9 @@ const createPlatform = (password: Accessor): Platform => ({
multiple: opts?.multiple ?? false,
title: opts?.title ?? "Choose a file",
})
- return result
+ if (!result) return result
+ if (Array.isArray(result)) return result.map((p) => p.replace(/\\/g, "/"))
+ return result.replace(/\\/g, "/")
},
async saveFilePickerDialog(opts) {
@@ -72,7 +76,8 @@ const createPlatform = (password: Accessor): Platform => ({
title: opts?.title ?? "Save file",
defaultPath: opts?.defaultPath,
})
- return result
+ if (!result) return result
+ return result.replace(/\\/g, "/")
},
openLink(url: string) {
diff --git a/packages/enterprise/src/custom-elements.d.ts b/packages/enterprise/src/custom-elements.d.ts
index e4ea0d6cebd..525c6dd6e31 120000
--- a/packages/enterprise/src/custom-elements.d.ts
+++ b/packages/enterprise/src/custom-elements.d.ts
@@ -1 +1 @@
-../../ui/src/custom-elements.d.ts
\ No newline at end of file
+export * from "../../ui/src/custom-elements.d.ts"
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index 0725933d731..ea98963968f 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -14,7 +14,7 @@ import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission/next"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
-import path from "path"
+import { Filesystem } from "@/util/filesystem"
export namespace Agent {
export const Info = z
@@ -91,12 +91,12 @@ export namespace Agent {
question: "allow",
plan_exit: "allow",
external_directory: {
- [path.join(Global.Path.data, "plans", "*")]: "allow",
+ [Filesystem.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
- [path.join(".opencode", "plans", "*.md")]: "allow",
- [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
+ [Filesystem.join(".opencode", "plans", "*.md")]: "allow",
+ [Filesystem.relative(Instance.worktree, Filesystem.join(Global.Path.data, Filesystem.join("plans", "*.md")))]: "allow",
},
}),
user,
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 1d64a2ff156..b55057e904c 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -1607,7 +1607,7 @@ function Write(props: ToolProps) {
})
const diagnostics = createMemo(() => {
- const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
+ const filePath = Filesystem.realpath(props.input.filePath ?? "")
return props.metadata.diagnostics?.[filePath] ?? []
})
@@ -1782,7 +1782,7 @@ function Edit(props: ToolProps) {
const diffContent = createMemo(() => props.metadata.diff)
const diagnostics = createMemo(() => {
- const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
+ const filePath = Filesystem.realpath(props.input.filePath ?? "")
const arr = props.metadata.diagnostics?.[filePath] ?? []
return arr.filter((x) => x.severity === 1).slice(0, 3)
})
@@ -1913,9 +1913,9 @@ function Question(props: ToolProps) {
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
- return path.relative(process.cwd(), input) || "."
+ return Filesystem.relative(process.cwd(), input) || "."
}
- return input
+ return Filesystem.normalize(input)
}
function input(input: Record, omit?: string[]): string {
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 1574c644d32..85bd42f1f1b 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -211,10 +211,11 @@ export namespace Config {
}
function rel(item: string, patterns: string[]) {
+ const normalized = Filesystem.normalize(item)
for (const pattern of patterns) {
- const index = item.indexOf(pattern)
+ const index = normalized.indexOf(pattern)
if (index === -1) continue
- return item.slice(index + pattern.length)
+ return normalized.slice(index + pattern.length)
}
}
diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts
index 7230f67afeb..da81106aeea 100644
--- a/packages/opencode/src/file/ignore.ts
+++ b/packages/opencode/src/file/ignore.ts
@@ -1,4 +1,4 @@
-import { sep } from "node:path"
+import { Filesystem } from "../util/filesystem"
export namespace FileIgnore {
const FOLDERS = new Set([
@@ -64,18 +64,19 @@ export namespace FileIgnore {
whitelist?: Bun.Glob[]
},
) {
+ const normalized = Filesystem.normalize(filepath)
for (const glob of opts?.whitelist || []) {
- if (glob.match(filepath)) return false
+ if (glob.match(normalized)) return false
}
- const parts = filepath.split(sep)
+ const parts = normalized.split("/")
for (let i = 0; i < parts.length; i++) {
if (FOLDERS.has(parts[i])) return true
}
const extra = opts?.extra || []
for (const glob of [...FILE_GLOBS, ...extra]) {
- if (glob.match(filepath)) return true
+ if (glob.match(normalized)) return true
}
return false
diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts
index 8704b65acb5..d770bc793a7 100644
--- a/packages/opencode/src/lsp/client.ts
+++ b/packages/opencode/src/lsp/client.ts
@@ -50,7 +50,7 @@ export namespace LSPClient {
const diagnostics = new Map()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
- const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
+ const filePath = Filesystem.realpath(fileURLToPath(params.uri))
l.info("textDocument/publishDiagnostics", {
path: filePath,
count: params.diagnostics.length,
@@ -208,7 +208,7 @@ export namespace LSPClient {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
- const normalizedPath = Filesystem.normalizePath(
+ const normalizedPath = Filesystem.realpath(
path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
)
log.info("waiting for diagnostics", { path: normalizedPath })
diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts
index 91d52065f6f..916b8c6fa87 100644
--- a/packages/opencode/src/patch/index.ts
+++ b/packages/opencode/src/patch/index.ts
@@ -75,26 +75,27 @@ export namespace Patch {
lines: string[],
startIdx: number,
): { filePath: string; movePath?: string; nextIdx: number } | null {
+ if (startIdx >= lines.length) return null
const line = lines[startIdx]
if (line.startsWith("*** Add File:")) {
- const filePath = line.split(":", 2)[1]?.trim()
+ const filePath = line.substring("*** Add File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Delete File:")) {
- const filePath = line.split(":", 2)[1]?.trim()
+ const filePath = line.substring("*** Delete File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Update File:")) {
- const filePath = line.split(":", 2)[1]?.trim()
+ const filePath = line.substring("*** Update File:".length).trim()
let movePath: string | undefined
let nextIdx = startIdx + 1
// Check for move directive
if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
- movePath = lines[nextIdx].split(":", 2)[1]?.trim()
+ movePath = lines[nextIdx].substring("*** Move to:".length).trim()
nextIdx++
}
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index ddaa90f1e2b..fd1d4f6201b 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/packages/opencode/src/project/instance.ts
@@ -16,13 +16,14 @@ const cache = new Map>()
export const Instance = {
async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise {
- let existing = cache.get(input.directory)
+ const directory = Filesystem.normalize(input.directory)
+ let existing = cache.get(directory)
if (!existing) {
- Log.Default.info("creating instance", { directory: input.directory })
+ Log.Default.info("creating instance", { directory })
existing = iife(async () => {
- const { project, sandbox } = await Project.fromDirectory(input.directory)
+ const { project, sandbox } = await Project.fromDirectory(directory)
const ctx = {
- directory: input.directory,
+ directory: directory,
worktree: sandbox,
project,
}
@@ -31,7 +32,7 @@ export const Instance = {
})
return ctx
})
- cache.set(input.directory, existing)
+ cache.set(directory, existing)
}
const ctx = await existing
return context.provide(ctx, async () => {
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index 72201636b75..2f963e2ea3e 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -52,7 +52,7 @@ export namespace Project {
const git = await matches.next().then((x) => x.value)
await matches.return()
if (git) {
- let sandbox = path.dirname(git)
+ let sandbox = Filesystem.dirname(git)
const gitBinary = Bun.which("git")
@@ -118,7 +118,7 @@ export namespace Project {
.nothrow()
.cwd(sandbox)
.text()
- .then((x) => path.resolve(sandbox, x.trim()))
+ .then((x) => Filesystem.resolve(sandbox, x.trim()))
.catch(() => undefined)
if (!top) {
@@ -138,7 +138,7 @@ export namespace Project {
.cwd(sandbox)
.text()
.then((x) => {
- const dirname = path.dirname(x.trim())
+ const dirname = Filesystem.dirname(x.trim())
if (dirname === ".") return sandbox
return dirname
})
diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts
index a98624dfae2..58fb17630e4 100644
--- a/packages/opencode/src/server/routes/session.ts
+++ b/packages/opencode/src/server/routes/session.ts
@@ -16,6 +16,7 @@ import { Log } from "../../util/log"
import { PermissionNext } from "@/permission/next"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
+import { Filesystem } from "../../util/filesystem"
const log = Log.create({ service: "server" })
@@ -55,8 +56,10 @@ export const SessionRoutes = lazy(() =>
const query = c.req.valid("query")
const term = query.search?.toLowerCase()
const sessions: Session.Info[] = []
+ const normalizedQueryDir = query.directory ? Filesystem.normalize(query.directory) : undefined
for await (const session of Session.list()) {
- if (query.directory !== undefined && session.directory !== query.directory) continue
+ if (normalizedQueryDir !== undefined && Filesystem.normalize(session.directory) !== normalizedQueryDir)
+ continue
if (query.roots && session.parentID) continue
if (query.start !== undefined && session.time.updated < query.start) continue
if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 3fcdab5238c..2d322b07d9e 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -22,6 +22,7 @@ import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
import { Global } from "@/global"
+import { Filesystem } from "@/util/filesystem"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -236,7 +237,7 @@ export namespace Session {
const base = Instance.project.vcs
? path.join(Instance.worktree, ".opencode", "plans")
: path.join(Global.Path.data, "plans")
- return path.join(base, [input.time.created, input.slug].join("-") + ".md")
+ return Filesystem.join(base, [input.time.created, input.slug].join("-") + ".md")
}
export const get = fn(Identifier.schema("session"), async (id) => {
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index 6ae0e9fe887..bf02d633d0c 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -72,7 +72,7 @@ export namespace Skill {
skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
- location: match,
+ location: Filesystem.normalize(match),
}
}
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index 69f2abc7903..e82d69dbf45 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -6,6 +6,7 @@ import { Global } from "../global"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
+import { Filesystem } from "../util/filesystem"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
@@ -67,7 +68,7 @@ export namespace Snapshot {
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
- .map((x) => path.join(Instance.worktree, x)),
+ .map((x) => Filesystem.join(Instance.worktree, x)),
}
}
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index f3a1b04d431..5993bb576a8 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -111,20 +111,11 @@ export const BashTool = Tool.define("bash", async () => {
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"].includes(command[0])) {
for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
- const resolved = await $`realpath ${arg}`
- .cwd(cwd)
- .quiet()
- .nothrow()
- .text()
- .then((x) => x.trim())
+ const resolved = Filesystem.resolve(cwd, arg)
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
- if (!Instance.containsPath(normalized)) directories.add(normalized)
+ const normalized = Filesystem.normalize(resolved)
+ if (!Filesystem.contains(Instance.directory, normalized)) directories.add(normalized)
}
}
}
@@ -140,7 +131,7 @@ export const BashTool = Tool.define("bash", async () => {
await ctx.ask({
permission: "external_directory",
patterns: Array.from(directories),
- always: Array.from(directories).map((x) => path.dirname(x) + "*"),
+ always: Array.from(directories).map((x) => Filesystem.dirname(x) + "*"),
metadata: {},
})
}
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 26db5b22836..18ae3d9b64b 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -40,7 +40,8 @@ export const EditTool = Tool.define("edit", {
throw new Error("oldString and newString must be different")
}
- const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
+ const normalized = Filesystem.normalize(params.filePath)
+ const filePath = path.isAbsolute(normalized) ? normalized : Filesystem.join(Instance.directory, normalized)
await assertExternalDirectory(ctx, filePath)
let diff = ""
@@ -52,7 +53,7 @@ export const EditTool = Tool.define("edit", {
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
- patterns: [path.relative(Instance.worktree, filePath)],
+ patterns: [Filesystem.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
@@ -80,7 +81,7 @@ export const EditTool = Tool.define("edit", {
)
await ctx.ask({
permission: "edit",
- patterns: [path.relative(Instance.worktree, filePath)],
+ patterns: [Filesystem.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
@@ -122,7 +123,7 @@ export const EditTool = Tool.define("edit", {
let output = "Edit applied successfully."
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
- const normalizedFilePath = Filesystem.normalizePath(filePath)
+ const normalizedFilePath = Filesystem.realpath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
@@ -138,7 +139,7 @@ export const EditTool = Tool.define("edit", {
diff,
filediff,
},
- title: `${path.relative(Instance.worktree, filePath)}`,
+ title: `${Filesystem.relative(Instance.worktree, filePath)}`,
output,
}
},
diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts
index 1d3958fc464..cc261d3d233 100644
--- a/packages/opencode/src/tool/external-directory.ts
+++ b/packages/opencode/src/tool/external-directory.ts
@@ -1,6 +1,7 @@
import path from "path"
import type { Tool } from "./tool"
import { Instance } from "../project/instance"
+import { Filesystem } from "@/util/filesystem"
type Kind = "file" | "directory"
@@ -14,11 +15,12 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
if (options?.bypass) return
+ target = Filesystem.normalize(target)
if (Instance.containsPath(target)) return
const kind = options?.kind ?? "file"
- const parentDir = kind === "directory" ? target : path.dirname(target)
- const glob = path.join(parentDir, "*")
+ const parentDir = kind === "directory" ? target : Filesystem.dirname(target)
+ const glob = Filesystem.join(parentDir, "*")
await ctx.ask({
permission: "external_directory",
diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts
index dda57f6ee1b..a9b91bd186d 100644
--- a/packages/opencode/src/tool/glob.ts
+++ b/packages/opencode/src/tool/glob.ts
@@ -5,6 +5,7 @@ import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
+import { Filesystem } from "../util/filesystem"
export const GlobTool = Tool.define("glob", {
description: DESCRIPTION,
@@ -28,8 +29,8 @@ export const GlobTool = Tool.define("glob", {
},
})
- let search = params.path ?? Instance.directory
- search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
+ let search = params.path ? Filesystem.normalize(params.path) : Instance.directory
+ search = path.isAbsolute(search) ? search : Filesystem.resolve(Instance.directory, search)
await assertExternalDirectory(ctx, search, { kind: "directory" })
const limit = 100
@@ -43,7 +44,7 @@ export const GlobTool = Tool.define("glob", {
truncated = true
break
}
- const full = path.resolve(search, file)
+ const full = Filesystem.resolve(search, file)
const stats = await Bun.file(full)
.stat()
.then((x) => x.mtime.getTime())
@@ -66,7 +67,7 @@ export const GlobTool = Tool.define("glob", {
}
return {
- title: path.relative(Instance.worktree, search),
+ title: Filesystem.relative(Instance.worktree, search),
metadata: {
count: files.length,
truncated,
diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts
index 097dedf4aaf..a75fe13f4fd 100644
--- a/packages/opencode/src/tool/grep.ts
+++ b/packages/opencode/src/tool/grep.ts
@@ -6,6 +6,7 @@ import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectory } from "./external-directory"
+import { Filesystem } from "../util/filesystem"
const MAX_LINE_LENGTH = 2000
@@ -33,7 +34,7 @@ export const GrepTool = Tool.define("grep", {
})
let searchPath = params.path ?? Instance.directory
- searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
+ searchPath = path.isAbsolute(searchPath) ? Filesystem.normalize(searchPath) : Filesystem.resolve(Instance.directory, searchPath)
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
const rgPath = await Ripgrep.filepath()
diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts
index cc3d750078f..e84e23f8a58 100644
--- a/packages/opencode/src/tool/ls.ts
+++ b/packages/opencode/src/tool/ls.ts
@@ -5,6 +5,7 @@ import DESCRIPTION from "./ls.txt"
import { Instance } from "../project/instance"
import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectory } from "./external-directory"
+import { Filesystem } from "../util/filesystem"
export const IGNORE_PATTERNS = [
"node_modules/",
@@ -42,7 +43,7 @@ export const ListTool = Tool.define("list", {
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params, ctx) {
- const searchPath = path.resolve(Instance.directory, params.path || ".")
+ const searchPath = Filesystem.resolve(Instance.directory, params.path || ".")
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
await ctx.ask({
@@ -66,7 +67,7 @@ export const ListTool = Tool.define("list", {
const filesByDir = new Map()
for (const file of files) {
- const dir = path.dirname(file)
+ const dir = Filesystem.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
@@ -90,7 +91,7 @@ export const ListTool = Tool.define("list", {
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
- .filter((d) => path.dirname(d) === dirPath && d !== dirPath)
+ .filter((d) => Filesystem.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
@@ -110,7 +111,7 @@ export const ListTool = Tool.define("list", {
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
- title: path.relative(Instance.worktree, searchPath),
+ title: Filesystem.relative(Instance.worktree, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts
index ca352280b2a..5d4842407a0 100644
--- a/packages/opencode/src/tool/lsp.ts
+++ b/packages/opencode/src/tool/lsp.ts
@@ -6,6 +6,7 @@ import DESCRIPTION from "./lsp.txt"
import { Instance } from "../project/instance"
import { pathToFileURL } from "url"
import { assertExternalDirectory } from "./external-directory"
+import { Filesystem } from "../util/filesystem"
const operations = [
"goToDefinition",
@@ -28,7 +29,7 @@ export const LspTool = Tool.define("lsp", {
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
execute: async (args, ctx) => {
- const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
+ const file = path.isAbsolute(args.filePath) ? args.filePath : Filesystem.join(Instance.directory, args.filePath)
await assertExternalDirectory(ctx, file)
await ctx.ask({
@@ -44,7 +45,7 @@ export const LspTool = Tool.define("lsp", {
character: args.character - 1,
}
- const relPath = path.relative(Instance.worktree, file)
+ const relPath = Filesystem.relative(Instance.worktree, file)
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
const exists = await Bun.file(file).exists()
diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts
index 7f562f4737a..20ecf7a2fc9 100644
--- a/packages/opencode/src/tool/multiedit.ts
+++ b/packages/opencode/src/tool/multiedit.ts
@@ -4,6 +4,7 @@ import { EditTool } from "./edit"
import DESCRIPTION from "./multiedit.txt"
import path from "path"
import { Instance } from "../project/instance"
+import { Filesystem } from "../util/filesystem"
export const MultiEditTool = Tool.define("multiedit", {
description: DESCRIPTION,
@@ -36,7 +37,7 @@ export const MultiEditTool = Tool.define("multiedit", {
results.push(result)
}
return {
- title: path.relative(Instance.worktree, params.filePath),
+ title: Filesystem.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},
diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts
index 08a58bfea9c..1c139307200 100644
--- a/packages/opencode/src/tool/patch.ts
+++ b/packages/opencode/src/tool/patch.ts
@@ -9,6 +9,7 @@ import { Instance } from "../project/instance"
import { Patch } from "../patch"
import { createTwoFilesPatch } from "diff"
import { assertExternalDirectory } from "./external-directory"
+import { Filesystem } from "../util/filesystem"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
@@ -48,7 +49,7 @@ export const PatchTool = Tool.define("patch", {
let totalDiff = ""
for (const hunk of hunks) {
- const filePath = path.resolve(Instance.directory, hunk.path)
+ const filePath = Filesystem.resolve(Instance.directory, hunk.path)
await assertExternalDirectory(ctx, filePath)
switch (hunk.type) {
@@ -91,7 +92,7 @@ export const PatchTool = Tool.define("patch", {
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
- const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
+ const movePath = hunk.move_path ? Filesystem.resolve(Instance.directory, hunk.move_path) : undefined
await assertExternalDirectory(ctx, movePath)
fileChanges.push({
@@ -126,7 +127,7 @@ export const PatchTool = Tool.define("patch", {
// Check permissions if needed
await ctx.ask({
permission: "edit",
- patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
+ patterns: fileChanges.map((c) => Filesystem.relative(Instance.worktree, c.filePath)),
always: ["*"],
metadata: {
diff: totalDiff,
@@ -140,7 +141,7 @@ export const PatchTool = Tool.define("patch", {
switch (change.type) {
case "add":
// Create parent directories
- const addDir = path.dirname(change.filePath)
+ const addDir = Filesystem.dirname(change.filePath)
if (addDir !== "." && addDir !== "/") {
await fs.mkdir(addDir, { recursive: true })
}
@@ -156,7 +157,7 @@ export const PatchTool = Tool.define("patch", {
case "move":
if (change.movePath) {
// Create parent directories for destination
- const moveDir = path.dirname(change.movePath)
+ const moveDir = Filesystem.dirname(change.movePath)
if (moveDir !== "." && moveDir !== "/") {
await fs.mkdir(moveDir, { recursive: true })
}
@@ -187,7 +188,7 @@ export const PatchTool = Tool.define("patch", {
}
// Generate output summary
- const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath))
+ const relativePaths = changedFiles.map((filePath) => Filesystem.relative(Instance.worktree, filePath))
const summary = `${fileChanges.length} files changed`
return {
diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts
index 6cb7a691c88..39c4e00b1d2 100644
--- a/packages/opencode/src/tool/plan.ts
+++ b/packages/opencode/src/tool/plan.ts
@@ -9,6 +9,7 @@ import { Provider } from "../provider/provider"
import { Instance } from "../project/instance"
import EXIT_DESCRIPTION from "./plan-exit.txt"
import ENTER_DESCRIPTION from "./plan-enter.txt"
+import { Filesystem } from "@/util/filesystem"
async function getLastModel(sessionID: string) {
for await (const item of MessageV2.stream(sessionID)) {
@@ -22,7 +23,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
- const plan = path.relative(Instance.worktree, Session.plan(session))
+ const plan = Filesystem.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
@@ -77,7 +78,7 @@ export const PlanEnterTool = Tool.define("plan_enter", {
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
- const plan = path.relative(Instance.worktree, Session.plan(session))
+ const plan = Filesystem.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index ce4ab28619d..dff870c0db4 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import { assertExternalDirectory } from "./external-directory"
+import { Filesystem } from "../util/filesystem"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -21,11 +22,11 @@ export const ReadTool = Tool.define("read", {
limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(),
}),
async execute(params, ctx) {
- let filepath = params.filePath
+ let filepath = Filesystem.normalize(params.filePath)
if (!path.isAbsolute(filepath)) {
- filepath = path.join(process.cwd(), filepath)
+ filepath = Filesystem.join(process.cwd(), filepath)
}
- const title = path.relative(Instance.worktree, filepath)
+ const title = Filesystem.relative(Instance.worktree, filepath)
await assertExternalDirectory(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
@@ -40,7 +41,7 @@ export const ReadTool = Tool.define("read", {
const file = Bun.file(filepath)
if (!(await file.exists())) {
- const dir = path.dirname(filepath)
+ const dir = Filesystem.dirname(filepath)
const base = path.basename(filepath)
const dirEntries = fs.readdirSync(dir)
@@ -49,7 +50,7 @@ export const ReadTool = Tool.define("read", {
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
)
- .map((entry) => path.join(dir, entry))
+ .map((entry) => Filesystem.join(dir, entry))
.slice(0, 3)
if (suggestions.length > 0) {
diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts
index 386abdae745..0fd4d0573d8 100644
--- a/packages/opencode/src/tool/skill.ts
+++ b/packages/opencode/src/tool/skill.ts
@@ -4,6 +4,7 @@ import { Tool } from "./tool"
import { Skill } from "../skill"
import { ConfigMarkdown } from "../config/markdown"
import { PermissionNext } from "../permission/next"
+import { Filesystem } from "../util/filesystem"
const parameters = z.object({
name: z.string().describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
@@ -57,7 +58,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
})
// Load and parse skill content
const parsed = await ConfigMarkdown.parse(skill.location)
- const dir = path.dirname(skill.location)
+ const dir = Filesystem.dirname(skill.location)
// Format output similar to plugin pattern
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")
diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts
index 4172b6447e6..5eedb4236cd 100644
--- a/packages/opencode/src/tool/truncation.ts
+++ b/packages/opencode/src/tool/truncation.ts
@@ -5,12 +5,13 @@ import { Identifier } from "../id/id"
import { lazy } from "../util/lazy"
import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
+import { Filesystem } from "../util/filesystem"
export namespace Truncate {
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
- export const DIR = path.join(Global.Path.data, "tool-output")
- export const GLOB = path.join(DIR, "*")
+ export const DIR = Filesystem.join(Global.Path.data, "tool-output")
+ export const GLOB = Filesystem.join(DIR, "*")
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
@@ -27,7 +28,7 @@ export namespace Truncate {
const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[])
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
- await fs.unlink(path.join(DIR, entry)).catch(() => {})
+ await fs.unlink(Filesystem.join(DIR, entry)).catch(() => {})
}
}
@@ -83,7 +84,7 @@ export namespace Truncate {
await init()
const id = Identifier.ascending("tool")
- const filepath = path.join(DIR, id)
+ const filepath = Filesystem.join(DIR, id)
await Bun.write(Bun.file(filepath), text)
const hint = hasTaskTool(agent)
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index cfcf6a0dab7..d2cfff1dac6 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -22,7 +22,8 @@ export const WriteTool = Tool.define("write", {
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
}),
async execute(params, ctx) {
- const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
+ const normalized = Filesystem.normalize(params.filePath)
+ const filepath = path.isAbsolute(normalized) ? normalized : Filesystem.resolve(Instance.directory, normalized)
await assertExternalDirectory(ctx, filepath)
const file = Bun.file(filepath)
@@ -33,7 +34,7 @@ export const WriteTool = Tool.define("write", {
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
await ctx.ask({
permission: "edit",
- patterns: [path.relative(Instance.worktree, filepath)],
+ patterns: [Filesystem.relative(Instance.worktree, filepath)],
always: ["*"],
metadata: {
filepath,
@@ -50,7 +51,7 @@ export const WriteTool = Tool.define("write", {
let output = "Wrote file successfully."
await LSP.touchFile(filepath, true)
const diagnostics = await LSP.diagnostics()
- const normalizedFilepath = Filesystem.normalizePath(filepath)
+ const normalizedFilepath = Filesystem.realpath(filepath)
let projectDiagnosticsCount = 0
for (const [file, issues] of Object.entries(diagnostics)) {
const errors = issues.filter((item) => item.severity === 1)
@@ -68,7 +69,7 @@ export const WriteTool = Tool.define("write", {
}
return {
- title: path.relative(Instance.worktree, filepath),
+ title: Filesystem.relative(Instance.worktree, filepath),
metadata: {
diagnostics,
filepath,
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index 7aff6bd1d30..b3905b061f4 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -1,5 +1,6 @@
import { realpathSync } from "fs"
-import { dirname, join, relative } from "path"
+import { Flag } from "@/flag/flag"
+import path from "path"
export namespace Filesystem {
export const exists = (p: string) =>
@@ -13,27 +14,49 @@ export namespace Filesystem {
.stat()
.then((s) => s.isDirectory())
.catch(() => false)
+
/**
* On Windows, normalize a path to its canonical casing using the filesystem.
* This is needed because Windows paths are case-insensitive but LSP servers
* may return paths with different casing than what we send them.
*/
- export function normalizePath(p: string): string {
+ export function realpath(p: string): string {
if (process.platform !== "win32") return p
try {
- return realpathSync.native(p)
+ return normalize(realpathSync.native(p))
} catch {
return p
}
}
- export function overlaps(a: string, b: string) {
- const relA = relative(a, b)
- const relB = relative(b, a)
- return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
+
+ /**
+ * Normalize a path to use forward slashes on all platforms.
+ * On Windows, also convert MSYS and Cygwin style paths to Windows drive letter paths.
+ */
+ export function normalize(p: string): string {
+ if (process.platform !== "win32") return p
+ return p.replace(/^\/(?:cygdrive\/)?([a-zA-Z])\//, (_, d) => `${d.toUpperCase()}:/`).replace(/\\+/g, "/")
+ }
+
+ export function relative(from: string, to: string) {
+ return normalize(path.relative(normalize(from), normalize(to)))
+ }
+
+ export function resolve(...segments: string[]) {
+ return normalize(path.resolve(...segments))
+ }
+
+ export function join(...segments: string[]) {
+ return normalize(path.join(...segments))
+ }
+
+ export function dirname(p: string) {
+ return normalize(path.dirname(p))
}
export function contains(parent: string, child: string) {
- return !relative(parent, child).startsWith("..")
+ const path = relative(parent, child)
+ return !/^\.\.|.:/.test(path)
}
export async function findUp(target: string, start: string, stop?: string) {
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 86cadca5d81..2fdd90589f4 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -540,8 +540,14 @@ test("resolves scoped npm plugins in config", async () => {
const config = await Config.get()
const pluginEntries = config.plugin ?? []
- const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
- const expected = import.meta.resolve("@scope/plugin", baseUrl)
+ // On Windows, import.meta.resolve() doesn't work with file:// URLs but works with regular paths
+ // On Linux, both file:// URLs and regular paths work
+ const resolveContext =
+ process.platform === "win32"
+ ? path.join(tmp.path, "opencode.json")
+ : pathToFileURL(path.join(tmp.path, "opencode.json")).href
+
+ const expected = import.meta.resolve("@scope/plugin", resolveContext)
expect(pluginEntries.includes(expected)).toBe(true)
diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts
index ed8c5e344a8..fbfa8faf57f 100644
--- a/packages/opencode/test/fixture/fixture.ts
+++ b/packages/opencode/test/fixture/fixture.ts
@@ -3,6 +3,7 @@ import * as fs from "fs/promises"
import os from "os"
import path from "path"
import type { Config } from "../../src/config/config"
+import { Filesystem } from "../../src/util/filesystem"
// Strip null bytes from paths (defensive fix for CI environment issues)
function sanitizePath(p: string): string {
@@ -32,7 +33,7 @@ export async function tmpdir(options?: TmpDirOptions) {
)
}
const extra = await options?.init?.(dirpath)
- const realpath = sanitizePath(await fs.realpath(dirpath))
+ const realpath = Filesystem.normalize(sanitizePath(await fs.realpath(dirpath)))
const result = {
[Symbol.asyncDispose]: async () => {
await options?.dispose?.(dirpath)
diff --git a/packages/opencode/test/ide/ide.test.ts b/packages/opencode/test/ide/ide.test.ts
index 4d70140197f..e10700e80ff 100644
--- a/packages/opencode/test/ide/ide.test.ts
+++ b/packages/opencode/test/ide/ide.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, test, afterEach } from "bun:test"
import { Ide } from "../../src/ide"
describe("ide", () => {
- const original = structuredClone(process.env)
+ const original = { ...process.env }
afterEach(() => {
Object.keys(process.env).forEach((key) => {
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index d44e606746e..3c8ffddd37e 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/packages/opencode/test/project/project.test.ts
@@ -5,6 +5,7 @@ import { Storage } from "../../src/storage/storage"
import { $ } from "bun"
import path from "path"
import { tmpdir } from "../fixture/fixture"
+import { Filesystem } from "../../src/util/filesystem"
Log.init({ print: false })
@@ -18,7 +19,7 @@ describe("Project.fromDirectory", () => {
expect(project).toBeDefined()
expect(project.id).toBe("global")
expect(project.vcs).toBe("git")
- expect(project.worktree).toBe(tmp.path)
+ expect(project.worktree).toBe(Filesystem.normalize(tmp.path))
const opencodeFile = path.join(tmp.path, ".git", "opencode")
const fileExists = await Bun.file(opencodeFile).exists()
@@ -33,7 +34,7 @@ describe("Project.fromDirectory", () => {
expect(project).toBeDefined()
expect(project.id).not.toBe("global")
expect(project.vcs).toBe("git")
- expect(project.worktree).toBe(tmp.path)
+ expect(project.worktree).toBe(Filesystem.normalize(tmp.path))
const opencodeFile = path.join(tmp.path, ".git", "opencode")
const fileExists = await Bun.file(opencodeFile).exists()
@@ -47,9 +48,9 @@ describe("Project.fromDirectory with worktrees", () => {
const { project, sandbox } = await Project.fromDirectory(tmp.path)
- expect(project.worktree).toBe(tmp.path)
- expect(sandbox).toBe(tmp.path)
- expect(project.sandboxes).not.toContain(tmp.path)
+ expect(project.worktree).toBe(Filesystem.normalize(tmp.path))
+ expect(sandbox).toBe(Filesystem.normalize(tmp.path))
+ expect(project.sandboxes).not.toContain(Filesystem.normalize(tmp.path))
})
test("should set worktree to root when called from a worktree", async () => {
@@ -60,10 +61,10 @@ describe("Project.fromDirectory with worktrees", () => {
const { project, sandbox } = await Project.fromDirectory(worktreePath)
- expect(project.worktree).toBe(tmp.path)
- expect(sandbox).toBe(worktreePath)
- expect(project.sandboxes).toContain(worktreePath)
- expect(project.sandboxes).not.toContain(tmp.path)
+ expect(project.worktree).toBe(Filesystem.normalize(tmp.path))
+ expect(sandbox).toBe(Filesystem.normalize(worktreePath))
+ expect(project.sandboxes).toContain(Filesystem.normalize(worktreePath))
+ expect(project.sandboxes).not.toContain(Filesystem.normalize(tmp.path))
await $`git worktree remove ${worktreePath}`.cwd(tmp.path).quiet()
})
@@ -79,10 +80,10 @@ describe("Project.fromDirectory with worktrees", () => {
await Project.fromDirectory(worktree1)
const { project } = await Project.fromDirectory(worktree2)
- expect(project.worktree).toBe(tmp.path)
- expect(project.sandboxes).toContain(worktree1)
- expect(project.sandboxes).toContain(worktree2)
- expect(project.sandboxes).not.toContain(tmp.path)
+ expect(project.worktree).toBe(Filesystem.normalize(tmp.path))
+ expect(project.sandboxes).toContain(Filesystem.normalize(worktree1))
+ expect(project.sandboxes).toContain(Filesystem.normalize(worktree2))
+ expect(project.sandboxes).not.toContain(Filesystem.normalize(tmp.path))
await $`git worktree remove ${worktree1}`.cwd(tmp.path).quiet()
await $`git worktree remove ${worktree2}`.cwd(tmp.path).quiet()
diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts
index cf933f81286..687cfde9184 100644
--- a/packages/opencode/test/snapshot/snapshot.test.ts
+++ b/packages/opencode/test/snapshot/snapshot.test.ts
@@ -295,7 +295,8 @@ test("very long filenames", async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
- const longName = "a".repeat(200) + ".txt"
+ const length = process.platform === "win32" ? 250 - tmp.path.length : 200
+ const longName = "a".repeat(length) + ".txt"
const longFile = `${tmp.path}/${longName}`
await Bun.write(longFile, "long filename content")
@@ -330,6 +331,13 @@ test("hidden files", async () => {
})
test("nested symlinks", async () => {
+ if (process.platform === "win32") {
+ // Skip on Windows: Symlink creation requires elevated privileges, Developer Mode, or core.symlinks=true in Git, which aren't default.
+ // Git's handling of directory symlinks is inconsistent, often treating them as copies or failing to track them as special entries.
+ // This causes test failures or unreliable results, unlike on Unix-like systems.
+ return
+ }
+
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts
index 750ff8193e9..ec957055b09 100644
--- a/packages/opencode/test/tool/bash.test.ts
+++ b/packages/opencode/test/tool/bash.test.ts
@@ -133,17 +133,18 @@ describe("tool.bash permissions", () => {
requests.push(req)
},
}
+ const tmpdir = process.platform === "win32" ? "C:\\Windows\\Temp" : "/tmp"
await bash.execute(
{
command: "ls",
- workdir: "/tmp",
+ workdir: tmpdir,
description: "List /tmp",
},
testCtx,
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
- expect(extDirReq!.patterns).toContain("/tmp")
+ expect(extDirReq!.patterns).toContain(tmpdir)
},
})
})
diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts
index b21f6a9715c..1f36e51376b 100644
--- a/packages/opencode/test/tool/external-directory.test.ts
+++ b/packages/opencode/test/tool/external-directory.test.ts
@@ -4,6 +4,7 @@ import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory"
import type { PermissionNext } from "../../src/permission/next"
+import { Filesystem } from "@/util/filesystem"
const baseCtx: Omit = {
sessionID: "test",
@@ -46,7 +47,7 @@ describe("tool.assertExternalDirectory", () => {
await Instance.provide({
directory: "/tmp/project",
fn: async () => {
- await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt"))
+ await assertExternalDirectory(ctx, Filesystem.join("/tmp/project", "file.txt"))
},
})
@@ -64,7 +65,7 @@ describe("tool.assertExternalDirectory", () => {
const directory = "/tmp/project"
const target = "/tmp/outside/file.txt"
- const expected = path.join(path.dirname(target), "*")
+ const expected = Filesystem.join(Filesystem.dirname(target), "*")
await Instance.provide({
directory,
@@ -90,7 +91,7 @@ describe("tool.assertExternalDirectory", () => {
const directory = "/tmp/project"
const target = "/tmp/outside"
- const expected = path.join(target, "*")
+ const expected = Filesystem.join(target, "*")
await Instance.provide({
directory,
diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts
index 0e5f0ba381d..7dbdb0ecf70 100644
--- a/packages/opencode/test/util/filesystem.test.ts
+++ b/packages/opencode/test/util/filesystem.test.ts
@@ -36,4 +36,73 @@ describe("util.filesystem", () => {
await rm(tmp, { recursive: true, force: true })
})
+
+ test("normalize() normalizes separators to forward slashes", () => {
+ if (process.platform === "win32") {
+ expect(Filesystem.normalize("C:/foo/bar")).toBe("C:/foo/bar")
+ expect(Filesystem.normalize("C:\\foo\\bar")).toBe("C:/foo/bar")
+ expect(Filesystem.normalize("/c/foo/bar")).toBe("C:/foo/bar")
+ expect(Filesystem.normalize("/cygdrive/c/foo/bar")).toBe("C:/foo/bar")
+ expect(Filesystem.normalize("/d/mixed\\path")).toBe("D:/mixed/path")
+ } else {
+ expect(Filesystem.normalize("/foo/bar")).toBe("/foo/bar")
+ expect(Filesystem.normalize("/c/foo/bar")).toBe("/c/foo/bar")
+ }
+ })
+
+ test("relative() with mixed separators", () => {
+ if (process.platform === "win32") {
+ expect(Filesystem.relative("C:/foo/bar", "C:/foo/baz")).toMatch(/^\.\./)
+ expect(Filesystem.relative("C:\\foo\\bar", "C:\\foo\\bar\\sub")).toMatch(/^sub/)
+ expect(Filesystem.relative("C:/foo", "C:/foo/../../etc")).toMatch(/^\.\./)
+ expect(Filesystem.relative("C:/foo", "D:/bar")).toMatch(/^D:/)
+ } else {
+ expect(Filesystem.relative("/foo/bar", "/foo/baz")).toMatch(/^\.\./)
+ expect(Filesystem.relative("/foo", "/foo/../etc")).toMatch(/^\.\./)
+ }
+ })
+
+ test("join() combines path segments", () => {
+ const result = Filesystem.join("foo", "bar", "baz.txt")
+ // Always uses forward slashes now
+ expect(result).toBe("foo/bar/baz.txt")
+ })
+
+ test("dirname() returns parent directory", () => {
+ expect(Filesystem.dirname(".")).toMatch(/^\.\.?$/)
+ if (process.platform === "win32") {
+ // Always uses forward slashes now
+ expect(Filesystem.dirname("\\")).toBe("/")
+ expect(Filesystem.dirname("C:/")).toBe("C:/")
+ expect(Filesystem.dirname("C:/file.txt")).toBe("C:/")
+ } else {
+ expect(Filesystem.dirname("/")).toBe("/")
+ }
+ })
+
+ test("contains() detects parent-child relationships", () => {
+ if (process.platform === "win32") {
+ expect(Filesystem.contains("C:/foo", "C:/foo/bar")).toBe(true)
+ expect(Filesystem.contains("C:/foo", "D:/foo/bar")).toBe(false)
+ expect(Filesystem.contains("C:/foo", "C:/foo/../etc")).toBe(false)
+ }
+ expect(Filesystem.contains("/foo", "/foo/bar/baz")).toBe(true)
+ expect(Filesystem.contains("/foo", "/bar")).toBe(false)
+ })
+
+ test("findUp() finds files in parent directories", async () => {
+ const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
+ const sub = path.join(tmp, "sub", "deep")
+
+ await mkdir(sub, { recursive: true })
+ await Bun.write(path.join(tmp, "config.txt"), "root")
+ await Bun.write(path.join(tmp, "sub", "config.txt"), "sub")
+
+ const results = await Filesystem.findUp("config.txt", sub)
+
+ expect(results.length).toBe(2)
+ expect(results.some((r) => r.includes("sub"))).toBe(true)
+
+ await rm(tmp, { recursive: true, force: true })
+ })
})
diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts
index 2da8028b46a..1f3dd96a1d2 100644
--- a/packages/util/src/path.ts
+++ b/packages/util/src/path.ts
@@ -7,7 +7,7 @@ export function getFilename(path: string | undefined) {
export function getDirectory(path: string | undefined) {
if (!path) return ""
- const parts = path.split("/")
+ const parts = path.split(/[\/\\]/)
return parts.slice(0, parts.length - 1).join("/") + "/"
}
From 30a54cebd2463c736a7b417d711fe1c50e7535d3 Mon Sep 17 00:00:00 2001
From: ops
Date: Sat, 17 Jan 2026 21:40:30 +0100
Subject: [PATCH 2/2] refactor: move path normalize code to path util
---
packages/opencode/src/util/filesystem.ts | 3 ++-
packages/ui/src/components/message-part.tsx | 5 +++--
packages/util/src/path.ts | 8 ++++++++
3 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index b3905b061f4..2eed7480576 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -1,6 +1,7 @@
import { realpathSync } from "fs"
import { Flag } from "@/flag/flag"
import path from "path"
+import { normalize as _normalize } from "@opencode-ai/util/path"
export namespace Filesystem {
export const exists = (p: string) =>
@@ -35,7 +36,7 @@ export namespace Filesystem {
*/
export function normalize(p: string): string {
if (process.platform !== "win32") return p
- return p.replace(/^\/(?:cygdrive\/)?([a-zA-Z])\//, (_, d) => `${d.toUpperCase()}:/`).replace(/\\+/g, "/")
+ return _normalize(p)
}
export function relative(from: string, to: string) {
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 165f46f6c50..3e3414261c1 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -41,7 +41,7 @@ import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
import { ImagePreview } from "./image-preview"
-import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
+import { getDirectory as _getDirectory, getFilename, normalize } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
@@ -61,7 +61,8 @@ function getDiagnostics(
filePath: string | undefined,
): Diagnostic[] {
if (!diagnosticsByFile || !filePath) return []
- const diagnostics = diagnosticsByFile[filePath] ?? []
+ const normalized = normalize(filePath)
+ const diagnostics = diagnosticsByFile[normalized] ?? diagnosticsByFile[filePath] ?? []
return diagnostics.filter((d) => d.severity === 1).slice(0, 3)
}
diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts
index 1f3dd96a1d2..0da50126ea2 100644
--- a/packages/util/src/path.ts
+++ b/packages/util/src/path.ts
@@ -16,3 +16,11 @@ export function getFileExtension(path: string | undefined) {
const parts = path.split(".")
return parts[parts.length - 1]
}
+
+/**
+ * Normalize a path to use forward slashes on all platforms.
+ * On Windows, also convert MSYS and Cygwin style paths to Windows drive letter paths.
+ */
+export function normalize(p: string): string {
+ return p.replace(/^\/(?:cygdrive\/)?([a-zA-Z])\//, (_, d) => `${d.toUpperCase()}:/`).replace(/\\+/g, "/")
+}