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
62 changes: 61 additions & 1 deletion packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 68 additions & 0 deletions packages/opencode/test/project/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading