Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 3 additions & 2 deletions packages/opencode/src/control-plane/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types"
import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse"
import { Path } from "@/path/path"

export namespace Workspace {
export const Event = {
Expand Down Expand Up @@ -40,7 +41,7 @@ export namespace Workspace {
type: row.type,
branch: row.branch,
name: row.name,
directory: row.directory,
directory: row.directory ? Path.stored(row.directory) : null,
extra: row.extra,
Comment on lines 43 to 45

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

row.directory ? ... : null treats an empty-string directory as null, which changes semantics compared to the previous code and also bypasses Path.stored("")'s sentinel behavior. Prefer a nullish check (e.g., row.directory === null ? null : Path.stored(row.directory)) so "" stays "" while null stays null.

Copilot uses AI. Check for mistakes.
projectID: row.project_id,
}
Expand All @@ -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 ? Path.stored(config.directory) : null,
extra: config.extra ?? null,
Comment on lines 68 to 70

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config.directory ? ... : null will coerce an empty-string directory to null, which can diverge from existing persisted values and from Path.stored("")'s sentinel behavior. Use a nullish check (e.g., config.directory === null ? null : Path.stored(config.directory)) so only null maps to null.

Copilot uses AI. Check for mistakes.
projectID: input.projectID,
}
Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/src/path/path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Filesystem } from "@/util/filesystem"

/**
* 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`
* - `/Users/luke/repo`
*
* You should not see:
* - `RUNNER~1`
* - `/cygdrive/c/...`
* - slash-only key forms used just for comparison
*/
export type StoredPath = string & { readonly __stored: unique symbol }

export namespace Path {
export function stored(input: RawPath | string): StoredPath {
if (!input || input === "/") return input as StoredPath
return Filesystem.resolve(input) as StoredPath
}
}
15 changes: 8 additions & 7 deletions packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { Path, type StoredPath } from "@/path/path"
import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
Expand All @@ -13,7 +14,7 @@ export interface Shape {
project: Project.Info
}
const context = Context.create<Shape>("instance")
const cache = new Map<string, Promise<Shape>>()
const cache = new Map<StoredPath, Promise<Shape>>()

const disposal = {
all: undefined as Promise<void> | undefined,
Expand Down Expand Up @@ -52,18 +53,18 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
})
}

function track(directory: string, next: Promise<Shape>) {
function track(dir: StoredPath, next: Promise<Shape>) {
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<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
const directory = Filesystem.resolve(input.directory)
const directory = Path.stored(input.directory)
let existing = cache.get(directory)
if (!existing) {
Log.Default.info("creating instance", { directory })
Expand Down Expand Up @@ -117,7 +118,7 @@ export const Instance = {
return State.create(() => Instance.directory, init, dispose)
},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = Filesystem.resolve(input.directory)
const directory = Path.stored(input.directory)
Log.Default.info("reloading instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory)
Expand All @@ -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)
Comment on lines 131 to 134

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cache.delete(directory as StoredPath) relies on an unchecked cast. If Instance.directory is ever provided with a non-canonical string, the cache entry won’t be removed. Prefer making Shape.directory a StoredPath (and ensure it’s always set via Path.stored) or normalize here (e.g., cache.delete(Path.stored(directory))) to avoid type-unsoundness and deletion mismatches.

Copilot uses AI. Check for mistakes.
},
async disposeAll() {
Expand Down
20 changes: 11 additions & 9 deletions packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,21 @@ import { git } from "../util/git"
import { Glob } from "../util/glob"
import { which } from "../util/which"
import { ProjectID } from "./schema"
import { Path, type StoredPath } from "@/path/path"

export namespace Project {
const log = Log.create({ service: "project" })

function gitpath(cwd: string, name: string) {
if (!name) return cwd
function gitpath(cwd: string, name: string): StoredPath {
if (!name) return Path.stored(cwd)
// git output includes trailing newlines; keep path whitespace intact.
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
if (!name) return Path.stored(cwd)

name = Filesystem.windowsPath(name)

if (path.isAbsolute(name)) return path.normalize(name)
return path.resolve(cwd, name)
if (path.isAbsolute(name)) return Path.stored(name)
return Path.stored(path.resolve(cwd, name))
}

export const Info = z
Expand Down Expand Up @@ -74,7 +75,7 @@ export namespace Project {
: undefined
return {
id: ProjectID.make(row.id),
worktree: row.worktree,
worktree: Path.stored(row.worktree),
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
name: row.name ?? undefined,
icon,
Expand All @@ -83,7 +84,7 @@ export namespace Project {
updated: row.time_updated,
initialized: row.time_initialized ?? undefined,
},
sandboxes: row.sandboxes,
sandboxes: row.sandboxes.map(Path.stored),
commands: row.commands ?? undefined,
}
}
Expand All @@ -96,14 +97,15 @@ export namespace Project {
}

export async function fromDirectory(directory: string) {
directory = Path.stored(directory)
log.info("fromDirectory", { directory })

const data = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory })
const dotgit = await matches.next().then((x) => x.value)
await matches.return()
if (dotgit) {
let sandbox = path.dirname(dotgit)
let sandbox = Path.stored(path.dirname(dotgit))

const gitBinary = which("git")

Expand All @@ -125,7 +127,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 : Path.stored(path.dirname(common))
})
.catch(() => undefined)

Expand Down
13 changes: 7 additions & 6 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { Path } from "@/path/path"

import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
Expand Down Expand Up @@ -68,7 +69,7 @@ export namespace Session {
slug: row.slug,
projectID: row.project_id,
workspaceID: row.workspace_id ?? undefined,
directory: row.directory,
directory: Path.stored(row.directory),
parentID: row.parent_id ?? undefined,
title: row.title,
version: row.version,
Expand All @@ -92,7 +93,7 @@ export namespace Session {
workspace_id: info.workspaceID,
parent_id: info.parentID,
slug: info.slug,
directory: info.directory,
directory: Path.stored(info.directory),
title: info.title,
version: info.version,
share_url: info.share?.url,
Expand Down Expand Up @@ -307,7 +308,7 @@ export namespace Session {
slug: Slug.create(),
version: Installation.VERSION,
projectID: Instance.project.id,
directory: input.directory,
directory: Path.stored(input.directory),
workspaceID: input.workspaceID,
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
Expand Down Expand Up @@ -552,7 +553,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, Path.stored(input.directory)))
}
if (input?.roots) {
conditions.push(isNull(SessionTable.parent_id))
Expand Down Expand Up @@ -592,7 +593,7 @@ export namespace Session {
const conditions: SQL[] = []

if (input?.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
conditions.push(eq(SessionTable.directory, Path.stored(input.directory)))
}
if (input?.roots) {
conditions.push(isNull(SessionTable.parent_id))
Expand Down Expand Up @@ -638,7 +639,7 @@ export namespace Session {
projects.set(item.id, {
id: item.id,
name: item.name ?? undefined,
worktree: item.worktree,
worktree: Path.stored(item.worktree),
})
}
}
Expand Down
24 changes: 24 additions & 0 deletions packages/opencode/test/path/path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Path } from "../../src/path/path"
import { tmpdir } from "../fixture/fixture"

describe("path", () => {
test("keeps sentinel storage paths unchanged", () => {
expect(String(Path.stored(""))).toBe("")
expect(String(Path.stored("/"))).toBe("/")
})

test("resolves Windows alias roots for stored paths", async () => {
if (process.platform !== "win32") return
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, "junction")

expect(Path.stored(alias)).toBe(Path.stored(real))
})
})
15 changes: 15 additions & 0 deletions packages/opencode/test/project/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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"
Expand Down Expand Up @@ -100,6 +101,20 @@ describe("Project.fromDirectory", () => {
expect(fileExists).toBe(true)
})

test("canonicalizes Windows alias roots before persisting", async () => {
if (process.platform !== "win32") return
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, "junction")

const { project, sandbox } = await p.fromDirectory(alias)

expect(String(project.worktree)).toBe(tmp.path)
expect(String(sandbox)).toBe(tmp.path)

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test creates a junction (alias) outside of tmp.path, but tmpdir() cleanup only removes tmp.path. That leaves an orphaned *-alias entry under the OS temp directory on Windows. Add explicit cleanup for alias (or create it inside tmp.path) to avoid leaking filesystem artifacts across test runs.

Suggested change
const { project, sandbox } = await p.fromDirectory(alias)
expect(String(project.worktree)).toBe(tmp.path)
expect(String(sandbox)).toBe(tmp.path)
try {
const { project, sandbox } = await p.fromDirectory(alias)
expect(String(project.worktree)).toBe(tmp.path)
expect(String(sandbox)).toBe(tmp.path)
} finally {
// Ensure the alias junction is removed even if the test fails.
await fs.unlink(alias).catch(() => {})
}

Copilot uses AI. Check for mistakes.
})

test("keeps git vcs when rev-list exits non-zero with empty output", async () => {
const p = await loadProject()
await using tmp = await tmpdir()
Expand Down
Loading