Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions packages/core/src/filesystem.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import { dirname, join, relative, resolve as pathResolve, win32 } from "path"
import { existsSync, realpathSync } from "fs"
import * as NFS from "fs/promises"
import { lookup } from "mime-types"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
Expand Down Expand Up @@ -188,7 +188,7 @@ export namespace AppFileSystem {

export function normalizePath(path: string): string {
if (process.platform !== "win32") return path
const resolved = pathResolve(windowsPath(path))
const resolved = normalizeWindowsAbsolutePath(windowsPath(path))
try {
return realpathSync.native(resolved)
} catch {
Expand All @@ -206,7 +206,8 @@ export namespace AppFileSystem {
}

export function resolve(path: string): string {
const resolved = pathResolve(windowsPath(path))
const resolved =
process.platform === "win32" ? normalizeWindowsAbsolutePath(windowsPath(path)) : pathResolve(path)
try {
return normalizePath(realpathSync(resolved))
} catch (error: any) {
Expand All @@ -224,6 +225,50 @@ export namespace AppFileSystem {
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
}

// Rooted but driveless paths (e.g. "/users/runner/...") arrive when callers
// strip the drive letter before handing the path back. path.resolve would
// bind such a path to the cwd's drive, which silently mismatches when the
// file actually lives on a different drive. Probe each known drive root for
// an existing file and fall back to win32.resolve only when none match.
//
// Important: this only repairs *existing* rooted-driveless paths. Targets
// that have not been created yet (e.g. write destinations) still fall back
// to the cwd-drive answer that win32.resolve produces. Callers that need
// a stable drive for a future path should pre-resolve via the project root.
function normalizeWindowsAbsolutePath(p: string): string {
const existing = resolveRootedWindowsVariant(p)
return win32.normalize(existing ?? win32.resolve(p))
}

function resolveRootedWindowsVariant(p: string): string | undefined {
if (!/^[\\/](?![\\/])/.test(p)) return
const suffix = p.replace(/^[\\/]+/, "").replaceAll("/", "\\")
for (const root of windowsDriveRoots()) {
const candidate = win32.join(root, suffix)
if (existsSync(candidate)) return candidate
}
}

function windowsDriveRoots(): string[] {
const result: string[] = []
const seen = new Set<string>()
const push = (input?: string) => {
if (!input) return
const match = input.match(/^([A-Za-z]:)/)
if (!match) return
const root = match[1].toUpperCase()
if (seen.has(root)) return
seen.add(root)
result.push(root + "\\")
}
push(process.cwd())
push(process.env.SystemDrive)
for (let code = 65; code <= 90; code++) {
push(String.fromCharCode(code) + ":")
}
return result
}
Comment thread
Astro-Han marked this conversation as resolved.

export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
Expand Down
139 changes: 139 additions & 0 deletions packages/core/test/filesystem/normalize-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { test, expect } from "bun:test"
import path from "path"
import os from "os"
import fs from "fs"
import { AppFileSystem } from "@opencode-ai/core/filesystem"

// All Windows variants below collapse to the same canonical form on Windows.
// On macOS/Linux normalizePath is the identity function, so we keep the
// Windows-only guards explicit to avoid accidental cross-platform skew.

test("normalizePath is identity on non-Windows", () => {
if (process.platform === "win32") return
const p = "/usr/local/bin/foo"
expect(AppFileSystem.normalizePath(p)).toBe(p)
})

test.skipIf(process.platform !== "win32")(
"normalizePath probes drive roots for rooted-but-driveless paths to existing files",
() => {
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "pawwork-fs-"))
try {
const file = path.join(tmpdir, "marker.txt")
fs.writeFileSync(file, "x")

const driveless = file.replace(/^[A-Za-z]:/, "").replaceAll("\\", "/").toLowerCase()
const result = AppFileSystem.normalizePath(driveless)

expect(result.toLowerCase()).toBe(file.toLowerCase())
} finally {
fs.rmSync(tmpdir, { recursive: true, force: true })
}
},
)

test.skipIf(process.platform !== "win32")(
"normalizePath canonicalizes Git Bash /c/... style paths",
() => {
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "pawwork-fs-"))
try {
const file = path.join(tmpdir, "marker.txt")
fs.writeFileSync(file, "x")

const drive = file.match(/^([A-Za-z]):/)![1].toLowerCase()
const tail = file.slice(2).replaceAll("\\", "/")
const gitBash = `/${drive}${tail}`
const result = AppFileSystem.normalizePath(gitBash)

expect(result.toLowerCase()).toBe(file.toLowerCase())
} finally {
fs.rmSync(tmpdir, { recursive: true, force: true })
}
},
)

test.skipIf(process.platform !== "win32")(
"normalizePath canonicalizes Cygwin /cygdrive/c/... paths",
() => {
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "pawwork-fs-"))
try {
const file = path.join(tmpdir, "marker.txt")
fs.writeFileSync(file, "x")

const drive = file.match(/^([A-Za-z]):/)![1].toLowerCase()
const tail = file.slice(2).replaceAll("\\", "/")
const cygwin = `/cygdrive/${drive}${tail}`
const result = AppFileSystem.normalizePath(cygwin)

expect(result.toLowerCase()).toBe(file.toLowerCase())
} finally {
fs.rmSync(tmpdir, { recursive: true, force: true })
}
},
)

test.skipIf(process.platform !== "win32")(
"normalizePath canonicalizes WSL /mnt/c/... paths",
() => {
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "pawwork-fs-"))
try {
const file = path.join(tmpdir, "marker.txt")
fs.writeFileSync(file, "x")

const drive = file.match(/^([A-Za-z]):/)![1].toLowerCase()
const tail = file.slice(2).replaceAll("\\", "/")
const wsl = `/mnt/${drive}${tail}`
const result = AppFileSystem.normalizePath(wsl)

expect(result.toLowerCase()).toBe(file.toLowerCase())
} finally {
fs.rmSync(tmpdir, { recursive: true, force: true })
}
},
)

test.skipIf(process.platform !== "win32")(
"normalizePath leaves drive-prefixed paths intact for already-canonical input",
() => {
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "pawwork-fs-"))
try {
const file = path.join(tmpdir, "marker.txt")
fs.writeFileSync(file, "x")

const result = AppFileSystem.normalizePath(file)
expect(result.toLowerCase()).toBe(file.toLowerCase())
} finally {
fs.rmSync(tmpdir, { recursive: true, force: true })
}
},
)

test.skipIf(process.platform !== "win32")(
"normalizePath falls back to cwd-drive resolution for non-existent rooted-driveless paths",
() => {
// The probe only repairs existing paths; documenting behavior so future
// changes don't accidentally start guessing for write targets.
const driveless = "/this/path/should/not/exist/anywhere/marker.txt"
const cwdDrive = process.cwd().match(/^([A-Za-z]:)/)![1].toUpperCase()
const expected = path.win32.normalize(path.win32.join(`${cwdDrive}\\`, driveless.replaceAll("/", "\\")))
const result = AppFileSystem.normalizePath(driveless)

expect(result.toUpperCase()).toBe(expected.toUpperCase())
},
)
Comment thread
Astro-Han marked this conversation as resolved.

test.skipIf(process.platform !== "win32")(
"normalizePathPattern preserves trailing /* glob",
() => {
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "pawwork-fs-"))
try {
const driveless = tmpdir.replace(/^[A-Za-z]:/, "").replaceAll("\\", "/").toLowerCase()
const pattern = AppFileSystem.normalizePathPattern(`${driveless}/*`)

expect(pattern.endsWith("\\*") || pattern.endsWith("/*")).toBe(true)
expect(pattern.toLowerCase()).toContain(tmpdir.toLowerCase())
} finally {
fs.rmSync(tmpdir, { recursive: true, force: true })
}
},
)
87 changes: 6 additions & 81 deletions packages/opencode/src/util/filesystem.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types"
import { realpathSync } from "fs"
import { dirname, isAbsolute as pathIsAbsolute, join, relative, resolve as pathResolve, win32 } from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Glob } from "./glob"

export namespace Filesystem {
Expand Down Expand Up @@ -111,89 +111,14 @@ export namespace 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 {
if (process.platform !== "win32") return p
const resolved = normalizeWindowsAbsolutePath(windowsPath(p))
try {
return realpathSync.native(resolved)
} catch {
return resolved
}
}

export function normalizePathPattern(p: string): string {
if (process.platform !== "win32") return p
if (p === "*") return p
const match = p.match(/^(.*)[\\/]\*$/)
if (!match) return normalizePath(p)
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
return join(normalizePath(dir), "*")
}

// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
// Also resolves symlinks so that callers using the result as a cache key
// always get the same canonical path for a given physical directory.
export function resolve(p: string): string {
const resolved = process.platform === "win32" ? normalizeWindowsAbsolutePath(windowsPath(p)) : pathResolve(p)
try {
return normalizePath(realpathSync(resolved))
} catch (e) {
if (isEnoent(e)) return normalizePath(resolved)
throw e
}
}

export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return (
p
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Git Bash for Windows paths are typically /<drive>/...
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Cygwin git paths are typically /cygdrive/<drive>/...
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// WSL paths are typically /mnt/<drive>/...
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
)
}

function normalizeWindowsAbsolutePath(p: string) {
const existing = resolveRootedWindowsVariant(p)
return win32.normalize(existing ?? win32.resolve(p))
}

function resolveRootedWindowsVariant(p: string) {
if (!/^[\\/](?![\\/])/.test(p)) return
const suffix = p.replace(/^[\\/]+/, "").replaceAll("/", "\\")
for (const root of windowsDriveRoots()) {
const candidate = win32.join(root, suffix)
if (existsSync(candidate)) return candidate
}
}

function windowsDriveRoots() {
const result: string[] = []
const seen = new Set<string>()
const push = (input?: string) => {
if (!input) return
const match = input.match(/^([A-Za-z]:)/)
if (!match) return
const root = match[1].toUpperCase()
if (seen.has(root)) return
seen.add(root)
result.push(root + "\\")
}
push(process.cwd())
push(process.env.SystemDrive)
for (let code = 65; code <= 90; code++) {
push(String.fromCharCode(code) + ":")
}
return result
}
export const normalizePath = AppFileSystem.normalizePath
export const normalizePathPattern = AppFileSystem.normalizePathPattern
export const resolve = AppFileSystem.resolve
export const windowsPath = AppFileSystem.windowsPath

function comparablePath(p: string) {
if (process.platform !== "win32") return pathResolve(p)
return win32.normalize(win32.resolve(windowsPath(p)))
return AppFileSystem.normalizePath(p)
}

function comparableRelative(from: string, to: string) {
Expand Down
Loading