Skip to content
Merged
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
35 changes: 18 additions & 17 deletions packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/test/fixture/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "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) {
Expand Down
140 changes: 140 additions & 0 deletions packages/opencode/test/project/migrate-global.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading