diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 1b7350f74478..c696c5a7176a 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -1,6 +1,6 @@ import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Session } from "@/session/session" +import { Session, sessionPath } from "@/session/session" import { MessageV2 } from "../../session/message-v2" import { CliError, effectCmd } from "../effect-cmd" import { Database } from "@opencode-ai/core/database/database" @@ -8,7 +8,6 @@ import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session import { InstanceRef } from "@/effect/instance-ref" import { ShareNext } from "@/share/share-next" import { EOL } from "os" -import path from "path" import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect, Schema } from "effect" import type { InstanceContext } from "@/project/instance-context" @@ -180,7 +179,7 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: Ins ...exportData.info, projectID: ctx.project.id, directory: ctx.directory, - path: path.relative(path.resolve(ctx.worktree), ctx.directory).replaceAll("\\", "/"), + path: sessionPath(ctx.worktree, ctx.directory), }) as Session.Info const row = Session.toRow(info) yield* db diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index cfe034146c1b..7cf28eafd34d 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -168,8 +168,14 @@ function getForkedTitle(title: string): string { return `${title} (fork #1)` } -function sessionPath(worktree: string, cwd: string) { - return path.relative(path.resolve(worktree), cwd).replaceAll("\\", "/") +export function sessionPath(worktree: string, cwd: string) { + // Non-git projects synthesize a worktree of "/". On Windows path.resolve("/") + // anchors to the launch drive's root, so the same directory would get a + // different relative path depending on which drive opencode was started from. + // Anchor "/" to the directory's own drive to keep paths stable and + // launch-independent (a no-op on POSIX where the root is always "/"). + const root = worktree === "/" ? path.parse(cwd).root : path.resolve(worktree) + return path.relative(root, cwd).replaceAll("\\", "/") } const Summary = Schema.Struct({ diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 354a578b23c9..e5c04c1eeb9a 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -184,6 +184,46 @@ describe("session.list", () => { { git: true }, ) + it.instance( + "lists non-git sessions from other directories with scope project", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const nested = path.join(test.directory, "nested") + yield* Effect.promise(() => mkdir(nested, { recursive: true })) + + const rootSession = yield* withSession({ title: "non-git-root" }) + const nestedSession = yield* withSession({ title: "non-git-nested" }).pipe(provideInstance(nested)) + + // Non-git projects have no repository worktree, so the TUI lists them + // project-wide; sessions from other directories must be included. + const ids = (yield* SessionNs.Service.use((session) => + session.list({ scope: "project" }), + )).map((session) => session.id) + expect(ids).toContain(rootSession.id) + expect(ids).toContain(nestedSession.id) + }), + ) + + it.instance( + "stores non-git sessions relative to the directory's own root", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const created = yield* withSession({ title: "non-git-path" }) + + const info = yield* SessionNs.use.get(created.id) + const anchored = path.relative(path.parse(test.directory).root, test.directory).replaceAll("\\", "/") + expect(info.path).toBe(anchored) + + // A path query in the anchored form must find it back. + const ids = (yield* SessionNs.Service.use((session) => + session.list({ path: anchored }), + )).map((session) => session.id) + expect(ids).toContain(created.id) + }), + ) + it.instance( "falls back to directory when filtering legacy sessions without path", () => diff --git a/packages/opencode/test/session/session-path.test.ts b/packages/opencode/test/session/session-path.test.ts new file mode 100644 index 000000000000..4392d348c410 --- /dev/null +++ b/packages/opencode/test/session/session-path.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { sessionPath } from "@/session/session" + +describe("sessionPath", () => { + test("resolves sessions relative to the repository worktree", () => { + const worktree = path.join(path.parse(process.cwd()).root, "repo") + expect(sessionPath(worktree, path.join(worktree, "packages", "opencode"))).toBe("packages/opencode") + expect(sessionPath(worktree, worktree)).toBe("") + }) + + test("anchors the synthetic / worktree to the directory's own root", () => { + const cases = + process.platform === "win32" + ? ["C:\\Users\\wxj20", "D:\\Dev\\code"] + : ["/home/wxj20", "/srv/dev/code"] + for (const cwd of cases) { + const root = path.parse(cwd).root + expect(sessionPath("/", cwd)).toBe(path.relative(root, cwd).replaceAll("\\", "/")) + } + }) + + test("never produces a cross-drive absolute path for the synthetic worktree", () => { + if (process.platform !== "win32") return + // With a CWD on C:, the un-anchored resolve("/") used to return "C:/Users/..." + // for a directory on another drive. The anchored form must stay relative. + expect(sessionPath("/", "C:\\Users\\wxj20")).not.toMatch(/^[A-Za-z]:\//) + expect(sessionPath("/", "D:\\Dev\\code")).not.toMatch(/^[A-Za-z]:\//) + }) +}) \ No newline at end of file