summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorMichael Dwan <[email protected]>2026-03-12 22:18:59 -0600
committerGitHub <[email protected]>2026-03-12 23:18:59 -0500
commitb94e110a4c3d78ee00a81d16fc70faab56eb6e8a (patch)
tree55fe713742f78c95489d36dbff3446b3c966bd2b
parentf0bba10b127a09829b234edcb5fac5fb0a84f5c0 (diff)
downloadopencode-b94e110a4c3d78ee00a81d16fc70faab56eb6e8a.tar.gz
opencode-b94e110a4c3d78ee00a81d16fc70faab56eb6e8a.zip
fix(opencode): sessions lost after git init in existing project (#16814)
Co-authored-by: Aiden Cline <[email protected]>
-rw-r--r--packages/opencode/src/project/project.ts35
-rw-r--r--packages/opencode/test/fixture/fixture.ts2
-rw-r--r--packages/opencode/test/project/migrate-global.test.ts140
3 files changed, 160 insertions, 17 deletions
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index 196dc8da6..1e14e94d7 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -218,23 +218,18 @@ export namespace Project {
})
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
- const existing = await iife(async () => {
- if (row) return fromRow(row)
- const fresh: Info = {
- id: data.id,
- worktree: data.worktree,
- vcs: data.vcs as Info["vcs"],
- sandboxes: [],
- time: {
- created: Date.now(),
- updated: Date.now(),
- },
- }
- if (data.id !== ProjectID.global) {
- await migrateFromGlobal(data.id, data.worktree)
- }
- return fresh
- })
+ const existing = row
+ ? fromRow(row)
+ : {
+ id: data.id,
+ worktree: data.worktree,
+ vcs: data.vcs as Info["vcs"],
+ sandboxes: [] as string[],
+ time: {
+ created: Date.now(),
+ updated: Date.now(),
+ },
+ }
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
@@ -277,6 +272,12 @@ export namespace Project {
Database.use((db) =>
db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(),
)
+ // Runs after upsert so the target project row exists (FK constraint).
+ // Runs on every startup because sessions created before git init
+ // accumulate under "global" and need migrating whenever they appear.
+ if (data.id !== ProjectID.global) {
+ await migrateFromGlobal(data.id, data.worktree)
+ }
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts
index 63f93bcaf..f2f864e8b 100644
--- a/packages/opencode/test/fixture/fixture.ts
+++ b/packages/opencode/test/fixture/fixture.ts
@@ -42,6 +42,8 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
if (options?.git) {
await $`git init`.cwd(dirpath).quiet()
await $`git config core.fsmonitor false`.cwd(dirpath).quiet()
+ await $`git config user.email "[email protected]"`.cwd(dirpath).quiet()
+ await $`git config user.name "Test"`.cwd(dirpath).quiet()
await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
}
if (options?.config) {
diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts
new file mode 100644
index 000000000..77e0a1d77
--- /dev/null
+++ b/packages/opencode/test/project/migrate-global.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, test } from "bun:test"
+import { Project } from "../../src/project/project"
+import { Database, eq } from "../../src/storage/db"
+import { SessionTable } from "../../src/session/session.sql"
+import { ProjectTable } from "../../src/project/project.sql"
+import { ProjectID } from "../../src/project/schema"
+import { SessionID } from "../../src/session/schema"
+import { Log } from "../../src/util/log"
+import { $ } from "bun"
+import { tmpdir } from "../fixture/fixture"
+
+Log.init({ print: false })
+
+function uid() {
+ return SessionID.make(crypto.randomUUID())
+}
+
+function seed(opts: { id: SessionID; dir: string; project: ProjectID }) {
+ const now = Date.now()
+ Database.use((db) =>
+ db
+ .insert(SessionTable)
+ .values({
+ id: opts.id,
+ project_id: opts.project,
+ slug: opts.id,
+ directory: opts.dir,
+ title: "test",
+ version: "0.0.0-test",
+ time_created: now,
+ time_updated: now,
+ })
+ .run(),
+ )
+}
+
+function ensureGlobal() {
+ Database.use((db) =>
+ db
+ .insert(ProjectTable)
+ .values({
+ id: ProjectID.global,
+ worktree: "/",
+ time_created: Date.now(),
+ time_updated: Date.now(),
+ sandboxes: [],
+ })
+ .onConflictDoNothing()
+ .run(),
+ )
+}
+
+describe("migrateFromGlobal", () => {
+ test("migrates global sessions on first project creation", async () => {
+ // 1. Start with git init but no commits — creates "global" project row
+ await using tmp = await tmpdir()
+ await $`git init`.cwd(tmp.path).quiet()
+ await $`git config user.name "Test"`.cwd(tmp.path).quiet()
+ await $`git config user.email "[email protected]"`.cwd(tmp.path).quiet()
+ const { project: pre } = await Project.fromDirectory(tmp.path)
+ expect(pre.id).toBe(ProjectID.global)
+
+ // 2. Seed a session under "global" with matching directory
+ const id = uid()
+ seed({ id, dir: tmp.path, project: ProjectID.global })
+
+ // 3. Make a commit so the project gets a real ID
+ await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet()
+
+ const { project: real } = await Project.fromDirectory(tmp.path)
+ expect(real.id).not.toBe(ProjectID.global)
+
+ // 4. The session should have been migrated to the real project ID
+ const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
+ expect(row).toBeDefined()
+ expect(row!.project_id).toBe(real.id)
+ })
+
+ test("migrates global sessions even when project row already exists", async () => {
+ // 1. Create a repo with a commit — real project ID created immediately
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+ expect(project.id).not.toBe(ProjectID.global)
+
+ // 2. Ensure "global" project row exists (as it would from a prior no-git session)
+ ensureGlobal()
+
+ // 3. Seed a session under "global" with matching directory.
+ // This simulates a session created before git init that wasn't
+ // present when the real project row was first created.
+ const id = uid()
+ seed({ id, dir: tmp.path, project: ProjectID.global })
+
+ // 4. Call fromDirectory again — project row already exists,
+ // so the current code skips migration entirely. This is the bug.
+ await Project.fromDirectory(tmp.path)
+
+ const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
+ expect(row).toBeDefined()
+ expect(row!.project_id).toBe(project.id)
+ })
+
+ test("migrates sessions with empty directory", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+ expect(project.id).not.toBe(ProjectID.global)
+
+ ensureGlobal()
+
+ // Legacy sessions may lack a directory value
+ const id = uid()
+ seed({ id, dir: "", project: ProjectID.global })
+
+ await Project.fromDirectory(tmp.path)
+
+ const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
+ expect(row).toBeDefined()
+ // Empty directory means "no known origin" — should be claimed
+ expect(row!.project_id).toBe(project.id)
+ })
+
+ test("does not steal sessions from unrelated directories", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+ expect(project.id).not.toBe(ProjectID.global)
+
+ ensureGlobal()
+
+ // Seed a session under "global" but for a DIFFERENT directory
+ const id = uid()
+ seed({ id, dir: "/some/other/dir", project: ProjectID.global })
+
+ await Project.fromDirectory(tmp.path)
+
+ const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
+ expect(row).toBeDefined()
+ // Should remain under "global" — not stolen
+ expect(row!.project_id).toBe(ProjectID.global)
+ })
+})