From b94e110a4c3d78ee00a81d16fc70faab56eb6e8a Mon Sep 17 00:00:00 2001 From: Michael Dwan Date: Thu, 12 Mar 2026 22:18:59 -0600 Subject: [PATCH] fix(opencode): sessions lost after git init in existing project (#16814) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/project/project.ts | 35 ++--- packages/opencode/test/fixture/fixture.ts | 2 + .../test/project/migrate-global.test.ts | 140 ++++++++++++++++++ 3 files changed, 160 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/test/project/migrate-global.test.ts 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(options?: TmpDirOptions) { if (options?.git) { await $`git init`.cwd(dirpath).quiet() await $`git config core.fsmonitor false`.cwd(dirpath).quiet() + await $`git config user.email "test@opencode.test"`.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 "test@opencode.test"`.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) + }) +})