Skip to content
Open
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
5 changes: 2 additions & 3 deletions packages/opencode/src/cli/cmd/import.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
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"
import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql"
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"
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/test/server/session-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
() =>
Expand Down
30 changes: 30 additions & 0 deletions packages/opencode/test/session/session-path.test.ts
Original file line number Diff line number Diff line change
@@ -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]:\//)
})
})
Loading