diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 6e787c7afddd..d151cdc89228 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -14,6 +14,7 @@ import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" import { Instance } from "@/project/instance" +import { StoredPath } from "@/path/path" declare global { const OPENCODE_WORKER_PATH: string @@ -115,10 +116,10 @@ export const TuiThreadCommand = cmd({ // Resolve relative --project paths from PWD, then use the real cwd after // chdir so the thread and worker share the same directory key. - const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) + const root = StoredPath.parse(process.env.PWD ?? process.cwd()) const next = args.project - ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) - : Filesystem.resolve(process.cwd()) + ? StoredPath.parse(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) + : StoredPath.parse(process.cwd()) const file = await target() try { process.chdir(next) @@ -126,7 +127,9 @@ export const TuiThreadCommand = cmd({ UI.error("Failed to change directory to " + next) return } - const cwd = Filesystem.resolve(process.cwd()) + // After chdir we intentionally collapse to the physical cwd so the thread, + // worker, and downstream session keys agree on one target directory. + const cwd = StoredPath.parse(Filesystem.resolve(process.cwd())) const worker = new Worker(file, { env: Object.fromEntries( diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index f84890950115..7f9e03ed9568 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -1,4 +1,5 @@ import z from "zod" +import { StoredPath } from "@/path/path" import { Worktree } from "@/worktree" import { type Adaptor, WorkspaceInfo } from "../types" @@ -17,7 +18,7 @@ export const WorktreeAdaptor: Adaptor = { ...info, name: worktree.name, branch: worktree.branch, - directory: worktree.directory, + directory: StoredPath.parse(worktree.directory), } }, async create(info) { diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index ab628a693814..1e6f029103b0 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,5 +1,6 @@ import z from "zod" import { ProjectID } from "@/project/schema" +import { StoredPath } from "@/path/path" import { WorkspaceID } from "./schema" export const WorkspaceInfo = z.object({ @@ -7,7 +8,7 @@ export const WorkspaceInfo = z.object({ type: z.string(), branch: z.string().nullable(), name: z.string().nullable(), - directory: z.string().nullable(), + directory: (z.string() as unknown as z.ZodType).nullable(), extra: z.unknown().nullable(), projectID: ProjectID.zod, }) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e5294844b148..3ac191ed2b09 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -12,6 +12,7 @@ import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" +import { StoredPath } from "@/path/path" export namespace Workspace { export const Event = { @@ -40,7 +41,7 @@ export namespace Workspace { type: row.type, branch: row.branch, name: row.name, - directory: row.directory, + directory: row.directory ? StoredPath.unsafe(row.directory) : null, extra: row.extra, projectID: row.project_id, } @@ -65,7 +66,7 @@ export namespace Workspace { type: config.type, branch: config.branch ?? null, name: config.name ?? null, - directory: config.directory ?? null, + directory: config.directory ? StoredPath.parse(config.directory) : null, extra: config.extra ?? null, projectID: input.projectID, } diff --git a/packages/opencode/src/path/migration.ts b/packages/opencode/src/path/migration.ts new file mode 100644 index 000000000000..4ee7a11be309 --- /dev/null +++ b/packages/opencode/src/path/migration.ts @@ -0,0 +1,69 @@ +import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" +import path from "path" +import { eq } from "@/storage/db" +import { Global } from "@/global" +import { Log } from "@/util/log" +import { existsSync, writeFileSync } from "fs" +import { ProjectTable } from "@/project/project.sql" +import { SessionTable } from "@/session/session.sql" +import { WorkspaceTable } from "@/control-plane/workspace.sql" +import { StoredPath } from "./path" + +export namespace PathMigration { + const log = Log.create({ service: "path-migration" }) + + const uniq = (input: string[]) => [...new Set(input.map((item) => StoredPath.parse(item)))] + + export type Stats = { + projects: number + sessions: number + workspaces: number + } + + export function run(db: SQLiteBunDatabase, input: { force?: boolean; marker?: string } = {}) { + if (process.platform !== "win32") { + return { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats + } + + const mark = input.marker ?? path.join(Global.Path.state, "stored-path-v1") + if (!input.force && existsSync(mark)) { + return { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats + } + + const stats = { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats + + for (const row of db.select().from(ProjectTable).all()) { + const worktree = StoredPath.parse(row.worktree) + const sandboxes = uniq(row.sandboxes) + if ( + worktree === row.worktree && + sandboxes.every((item, idx) => item === row.sandboxes[idx]) && + sandboxes.length === row.sandboxes.length + ) + continue + + db.update(ProjectTable).set({ worktree, sandboxes }).where(eq(ProjectTable.id, row.id)).run() + stats.projects += 1 + } + + for (const row of db.select().from(SessionTable).all()) { + const directory = StoredPath.parse(row.directory) + if (directory === row.directory) continue + + db.update(SessionTable).set({ directory }).where(eq(SessionTable.id, row.id)).run() + stats.sessions += 1 + } + + for (const row of db.select().from(WorkspaceTable).all()) { + const directory = row.directory ? StoredPath.parse(row.directory) : null + if (directory === row.directory) continue + + db.update(WorkspaceTable).set({ directory }).where(eq(WorkspaceTable.id, row.id)).run() + stats.workspaces += 1 + } + + log.info("completed", stats) + if (!input.force) writeFileSync(mark, "1") + return stats + } +} diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts new file mode 100644 index 000000000000..f047d04d82d5 --- /dev/null +++ b/packages/opencode/src/path/path.ts @@ -0,0 +1,117 @@ +import { Filesystem } from "@/util/filesystem" +import { lstatSync, readdirSync, realpathSync } from "fs" +import path from "path" + +/** + * Any legal path text we ingest from the outside world. + * + * You might see: + * - `C:\Users\RUNNER~1\repo` + * - `C:/Users/runneradmin/repo` + * - `/c/Users/runneradmin/repo` + * - `/cygdrive/c/Users/runneradmin/repo` + * + * You should not assume: + * - native separators + * - canonical casing + * - long Windows names + * - symlinks resolved + * - safe to persist directly + */ +export type RawPath = string & { readonly __raw: unique symbol } + +/** + * Canonical path value we keep in storage and long-lived runtime state. + * + * You might see: + * - `C:\Users\runneradmin\repo` + * - `C:\Repos\my-link` + * - `/Users/luke/repo` + * + * You should not see: + * - `RUNNER~1` + * - `/cygdrive/c/...` + * - slash-only key forms used just for comparison + * + * Stored paths preserve the user's chosen route. They should not resolve + * symlink, junction, mapped-drive, or UNC roots to some different target. + */ +export type StoredPath = string & { readonly __stored: unique symbol } + +const caseMatch = (dir: string, part: string) => { + try { + const low = part.toLowerCase() + return readdirSync(dir).find((item) => item.toLowerCase() === low) + } catch { + return + } +} + +const root = (input: string) => input.replace(/^[a-z]:/, (x) => x.toUpperCase()) + +const storedWin = (input: string) => { + const full = path.resolve(Filesystem.windowsPath(input)) + const base = root(path.parse(full).root) + const parts = full + .slice(base.length) + .split(/[\\/]+/) + .filter(Boolean) + let dir = base + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const next = path.join(dir, part) + const hit = (() => { + try { + return lstatSync(next) + } catch { + return + } + })() + + if (!hit) return path.join(dir, ...parts.slice(i)) as StoredPath + if (hit.isSymbolicLink()) { + dir = path.join(dir, caseMatch(dir, part) ?? part) + continue + } + + const name = (() => { + try { + return path.basename(realpathSync.native(next)) + } catch { + return caseMatch(dir, part) ?? part + } + })() + dir = path.join(dir, name) + } + + return dir as StoredPath +} + +export namespace StoredPath { + /** + * Parse raw external input into the canonical stored path form. + */ + export function parse(input: RawPath | string): StoredPath { + // Legacy sentinel: "/" is used internally to represent the global/non-project + // pseudo-root. It is not a real Windows StoredPath, but we preserve it here + // for backward compatibility with existing project/session/instance logic. + // Tech debt: split this sentinel out from StoredPath entirely. + if (!input || input === "/") return input as StoredPath + if (process.platform !== "win32") return path.resolve(input) as StoredPath + return storedWin(input) + } + + /** + * Rehydrate a value that is already stored in canonical form. + * + * Use this for trusted values we previously persisted ourselves. + */ + export function unsafe(input: string): StoredPath { + return input as StoredPath + } + + export function same(a: RawPath | string, b: RawPath | string) { + return Filesystem.resolve(parse(a)) === Filesystem.resolve(parse(b)) + } +} diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 4c9b2e107bc8..a3b10ec59041 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -3,6 +3,7 @@ import { disposeInstance } from "@/effect/instance-registry" import { Filesystem } from "@/util/filesystem" import { iife } from "@/util/iife" import { Log } from "@/util/log" +import { StoredPath } from "@/path/path" import { Context } from "../util/context" import { Project } from "./project" import { State } from "./state" @@ -13,7 +14,7 @@ export interface Shape { project: Project.Info } const context = Context.create("instance") -const cache = new Map>() +const cache = new Map>() const disposal = { all: undefined as Promise | undefined, @@ -52,18 +53,18 @@ function boot(input: { directory: string; init?: () => Promise; project?: P }) } -function track(directory: string, next: Promise) { +function track(dir: StoredPath, next: Promise) { const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) + if (cache.get(dir) === task) cache.delete(dir) throw error }) - cache.set(directory, task) + cache.set(dir, task) return task } export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = Filesystem.resolve(input.directory) + const directory = StoredPath.parse(input.directory) let existing = cache.get(directory) if (!existing) { Log.Default.info("creating instance", { directory }) @@ -117,7 +118,7 @@ export const Instance = { return State.create(() => Instance.directory, init, dispose) }, async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = Filesystem.resolve(input.directory) + const directory = StoredPath.parse(input.directory) Log.Default.info("reloading instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) @@ -129,7 +130,7 @@ export const Instance = { const directory = Instance.directory Log.Default.info("disposing instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) - cache.delete(directory) + cache.delete(directory as StoredPath) emit(directory) }, async disposeAll() { diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 1cef41c85c91..1ad5f0c0c680 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -15,26 +15,35 @@ import { git } from "../util/git" import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" +import { StoredPath } from "@/path/path" export namespace Project { const log = Log.create({ service: "project" }) - function gitpath(cwd: string, name: string) { - if (!name) return cwd + function preserve(curr: StoredPath, next: string) { + const full = StoredPath.parse(next) + if (StoredPath.same(curr, full)) return curr + return full + } + + function gitpath(cwd: string, name: string): StoredPath { + if (!name) return StoredPath.parse(cwd) // git output includes trailing newlines; keep path whitespace intact. name = name.replace(/[\r\n]+$/, "") - if (!name) return cwd + if (!name) return StoredPath.parse(cwd) name = Filesystem.windowsPath(name) - if (path.isAbsolute(name)) return path.normalize(name) - return path.resolve(cwd, name) + if (path.isAbsolute(name)) return StoredPath.parse(name) + return StoredPath.parse(path.resolve(cwd, name)) } + const PathValue = z.string() as unknown as z.ZodType + export const Info = z .object({ id: ProjectID.zod, - worktree: z.string(), + worktree: PathValue, vcs: z.literal("git").optional(), name: z.string().optional(), icon: z @@ -54,7 +63,7 @@ export namespace Project { updated: z.number(), initialized: z.number().optional(), }), - sandboxes: z.array(z.string()), + sandboxes: z.array(PathValue), }) .meta({ ref: "Project", @@ -74,7 +83,7 @@ export namespace Project { : undefined return { id: ProjectID.make(row.id), - worktree: row.worktree, + worktree: StoredPath.unsafe(row.worktree), vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, icon, @@ -83,7 +92,7 @@ export namespace Project { updated: row.time_updated, initialized: row.time_initialized ?? undefined, }, - sandboxes: row.sandboxes, + sandboxes: row.sandboxes.map(StoredPath.unsafe), commands: row.commands ?? undefined, } } @@ -96,6 +105,7 @@ export namespace Project { } export async function fromDirectory(directory: string) { + directory = StoredPath.parse(directory) log.info("fromDirectory", { directory }) const data = await iife(async () => { @@ -103,7 +113,7 @@ export namespace Project { const dotgit = await matches.next().then((x) => x.value) await matches.return() if (dotgit) { - let sandbox = path.dirname(dotgit) + let sandbox = StoredPath.parse(path.dirname(dotgit)) const gitBinary = which("git") @@ -125,7 +135,7 @@ export namespace Project { .then(async (result) => { const common = gitpath(sandbox, await result.text()) // Avoid going to parent of sandbox when git-common-dir is empty. - return common === sandbox ? sandbox : path.dirname(common) + return common === sandbox ? sandbox : preserve(sandbox, path.dirname(common)) }) .catch(() => undefined) @@ -199,7 +209,7 @@ export namespace Project { } } - sandbox = top + sandbox = preserve(sandbox, top) return { id, @@ -211,20 +221,20 @@ export namespace Project { return { id: ProjectID.global, - worktree: "/", - sandbox: "/", + worktree: StoredPath.unsafe("/"), + sandbox: StoredPath.unsafe("/"), vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), } }) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) - const existing = row + const existing: Info = row ? fromRow(row) : { id: data.id, worktree: data.worktree, vcs: data.vcs as Info["vcs"], - sandboxes: [] as string[], + sandboxes: [] as StoredPath[], time: { created: Date.now(), updated: Date.now(), @@ -410,6 +420,7 @@ export namespace Project { export async function addSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) + directory = StoredPath.parse(directory) const sandboxes = [...row.sandboxes] if (!sandboxes.includes(directory)) sandboxes.push(directory) const result = Database.use((db) => @@ -434,6 +445,7 @@ export namespace Project { export async function removeSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) + directory = StoredPath.parse(directory) const sandboxes = row.sandboxes.filter((s) => s !== directory) const result = Database.use((db) => db diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7ead4df8a3cb..b3a8409d9640 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -38,6 +38,7 @@ import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" import { Filesystem } from "@/util/filesystem" +import { StoredPath } from "@/path/path" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -193,7 +194,7 @@ export namespace Server { if (c.req.path === "/log") return next() const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = Filesystem.resolve( + const directory = StoredPath.parse( (() => { try { return decodeURIComponent(raw) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index f2d436ff10d9..717bd8106dc5 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -25,6 +25,7 @@ import { WorkspaceContext } from "../control-plane/workspace-context" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" +import { StoredPath } from "@/path/path" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" @@ -51,6 +52,8 @@ export namespace Session { type SessionRow = typeof SessionTable.$inferSelect + const PathValue = z.string() as unknown as z.ZodType + export function fromRow(row: SessionRow): Info { const summary = row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null @@ -68,7 +71,7 @@ export namespace Session { slug: row.slug, projectID: row.project_id, workspaceID: row.workspace_id ?? undefined, - directory: row.directory, + directory: StoredPath.unsafe(row.directory), parentID: row.parent_id ?? undefined, title: row.title, version: row.version, @@ -125,7 +128,7 @@ export namespace Session { slug: z.string(), projectID: ProjectID.zod, workspaceID: WorkspaceID.zod.optional(), - directory: z.string(), + directory: PathValue, parentID: SessionID.zod.optional(), summary: z .object({ @@ -167,7 +170,7 @@ export namespace Session { .object({ id: ProjectID.zod, name: z.string().optional(), - worktree: z.string(), + worktree: PathValue, }) .meta({ ref: "ProjectSummary", @@ -307,7 +310,7 @@ export namespace Session { slug: Slug.create(), version: Installation.VERSION, projectID: Instance.project.id, - directory: input.directory, + directory: StoredPath.parse(input.directory), workspaceID: input.workspaceID, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), @@ -552,7 +555,7 @@ export namespace Session { conditions.push(eq(SessionTable.workspace_id, WorkspaceContext.workspaceID)) } if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + conditions.push(eq(SessionTable.directory, StoredPath.parse(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) @@ -592,7 +595,7 @@ export namespace Session { const conditions: SQL[] = [] if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + conditions.push(eq(SessionTable.directory, StoredPath.parse(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) @@ -638,7 +641,7 @@ export namespace Session { projects.set(item.id, { id: item.id, name: item.name ?? undefined, - worktree: item.worktree, + worktree: StoredPath.unsafe(item.worktree), }) } } diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 1bb8c1a69bf3..590fbcc22775 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -14,6 +14,7 @@ import { Installation } from "../installation" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" import { init } from "#db" +import { PathMigration } from "@/path/migration" declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined @@ -108,6 +109,8 @@ export namespace Database { migrate(db, entries) } + PathMigration.run(db) + return db }) diff --git a/packages/opencode/test/control-plane/workspace-sync.test.ts b/packages/opencode/test/control-plane/workspace-sync.test.ts index 0f8d608fb39b..36b21489d344 100644 --- a/packages/opencode/test/control-plane/workspace-sync.test.ts +++ b/packages/opencode/test/control-plane/workspace-sync.test.ts @@ -9,6 +9,13 @@ import { GlobalBus } from "../../src/bus/global" import { resetDatabase } from "../fixture/db" import * as adaptors from "../../src/control-plane/adaptors" import type { Adaptor } from "../../src/control-plane/types" +import { StoredPath } from "../../src/path/path" + +const bash = (input: string) => { + const drive = input[0].toLowerCase() + const rest = input.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` +} afterEach(async () => { mock.restore() @@ -96,4 +103,37 @@ describe("control-plane/workspace.startSyncing", () => { await sync.stop() }) + + test("stores and rehydrates workspace directories in one stored form", async () => { + const { Workspace } = await import("../../src/control-plane/workspace") + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const dir = process.platform === "win32" ? bash(tmp.path) : tmp.path + adaptors.installAdaptor("testing-path", { + ...TestAdaptor, + configure(config) { + return { + ...config, + directory: StoredPath.parse(dir), + name: "remote-b", + } + }, + async create() {}, + }) + + const created = await Workspace.create({ + type: "testing-path", + branch: null, + projectID: project.id, + extra: null, + }) + + const loaded = await Workspace.get(created.id) + const listed = Workspace.list(project) + + expect(created.directory).toBe(StoredPath.unsafe(tmp.path)) + expect(loaded?.directory).toBe(StoredPath.unsafe(tmp.path)) + expect(listed.find((item) => item.id === created.id)?.directory).toBe(StoredPath.unsafe(tmp.path)) + }) }) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 2d527482ba11..723bd98a91cd 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -4,6 +4,12 @@ import { InstanceState } from "../../src/effect/instance-state" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" +const bash = (input: string) => { + const drive = input[0].toLowerCase() + const rest = input.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` +} + async function access(state: InstanceState, dir: string) { return Instance.provide({ directory: dir, @@ -85,6 +91,60 @@ test("InstanceState invalidates on reload", async () => { ) }) +test("Instance.provide collapses equivalent Windows bash spellings", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + let n = 0 + + const a = await Instance.provide({ + directory: tmp.path, + init: async () => { + n += 1 + }, + fn: async () => Instance.directory, + }) + + const b = await Instance.provide({ + directory: bash(tmp.path), + init: async () => { + n += 1 + }, + fn: async () => Instance.directory, + }) + + expect(a).toBe(tmp.path) + expect(b).toBe(tmp.path) + expect(n).toBe(1) +}) + +test("Instance.provide collapses drive-letter casing variants", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + let n = 0 + + const raw = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + + const a = await Instance.provide({ + directory: tmp.path, + init: async () => { + n += 1 + }, + fn: async () => Instance.directory, + }) + + const b = await Instance.provide({ + directory: raw, + init: async () => { + n += 1 + }, + fn: async () => Instance.directory, + }) + + expect(a).toBe(tmp.path) + expect(b).toBe(tmp.path) + expect(n).toBe(1) +}) + test("InstanceState invalidates on disposeAll", async () => { await using one = await tmpdir() await using two = await tmpdir() diff --git a/packages/opencode/test/path/migration.test.ts b/packages/opencode/test/path/migration.test.ts new file mode 100644 index 000000000000..3b83f93accbb --- /dev/null +++ b/packages/opencode/test/path/migration.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, test } from "bun:test" +import path from "path" +import { Global } from "../../src/global" +import { Database } from "../../src/storage/db" +import { ProjectID } from "../../src/project/schema" +import { ProjectTable } from "../../src/project/project.sql" +import { SessionID } from "../../src/session/schema" +import { SessionTable } from "../../src/session/session.sql" +import { WorkspaceID } from "../../src/control-plane/schema" +import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { PathMigration } from "../../src/path/migration" +import { tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" +import { eq } from "../../src/storage/db" + +const bash = (input: string) => { + const drive = input[0].toLowerCase() + const rest = input.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` +} + +afterEach(async () => { + await resetDatabase() +}) + +describe("path migration", () => { + test("rewrites old stored project, sandbox, session, and workspace paths", async () => { + if (process.platform !== "win32") return + await resetDatabase() + await using tmp = await tmpdir({ git: true }) + + const prev = Global.Path.state + ;(Global.Path as { state: string }).state = tmp.path + + try { + const low = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + const raw = bash(tmp.path) + const projectID = ProjectID.make("project-path-migrate") + const sessionID = SessionID.make("session-path-migrate") + const workspaceID = WorkspaceID.make("workspace-path-migrate") + + Database.use((db) => { + db.insert(ProjectTable) + .values({ + id: projectID, + worktree: raw, + vcs: "git", + name: null, + icon_url: null, + icon_color: null, + time_created: 1, + time_updated: 1, + time_initialized: null, + sandboxes: [low, tmp.path], + commands: null, + }) + .run() + + db.insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + workspace_id: null, + parent_id: null, + slug: "session-path-migrate", + directory: raw, + title: "session", + version: "v2", + share_url: null, + summary_additions: null, + summary_deletions: null, + summary_files: null, + summary_diffs: null, + revert: null, + permission: null, + time_created: 1, + time_updated: 1, + time_compacting: null, + time_archived: null, + }) + .run() + + db.insert(WorkspaceTable) + .values({ + id: workspaceID, + type: "worktree", + branch: null, + name: "workspace", + directory: low, + extra: null, + project_id: projectID, + }) + .run() + }) + + const stats = PathMigration.run(Database.Client(), { force: true, marker: path.join(tmp.path, "marker") }) + + const project = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()) + const session = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) + const workspace = Database.use((db) => + db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get(), + ) + + expect(stats).toEqual({ projects: 1, sessions: 1, workspaces: 1 }) + expect(project?.worktree).toBe(tmp.path) + expect(project?.sandboxes).toEqual([tmp.path]) + expect(session?.directory).toBe(tmp.path) + expect(workspace?.directory).toBe(tmp.path) + } finally { + ;(Global.Path as { state: string }).state = prev + } + }) +}) diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts new file mode 100644 index 000000000000..99a349b563e7 --- /dev/null +++ b/packages/opencode/test/path/path.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test" +import { dlopen, ptr } from "bun:ffi" +import fs from "fs/promises" +import path from "path" +import { StoredPath } from "../../src/path/path" +import { tmpdir } from "../fixture/fixture" + +const k32 = + process.platform === "win32" + ? dlopen("kernel32.dll", { + GetShortPathNameW: { args: ["ptr", "ptr", "u32"], returns: "u32" }, + }) + : undefined + +const wide = (input: string) => Buffer.from(input + "\0", "utf16le") + +const text = (input: Uint16Array) => { + const end = input.indexOf(0) + return Buffer.from(input.buffer, input.byteOffset, (end === -1 ? input.length : end) * 2).toString("utf16le") +} + +const short = (input: string) => { + if (!k32) return + const src = wide(input) + const out = new Uint16Array(4096) + const len = k32.symbols.GetShortPathNameW(ptr(src), ptr(out), out.length) + if (!len) return + return text(out) +} + +describe("path", () => { + const bash = (input: string) => { + const drive = input[0].toLowerCase() + const rest = input.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` + } + + test("keeps sentinel storage paths unchanged", () => { + expect(String(StoredPath.parse(""))).toBe("") + expect(String(StoredPath.parse("/"))).toBe("/") + }) + + test("keeps missing paths on the chosen route", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "missing", "child") + expect(String(StoredPath.parse(dir))).toBe(path.resolve(dir)) + }) + + test("preserves symlink routes in stored paths", async () => { + await using tmp = await tmpdir() + + const real = path.join(tmp.path, "Target") + await fs.mkdir(real, { recursive: true }) + await fs.mkdir(path.join(real, "Leaf"), { recursive: true }) + const alias = path.join(tmp.path, "Alias") + await fs.symlink(real, alias, process.platform === "win32" ? "junction" : "dir") + + expect(String(StoredPath.parse(alias))).toBe(alias) + expect(String(StoredPath.parse(path.join(alias, "Leaf")))).toBe(path.join(alias, "Leaf")) + expect(String(StoredPath.parse(alias))).not.toBe(real) + expect(String(StoredPath.parse(path.join(alias, "Leaf")))).not.toBe(path.join(real, "Leaf")) + }) + + test("keeps missing descendants on a chosen symlink route", async () => { + await using tmp = await tmpdir() + + const real = path.join(tmp.path, "Target") + await fs.mkdir(real, { recursive: true }) + const alias = path.join(tmp.path, "Alias") + await fs.symlink(real, alias, process.platform === "win32" ? "junction" : "dir") + + expect(String(StoredPath.parse(path.join(alias, "missing", "child")))).toBe(path.join(alias, "missing", "child")) + expect(String(StoredPath.parse(path.join(alias, "missing", "child")))).not.toBe(path.join(real, "missing", "child")) + }) + + test("keeps native posix routes without introducing Windows key forms", async () => { + if (process.platform === "win32") return + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "Thing") + await fs.mkdir(dir, { recursive: true }) + + expect(String(StoredPath.parse(dir))).toBe(dir) + expect(String(StoredPath.parse(dir))).not.toContain("\\") + }) + + test("normalizes Windows bash-style paths to native routes", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + + const root = bash(tmp.path) + const drive = tmp.path[0].toLowerCase() + const rest = tmp.path.slice(2).replaceAll("\\", "/") + + expect(String(StoredPath.parse(root))).toBe(tmp.path) + expect(String(StoredPath.parse(`/cygdrive/${drive}${rest}`))).toBe(tmp.path) + expect(String(StoredPath.parse(`/mnt/${drive}${rest}`))).toBe(tmp.path) + expect(String(StoredPath.parse(root))).not.toContain("/cygdrive/") + expect(String(StoredPath.parse(root))).not.toContain(`/mnt/${drive}`) + }) + + test("canonicalizes lowercase drive roots to the stored drive casing", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + + const raw = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + expect(String(StoredPath.parse(raw))).toBe(tmp.path) + }) + + test("expands Windows short-name aliases when one exists", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + + const raw = short(tmp.path) + if (!raw || raw === tmp.path) return + + expect(raw).toContain("~") + expect(String(StoredPath.parse(raw))).toBe(tmp.path) + expect(String(StoredPath.parse(raw))).not.toContain("~") + }) + + test("preserves UNC routes for network-style paths", () => { + if (process.platform !== "win32") return + const dir = "\\\\server\\share\\Repo\\folder" + expect(String(StoredPath.parse(dir))).toBe(dir) + }) +}) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index a71fe0528f02..dea3055ebe89 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -2,14 +2,18 @@ import { describe, expect, mock, test } from "bun:test" import { Project } from "../../src/project/project" import { Log } from "../../src/util/log" import { $ } from "bun" +import fs from "fs/promises" import path from "path" import { tmpdir } from "../fixture/fixture" import { Filesystem } from "../../src/util/filesystem" import { GlobalBus } from "../../src/bus/global" +import { StoredPath } from "../../src/path/path" import { ProjectID } from "../../src/project/schema" Log.init({ print: false }) +const trust = StoredPath.unsafe + const gitModule = await import("../../src/util/git") const originalGit = gitModule.git @@ -77,7 +81,7 @@ describe("Project.fromDirectory", () => { expect(project).toBeDefined() expect(project.id).toBe(ProjectID.global) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) const opencodeFile = path.join(tmp.path, ".git", "opencode") const fileExists = await Filesystem.exists(opencodeFile) @@ -93,13 +97,27 @@ describe("Project.fromDirectory", () => { expect(project).toBeDefined() expect(project.id).not.toBe(ProjectID.global) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) const opencodeFile = path.join(tmp.path, ".git", "opencode") const fileExists = await Filesystem.exists(opencodeFile) expect(fileExists).toBe(true) }) + test("preserves chosen route when opening a repo through a symlink", async () => { + const p = await loadProject() + await using tmp = await tmpdir({ git: true }) + + const alias = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-alias") + await fs.symlink(tmp.path, alias, process.platform === "win32" ? "junction" : "dir") + + const { project, sandbox } = await p.fromDirectory(alias) + + expect(project.worktree).toBe(trust(alias)) + expect(sandbox).toBe(trust(alias)) + expect(project.worktree).not.toBe(trust(tmp.path)) + }) + test("keeps git vcs when rev-list exits non-zero with empty output", async () => { const p = await loadProject() await using tmp = await tmpdir() @@ -109,7 +127,7 @@ describe("Project.fromDirectory", () => { const { project } = await p.fromDirectory(tmp.path) expect(project.vcs).toBe("git") expect(project.id).toBe(ProjectID.global) - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) }) }) @@ -120,8 +138,8 @@ describe("Project.fromDirectory", () => { await withMode("top-fail", async () => { const { project, sandbox } = await p.fromDirectory(tmp.path) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) + expect(sandbox).toBe(trust(tmp.path)) }) }) @@ -132,8 +150,8 @@ describe("Project.fromDirectory", () => { await withMode("common-dir-fail", async () => { const { project, sandbox } = await p.fromDirectory(tmp.path) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) + expect(sandbox).toBe(trust(tmp.path)) }) }) }) @@ -145,9 +163,9 @@ describe("Project.fromDirectory with worktrees", () => { const { project, sandbox } = await p.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(trust(tmp.path)) + expect(sandbox).toBe(trust(tmp.path)) + expect(project.sandboxes).not.toContain(trust(tmp.path)) }) test("should set worktree to root when called from a worktree", async () => { @@ -160,10 +178,10 @@ describe("Project.fromDirectory with worktrees", () => { const { project, sandbox } = await p.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(trust(tmp.path)) + expect(sandbox).toBe(trust(worktreePath)) + expect(project.sandboxes).toContain(trust(worktreePath)) + expect(project.sandboxes).not.toContain(trust(tmp.path)) } finally { await $`git worktree remove ${worktreePath}` .cwd(tmp.path) @@ -231,10 +249,10 @@ describe("Project.fromDirectory with worktrees", () => { await p.fromDirectory(worktree1) const { project } = await p.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(trust(tmp.path)) + expect(project.sandboxes).toContain(trust(worktree1)) + expect(project.sandboxes).toContain(trust(worktree2)) + expect(project.sandboxes).not.toContain(trust(tmp.path)) } finally { await $`git worktree remove ${worktree1}` .cwd(tmp.path) @@ -282,6 +300,33 @@ describe("Project.discover", () => { }) }) +describe("Project sandboxes", () => { + test("dedupes equivalent sandbox path spellings", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const raw = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + + await Project.addSandbox(project.id, tmp.path) + const updated = await Project.addSandbox(project.id, raw) + + expect(updated.sandboxes).toEqual([trust(tmp.path)]) + }) + + test("removes a sandbox across equivalent path spellings", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + await Project.addSandbox(project.id, tmp.path) + const raw = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + const updated = await Project.removeSandbox(project.id, raw) + + expect(updated.sandboxes).toEqual([]) + }) +}) + describe("Project.update", () => { test("should update name", async () => { await using tmp = await tmpdir({ git: true }) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 675a89011f96..af0df068860b 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,12 +1,20 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Instance } from "../../src/project/instance" +import { StoredPath } from "../../src/path/path" import { Session } from "../../src/session" import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) +const bash = (input: string) => { + const drive = input[0].toLowerCase() + const rest = input.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` +} + describe("Session.list", () => { test("filters by directory", async () => { await Instance.provide({ @@ -87,4 +95,38 @@ describe("Session.list", () => { }, }) }) + + test("matches the stored directory when queried with a Windows bash path", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "bash-path-session" }) + const sessions = [...Session.list({ directory: bash(tmp.path) })] + + expect(sessions.map((item) => item.id)).toContain(session.id) + expect(sessions.find((item) => item.id === session.id)?.directory).toBe(StoredPath.unsafe(tmp.path)) + }, + }) + }) + + test("matches the stored directory when queried with a lowercase drive path", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + + const raw = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "lower-drive-session" }) + const sessions = [...Session.list({ directory: raw })] + + expect(sessions.map((item) => item.id)).toContain(session.id) + expect(sessions.find((item) => item.id === session.id)?.directory).toBe(StoredPath.unsafe(tmp.path)) + }, + }) + }) })