diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 5f1b64743a74..4d585f7165e8 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { and, eq, sql } from "drizzle-orm" +import { and, eq, ne, sql } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectDirectories } from "@opencode-ai/core/project/directories" @@ -245,6 +245,66 @@ export const layer = Layer.effect( const data = yield* projectV2.resolve(AbsolutePath.make(directory)) const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory + // If the resolved project ID differs from an existing project whose + // sandboxes contain this directory, prefer the existing project. This + // prevents duplicate projects when a git remote URL changes (causing a + // different project ID to be computed) and the daemon restarts. + // + // `sandboxes` is a JSON-serialized text array (see + // `database/path.ts#absoluteArrayColumn`), so we look it up via + // SQLite's `json_each` for a precise, case-sensitive match against + // `data.directory` — the canonical git worktree root returned by + // `projectV2.resolve`. Matching on the raw input `directory` would be + // redundant: only `data.directory` is ever persisted to `sandboxes` + // (see Phase 2 upsert and the `addSandbox` path), so the two values + // only ever differ when the user opened a subdirectory of the repo, + // in which case `data.directory` is the right thing to look up. + if (data.vcs) { + const absDir = AbsolutePath.make(data.directory) + const sandboxOwner = yield* db + .select() + .from(ProjectTable) + .where( + and( + ne(ProjectTable.id, data.id), + sql`EXISTS (SELECT 1 FROM json_each(${ProjectTable.sandboxes}) WHERE json_each.value = ${absDir})`, + ), + ) + .get() + .pipe(Effect.orDie) + if (sandboxOwner) { + yield* Effect.logInfo("fromDirectory sandbox match", { + directory, + resolvedID: data.id, + sandboxProjectID: sandboxOwner.id, + }) + const existing = fromRow(sandboxOwner) + const sandboxes = existing.sandboxes.includes(absDir) + ? existing.sandboxes + : [...existing.sandboxes, absDir] + yield* db + .update(ProjectTable) + .set({ sandboxes: sandboxes as AbsolutePath[], time_updated: Date.now() }) + .where(eq(ProjectTable.id, sandboxOwner.id)) + .run() + .pipe(Effect.orDie) + yield* saveProjectDirectory({ projectID: sandboxOwner.id, directory: absDir }) + const updated = { ...existing, sandboxes } + yield* emitUpdated(updated) + // Phase 2 is intentionally skipped: + // * `migrateProjectId` would rename the existing project from + // `data.previous` to `data.id` (or delete it as a duplicate), + // but the sandbox match is precisely the case where we want + // to KEEP `sandboxOwner` as the canonical owner of this + // directory and its sessions. Running migration would discard + // the real project and strand the existing sessions. + // * The regular upsert would create a second project row at + // `data.id`, which is the exact duplicate this branch exists + // to prevent. + return { project: updated, sandbox: data.directory } + } + } + // Phase 2: upsert const projectID = ProjectV2.ID.make(data.id) yield* migrateProjectId(data.previous ? ProjectV2.ID.make(data.previous) : undefined, projectID) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 05e205cd8704..882a8d6f2c4c 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -241,6 +241,74 @@ describe("Project.fromDirectory", () => { ).toBe(remoteID) }), ) + + it.live("reuses existing project via sandbox match when remote URL changes", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + const project = yield* Project.Service + const tmp = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => $`git remote add origin git@github.com:initial/owner.git`.cwd(tmp).quiet()) + + const initial = yield* project.fromDirectory(tmp) + const initialID = initial.project.id + expect(initialID).toBe(remoteProjectID("github.com/initial/owner")) + + const sessionID = crypto.randomUUID() as SessionID + const workspaceID = WorkspaceV2.ID.ascending() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: initialID, + slug: sessionID, + directory: tmp, + title: "sandbox-reuse", + version: "0.0.0-test", + time_created: Date.now(), + time_updated: Date.now(), + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(WorkspaceTable) + .values({ id: workspaceID, type: "local", name: "sandbox-reuse", project_id: initialID }) + .run() + .pipe(Effect.orDie) + + // Register the directory as a sandbox of the initial project so the + // sandbox-match path in fromDirectory can fire. In normal use this + // happens when a worktree is opened (Phase 2 adds the linked + // worktree to sandboxes); we use addSandbox here to keep the test + // independent of `git worktree` plumbing. + yield* project.addSandbox(initialID, tmp) + + // Simulate a remote URL change (e.g. after forking the repo). The + // next resolve will compute a different project ID from the new + // remote, so `data.id` will no longer match `initialID`. + yield* Effect.promise(() => + $`git remote set-url origin git@github.com:renamed/owner.git`.cwd(tmp).quiet(), + ) + + const result = yield* project.fromDirectory(tmp) + + // The sandbox-match path should reuse the initial project, not + // create a new one or migrate to a new project ID. + expect(result.project.id).toBe(initialID) + expect(result.project.sandboxes).toContain(tmp) + expect( + yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, initialID)).get().pipe(Effect.orDie), + ).toBeDefined() + expect(yield* project.list()).toHaveLength(1) + expect( + (yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)) + ?.project_id, + ).toBe(initialID) + expect( + (yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie)) + ?.project_id, + ).toBe(initialID) + }), + ) }) describe("Project.fromDirectory git failure paths", () => {