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, "/") +}