diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 49e054d7cc9a..64e9d32b9658 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -17,6 +17,16 @@ export type ID = ProjectSchema.ID export const Vcs = ProjectSchema.Vcs export type Vcs = ProjectSchema.Vcs +const STABLE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * Whether an id is a stable minted repo identity (uuid) rather than a legacy + * derived id (remote hash, root commit, cached value) or the global sentinel. + */ +export function isStableID(id: string) { + return STABLE_ID_PATTERN.test(id) +} + export class Info extends Schema.Class("Project.Info")({ id: ID, }) {} @@ -38,15 +48,17 @@ export interface Interface { readonly directories: (input: DirectoriesInput) => Effect.Effect readonly resolve: (input: AbsolutePath) => Effect.Effect /** - * Temporary bridge method for writing the resolved project ID to the repo-local cache. + * Temporary bridge method for writing a project's minted identity to the + * repo-local cache (`/opencode`) as versioned JSON. * * This exists while the old opencode project service and this core project * service work together: core resolves the ID, while the old service still owns - * database migration and persistence. The old service should call this after it - * finishes migrating from `resolve().previous` to `resolve().id`; once project - * persistence moves into core, this separate bridge method can go away. + * minting, database migration, and persistence. Returns whether the write + * landed so callers only adopt a minted identity that is durably stored; + * once project persistence moves into core, this separate bridge method can + * go away. */ - readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect + readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect } export class Service extends Context.Service()("@opencode/ProjectV2") {} @@ -62,12 +74,28 @@ const layer = Layer.effect( return yield* projectDirectories.list(input.projectID) }) + const parse = (content: string): { repoID?: ID; legacy?: ID } => { + try { + const parsed: unknown = JSON.parse(content) + if (parsed && typeof parsed === "object") { + const repoID = "repoID" in parsed ? parsed.repoID : undefined + // Forward-compatible read: honor the repoID of any structured + // version, ignore structured content we do not understand. + if (typeof repoID === "string" && isStableID(repoID)) return { repoID: ID.make(repoID) } + return {} + } + } catch {} + // Bare string contents predate the versioned format. + return { legacy: ID.make(content) } + } + const cached = Effect.fnUntraced(function* (dir: string) { - return yield* fs.readFileString(path.join(dir, "opencode")).pipe( + const content = yield* fs.readFileString(path.join(dir, "opencode")).pipe( Effect.map((value) => value.trim()), - Effect.map((value) => (value ? ID.make(value) : undefined)), Effect.catch(() => Effect.succeed(undefined)), ) + if (!content) return { repoID: undefined, legacy: undefined } + return parse(content) }) const remote = Effect.fnUntraced(function* (repo: Git.Repository) { @@ -111,18 +139,31 @@ const layer = Layer.effect( const repo = yield* git.repo.discover(input) if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined } - const previous = yield* cached(repo.commonDirectory) + const vcs = { type: "git" as const, store: repo.commonDirectory } + const stored = yield* cached(repo.commonDirectory) + // A minted identity persisted in the versioned cache file is + // authoritative: it is what keeps independent clones of the same + // remote distinct while linked worktrees (shared common dir) and + // renamed checkouts keep resolving to the same project. + if (stored.repoID) return { id: stored.repoID, directory: repo.worktree, vcs } + + const previous = stored.legacy const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) return { previous, id: id ?? ID.global, directory: repo.worktree, - vcs: { type: "git" as const, store: repo.commonDirectory }, + vcs, } }) const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) { - yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore) + return yield* fs + .writeFileString(path.join(input.store, "opencode"), JSON.stringify({ version: 1, repoID: input.id }) + "\n") + .pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ) }) return Service.of({ directories, resolve, commit }) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index e9ff2fc149e7..464165a2912a 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -79,7 +79,7 @@ describe("node build", () => { return Project.Service.of({ directories: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), - commit: () => Effect.void, + commit: () => Effect.succeed(true), }) }), ) diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index 77e9e96745bb..9b8ed4327785 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -19,7 +19,7 @@ const projectLayer = Layer.succeed( directory: AbsolutePath.make("/repo"), vcs: { type: "git", store: AbsolutePath.make("/repo/.git") }, }), - commit: () => Effect.void, + commit: () => Effect.succeed(true), }), ) const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]])) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index fa709a8b2bf5..b55a8d6f1bff 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -219,3 +219,165 @@ describe("ProjectV2.resolve", () => { }), ) }) + +describe("ProjectV2 versioned identity file", () => { + const uuid = "b3f1c2a0-4d5e-4f6a-8b7c-0123456789ab" + + it.live("honors v1 repoID from .git/opencode over the remote", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" })) + yield* Effect.promise(() => + Bun.write(path.join(tmp.path, ".git", "opencode"), JSON.stringify({ version: 1, repoID: uuid })), + ) + const project = yield* ProjectV2.Service + + const result = yield* project.resolve(abs(tmp.path)) + + expect(result.id).toBe(ProjectV2.ID.make(uuid)) + expect(result.previous).toBeUndefined() + expect(result.vcs?.type).toBe("git") + }), + ) + + it.live("two clones of the same remote resolve to their own v1 identities", () => + Effect.gen(function* () { + const a = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const b = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const uuidB = "0f9e8d7c-6b5a-4f3e-9d1c-ba9876543210" + yield* Effect.promise(() => initRepo(a.path, { commit: true, remote: "git@github.com:owner/repo.git" })) + yield* Effect.promise(() => initRepo(b.path, { commit: true, remote: "git@github.com:owner/repo.git" })) + yield* Effect.promise(() => + Bun.write(path.join(a.path, ".git", "opencode"), JSON.stringify({ version: 1, repoID: uuid })), + ) + yield* Effect.promise(() => + Bun.write(path.join(b.path, ".git", "opencode"), JSON.stringify({ version: 1, repoID: uuidB })), + ) + const project = yield* ProjectV2.Service + + const first = yield* project.resolve(abs(a.path)) + const second = yield* project.resolve(abs(b.path)) + + expect(first.id).toBe(ProjectV2.ID.make(uuid)) + expect(second.id).toBe(ProjectV2.ID.make(uuidB)) + expect(first.id).not.toBe(second.id) + }), + ) + + it.live("linked worktree resolves to the clone's v1 repoID", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const worktree = `${tmp.path}-worktree` + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore), + ) + yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" })) + yield* Effect.promise(() => + Bun.write(path.join(tmp.path, ".git", "opencode"), JSON.stringify({ version: 1, repoID: uuid })), + ) + yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet()) + const project = yield* ProjectV2.Service + + const result = yield* project.resolve(abs(worktree)) + + expect(result.id).toBe(ProjectV2.ID.make(uuid)) + expect(result.previous).toBeUndefined() + expect(result.directory).toBe(yield* real(worktree)) + }), + ) + + it.live("identity survives a folder rename", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const renamed = `${tmp.path}-renamed` + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${renamed}`.quiet().nothrow()).pipe(Effect.ignore), + ) + yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" })) + yield* Effect.promise(() => + Bun.write(path.join(tmp.path, ".git", "opencode"), JSON.stringify({ version: 1, repoID: uuid })), + ) + yield* Effect.promise(() => fs.rename(tmp.path, renamed)) + const project = yield* ProjectV2.Service + + const result = yield* project.resolve(abs(renamed)) + + expect(result.id).toBe(ProjectV2.ID.make(uuid)) + expect(result.previous).toBeUndefined() + expect(result.directory).toBe(yield* real(renamed)) + }), + ) + + it.live("ignores structured content without a valid repoID and falls back to legacy chain", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" })) + yield* Effect.promise(() => + Bun.write(path.join(tmp.path, ".git", "opencode"), JSON.stringify({ version: 99, other: "thing" })), + ) + const project = yield* ProjectV2.Service + + const result = yield* project.resolve(abs(tmp.path)) + + expect(result.id).toBe(remoteID("github.com/owner/repo")) + expect(result.previous).toBeUndefined() + }), + ) + + it.live("treats a non-uuid bare string as the legacy previous id", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" })) + yield* Effect.promise(() => + Bun.write(path.join(tmp.path, ".git", "opencode"), Hash.fast("git-remote:github.com/owner/repo")), + ) + const project = yield* ProjectV2.Service + + const result = yield* project.resolve(abs(tmp.path)) + + expect(result.id).toBe(remoteID("github.com/owner/repo")) + expect(result.previous).toBe(remoteID("github.com/owner/repo")) + }), + ) + + it.live("commit writes the versioned identity file and round-trips through resolve", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" })) + const project = yield* ProjectV2.Service + + yield* project.commit({ store: abs(path.join(tmp.path, ".git")), id: ProjectV2.ID.make(uuid) }) + + const content = yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).text()) + expect(JSON.parse(content)).toEqual({ version: 1, repoID: uuid }) + + const result = yield* project.resolve(abs(tmp.path)) + expect(result.id).toBe(ProjectV2.ID.make(uuid)) + expect(result.previous).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 4688ede82d18..29c9a3e67e72 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -32,7 +32,7 @@ const projects = Layer.succeed( ProjectV2.Service.of({ resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), - commit: () => Effect.void, + commit: () => Effect.succeed(true), }), ) const it = testEffect( diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts index c67f776d2930..d1b089117177 100644 --- a/packages/core/test/session-history.test.ts +++ b/packages/core/test/session-history.test.ts @@ -20,7 +20,7 @@ const projects = Layer.succeed( ProjectV2.Service.of({ resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), - commit: () => Effect.void, + commit: () => Effect.succeed(true), }), ) const it = testEffect( diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 9870377b2169..faa4399b5041 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,5 +1,7 @@ +import path from "path" 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 { Global } from "@opencode-ai/core/global" 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" @@ -146,6 +148,7 @@ const layer = Layer.effect( const migrateProjectId = Effect.fn("Project.migrateProjectId")(function* ( oldID: ProjectV2.ID | undefined, newID: ProjectV2.ID, + worktree: string, ) { if (!oldID) return if (oldID === ProjectV2.ID.global) return @@ -163,6 +166,14 @@ const layer = Layer.effect( .values({ ...oldProject, id: newID, + // A legacy id may have been shared by several distinct + // clones, so the old row's worktree may belong to a + // different checkout; the minted identity belongs to the + // directory being opened. Sandboxes are cleared for the + // same reason and re-validated as directories are opened + // (same rationale as the directory clearing below). + worktree: AbsolutePath.make(worktree), + sandboxes: [], time_updated: Date.now(), }) .run() @@ -190,6 +201,18 @@ const layer = Layer.effect( { behavior: "immediate" }, ) .pipe(Effect.orDie) + + // Snapshot and managed-worktree storage are keyed by project id on + // disk; carry them over so history survives re-identification. Legacy + // ids can be arbitrary cached file content, so only ids that are plain + // hash/uuid shapes may be used as a path segment. + if (/^[0-9a-f-]+$/i.test(oldID)) { + for (const store of ["snapshot", "worktree"]) { + yield* fs + .rename(path.join(Global.Path.data, store, oldID), path.join(Global.Path.data, store, newID)) + .pipe(Effect.ignore) + } + } }) const saveProjectDirectory = Effect.fn("Project.saveProjectDirectory")(function* (input: { @@ -217,8 +240,38 @@ const layer = Layer.effect( const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory // Phase 2: upsert - const projectID = ProjectV2.ID.make(data.id) - yield* migrateProjectId(data.previous ? ProjectV2.ID.make(data.previous) : undefined, projectID) + const resolvedID = ProjectV2.ID.make(data.id) + let projectID = resolvedID + if (data.vcs?.type === "git" && resolvedID !== ProjectV2.ID.global && !ProjectV2.isStableID(resolvedID)) { + // Legacy derived ids (remote hash, root commit) collapse independent + // clones of the same repo into one project. Mint a per-clone identity + // and persist it to the repo-local cache, which linked worktrees share + // through the git common dir and which survives folder renames. Only + // adopt the minted id once it is durably written so a read-only .git + // does not fragment identity on every boot. + const minted = ProjectV2.ID.make(crypto.randomUUID()) + const persisted = yield* projectV2.commit({ store: data.vcs.store, id: minted }) + if (persisted) { + // Re-resolve so concurrent mints for the same repo (e.g. a clone + // and its linked worktree booting together) converge on whichever + // identity landed in the cache file. + const settled = yield* projectV2.resolve(AbsolutePath.make(directory)) + projectID = ProjectV2.isStableID(settled.id) ? ProjectV2.ID.make(settled.id) : minted + yield* migrateProjectId(resolvedID, projectID, data.directory) + } + } + if (projectID === resolvedID) { + // Not minted (already stable, global, or the identity write failed): + // preserve the legacy cached-id migration. + yield* migrateProjectId( + data.previous ? ProjectV2.ID.make(data.previous) : undefined, + projectID, + data.directory, + ) + } else if (data.previous && data.previous !== resolvedID) { + // Stale cached ids from older schemes follow the mint as well. + yield* migrateProjectId(ProjectV2.ID.make(data.previous), projectID, data.directory) + } const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie) const existing = row ? fromRow(row) @@ -238,6 +291,16 @@ const layer = Layer.effect( vcs: data.vcs?.type ?? fakeVcs, time: { ...existing.time, updated: Date.now() }, } + if (projectID !== ProjectV2.ID.global && result.worktree !== data.directory) { + // A renamed or moved clone keeps its identity through the repo-local + // cache file but leaves the stored worktree pointing at a dead path; + // adopt the directory it now resolves from. + const worktreeExists = yield* fs.exists(result.worktree).pipe(Effect.orDie) + if (!worktreeExists) { + result.worktree = data.directory + result.sandboxes = result.sandboxes.filter((sandbox) => sandbox !== result.worktree) + } + } if ( projectID !== ProjectV2.ID.global && data.directory !== result.worktree && @@ -289,10 +352,15 @@ const layer = Layer.effect( .pipe(Effect.orDie) if (projectID !== ProjectV2.ID.global) { + // Adopt sessions recorded against this exact directory, wherever they + // are currently parented: global sessions created before the project + // existed, and sessions carried off by a sibling clone that shared a + // legacy id (whole-project migration moves them in bulk; each clone + // reclaims its own directory's sessions when it is next opened). yield* db .update(SessionTable) .set({ project_id: projectID }) - .where(and(eq(SessionTable.project_id, ProjectV2.ID.global), eq(SessionTable.directory, data.directory))) + .where(and(ne(SessionTable.project_id, projectID), eq(SessionTable.directory, data.directory))) .run() .pipe(Effect.orDie) } @@ -303,9 +371,6 @@ const layer = Layer.effect( }) yield* emitUpdated(result) - if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") { - yield* projectV2.commit({ store: data.vcs.store, id: data.id }) - } return { project: result, sandbox: data.vcs ? data.directory : worktree } }) diff --git a/packages/opencode/test/project/identity.test.ts b/packages/opencode/test/project/identity.test.ts new file mode 100644 index 000000000000..a8dcc7139137 --- /dev/null +++ b/packages/opencode/test/project/identity.test.ts @@ -0,0 +1,348 @@ +import { describe, expect } from "bun:test" +import { Project } from "@/project/project" +import { $ } from "bun" +import fs from "fs/promises" +import path from "path" +import { tmpdirScoped } from "../fixture/fixture" +import { Database } from "@opencode-ai/core/database/database" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" +import { eq } from "drizzle-orm" +import { Hash } from "@opencode-ai/core/util/hash" +import { SessionID } from "@/session/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { Effect } from "effect" +import { ProjectV2 } from "@opencode-ai/core/project" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { testEffect } from "../lib/effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AbsolutePath } from "@opencode-ai/core/schema" + +const projectTestNode = LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node]) +const it = testEffect(AppNodeBuilder.build(projectTestNode)) + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ +const REMOTE = "git@github.com:identity-test/repo.git" +const legacyRemoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/identity-test/repo")) + +function addRemote(dir: string) { + return Effect.promise(() => $`git remote add origin ${REMOTE}`.cwd(dir).quiet()) +} + +function writeLegacyFile(dir: string, id: string) { + return Effect.promise(() => Bun.write(path.join(dir, ".git", "opencode"), id)) +} + +function readIdentityFile(dir: string) { + return Effect.promise(() => Bun.file(path.join(dir, ".git", "opencode")).text()).pipe( + Effect.map((content) => JSON.parse(content) as { version: number; repoID: string }), + ) +} + +function seedProject(opts: { id: ProjectV2.ID; worktree: string; sandboxes?: string[] }) { + const now = Date.now() + return Database.Service.use(({ db }) => + db + .insert(ProjectTable) + .values({ + id: opts.id, + worktree: AbsolutePath.make(opts.worktree), + vcs: "git", + sandboxes: (opts.sandboxes ?? []).map((sandbox) => AbsolutePath.make(sandbox)), + time_created: now, + time_updated: now, + }) + .run() + .pipe(Effect.orDie), + ) +} + +function seedSession(opts: { id: SessionID; dir: string; project: ProjectV2.ID }) { + const now = Date.now() + return Database.Service.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() + .pipe(Effect.orDie), + ) +} + +function seedWorkspace(opts: { id: WorkspaceV2.ID; project: ProjectV2.ID }) { + return Database.Service.use(({ db }) => + db + .insert(WorkspaceTable) + .values({ id: opts.id, type: "local", name: "test", project_id: opts.project }) + .run() + .pipe(Effect.orDie), + ) +} + +function sessionProject(id: SessionID) { + return Database.Service.use(({ db }) => + db + .select() + .from(SessionTable) + .where(eq(SessionTable.id, id)) + .get() + .pipe( + Effect.orDie, + Effect.map((row) => row?.project_id), + ), + ) +} + +function projectRow(id: ProjectV2.ID) { + return Database.Service.use(({ db }) => + db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie), + ) +} + +function projectCount() { + return Database.Service.use(({ db }) => + db + .select() + .from(ProjectTable) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => rows.length), + ), + ) +} + +describe("Project identity minting", () => { + it.live("mints a stable uuid identity for a fresh repo", () => + Effect.gen(function* () { + const project = yield* Project.Service + const tmp = yield* tmpdirScoped({ git: true }) + yield* addRemote(tmp) + + const first = yield* project.fromDirectory(tmp) + + expect(first.project.id).toMatch(UUID_RE) + expect(first.project.id).not.toBe(legacyRemoteID) + expect(first.project.worktree).toBe(tmp) + + const file = yield* readIdentityFile(tmp) + expect(file).toEqual({ version: 1, repoID: first.project.id }) + + const second = yield* project.fromDirectory(tmp) + expect(second.project.id).toBe(first.project.id) + expect(yield* projectCount()).toBe(1) + }), + ) + + it.live("two clones of the same repo become distinct projects", () => + Effect.gen(function* () { + const project = yield* Project.Service + // Old scheme keyed identity on the normalized remote hash, so two + // independent checkouts with the same origin model two clones exactly. + const a = yield* tmpdirScoped({ git: true }) + const b = yield* tmpdirScoped({ git: true }) + yield* addRemote(a) + yield* addRemote(b) + + const first = yield* project.fromDirectory(a) + const second = yield* project.fromDirectory(b) + + expect(first.project.id).toMatch(UUID_RE) + expect(second.project.id).toMatch(UUID_RE) + expect(second.project.id).not.toBe(first.project.id) + expect(first.project.worktree).toBe(a) + expect(second.project.worktree).toBe(b) + + const firstRow = yield* projectRow(first.project.id) + expect(firstRow?.sandboxes).not.toContain(b) + expect(yield* projectCount()).toBe(2) + }), + ) + + it.live("linked worktree keeps mapping to its clone's project", () => + Effect.gen(function* () { + const project = yield* Project.Service + const tmp = yield* tmpdirScoped({ git: true }) + yield* addRemote(tmp) + const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt") + yield* Effect.addFinalizer(() => + Effect.promise(() => $`git worktree remove --force ${worktreePath}`.cwd(tmp).quiet().catch(() => {})), + ) + yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet()) + + const clone = yield* project.fromDirectory(tmp) + const linked = yield* project.fromDirectory(worktreePath) + + expect(linked.project.id).toBe(clone.project.id) + expect(linked.project.worktree).toBe(tmp) + expect(linked.project.sandboxes).toContain(linked.sandbox) + expect(yield* projectCount()).toBe(1) + }), + ) + + it.live("keeps legacy identity when the identity file cannot be written", () => + Effect.gen(function* () { + if (process.platform === "win32") return + const project = yield* Project.Service + const tmp = yield* tmpdirScoped({ git: true }) + yield* addRemote(tmp) + const gitDir = path.join(tmp, ".git") + yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(gitDir, 0o755))) + yield* Effect.promise(() => fs.chmod(gitDir, 0o555)) + + const first = yield* project.fromDirectory(tmp) + const second = yield* project.fromDirectory(tmp) + + // Without a durable identity file, minting would fragment identity on + // every boot; the legacy derived id must remain in effect instead. + expect(first.project.id).toBe(legacyRemoteID) + expect(second.project.id).toBe(legacyRemoteID) + expect(yield* Effect.promise(() => Bun.file(path.join(gitDir, "opencode")).exists())).toBe(false) + }), + ) + + it.live("empty repo without a remote stays global", () => + Effect.gen(function* () { + const project = yield* Project.Service + const tmp = yield* tmpdirScoped() + yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) + + const result = yield* project.fromDirectory(tmp) + + expect(result.project.id).toBe(ProjectV2.ID.global) + expect(yield* Effect.promise(() => Bun.file(path.join(tmp, ".git", "opencode")).exists())).toBe(false) + }), + ) +}) + +describe("Project identity migration", () => { + it.live("migrates a legacy project to its minted identity once", () => + Effect.gen(function* () { + const project = yield* Project.Service + const tmp = yield* tmpdirScoped({ git: true }) + yield* addRemote(tmp) + yield* writeLegacyFile(tmp, legacyRemoteID) + yield* seedProject({ id: legacyRemoteID, worktree: tmp }) + const sessionID = crypto.randomUUID() as SessionID + yield* seedSession({ id: sessionID, dir: tmp, project: legacyRemoteID }) + const workspaceID = WorkspaceV2.ID.ascending() + yield* seedWorkspace({ id: workspaceID, project: legacyRemoteID }) + + const result = yield* project.fromDirectory(tmp) + + expect(result.project.id).toMatch(UUID_RE) + expect(yield* projectRow(legacyRemoteID)).toBeUndefined() + expect(yield* sessionProject(sessionID)).toBe(result.project.id) + const workspace = yield* Database.Service.use(({ db }) => + db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie), + ) + expect(workspace?.project_id).toBe(result.project.id) + const file = yield* readIdentityFile(tmp) + expect(file).toEqual({ version: 1, repoID: result.project.id }) + + const again = yield* project.fromDirectory(tmp) + expect(again.project.id).toBe(result.project.id) + expect(yield* projectCount()).toBe(1) + }), + ) + + it.live("splits two clones previously merged under one legacy id", () => + Effect.gen(function* () { + const project = yield* Project.Service + const a = yield* tmpdirScoped({ git: true }) + const b = yield* tmpdirScoped({ git: true }) + yield* addRemote(a) + yield* addRemote(b) + // Bug-era state: one shared row, second clone filed as a sandbox of the + // first, sessions from both directories parented to the shared id. + yield* seedProject({ id: legacyRemoteID, worktree: a, sandboxes: [b] }) + yield* writeLegacyFile(a, legacyRemoteID) + yield* writeLegacyFile(b, legacyRemoteID) + const sessionA = crypto.randomUUID() as SessionID + const sessionB = crypto.randomUUID() as SessionID + yield* seedSession({ id: sessionA, dir: a, project: legacyRemoteID }) + yield* seedSession({ id: sessionB, dir: b, project: legacyRemoteID }) + + const first = yield* project.fromDirectory(a) + + expect(first.project.id).toMatch(UUID_RE) + expect(first.project.worktree).toBe(a) + expect(first.project.sandboxes).not.toContain(b) + expect(yield* projectRow(legacyRemoteID)).toBeUndefined() + + const second = yield* project.fromDirectory(b) + + expect(second.project.id).toMatch(UUID_RE) + expect(second.project.id).not.toBe(first.project.id) + expect(second.project.worktree).toBe(b) + expect(yield* sessionProject(sessionA)).toBe(first.project.id) + expect(yield* sessionProject(sessionB)).toBe(second.project.id) + expect(yield* projectCount()).toBe(2) + }), + ) + + it.live("second clone opened first does not adopt the first clone's worktree", () => + Effect.gen(function* () { + const project = yield* Project.Service + const a = yield* tmpdirScoped({ git: true }) + const b = yield* tmpdirScoped({ git: true }) + yield* addRemote(a) + yield* addRemote(b) + yield* seedProject({ id: legacyRemoteID, worktree: a, sandboxes: [b] }) + yield* writeLegacyFile(a, legacyRemoteID) + yield* writeLegacyFile(b, legacyRemoteID) + const sessionA = crypto.randomUUID() as SessionID + const sessionB = crypto.randomUUID() as SessionID + yield* seedSession({ id: sessionA, dir: a, project: legacyRemoteID }) + yield* seedSession({ id: sessionB, dir: b, project: legacyRemoteID }) + + const second = yield* project.fromDirectory(b) + + expect(second.project.worktree).toBe(b) + expect(second.project.sandboxes).not.toContain(a) + expect(second.project.sandboxes).not.toContain(b) + + const first = yield* project.fromDirectory(a) + + expect(first.project.id).not.toBe(second.project.id) + expect(first.project.worktree).toBe(a) + expect(yield* sessionProject(sessionA)).toBe(first.project.id) + expect(yield* sessionProject(sessionB)).toBe(second.project.id) + }), + ) + + it.live("renamed clone keeps its identity, sessions, and refreshed worktree", () => + Effect.gen(function* () { + const project = yield* Project.Service + const tmp = yield* tmpdirScoped({ git: true }) + yield* addRemote(tmp) + const renamed = tmp + "-renamed" + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${renamed}`.quiet().nothrow()).pipe(Effect.ignore), + ) + + const before = yield* project.fromDirectory(tmp) + const sessionID = crypto.randomUUID() as SessionID + yield* seedSession({ id: sessionID, dir: tmp, project: before.project.id }) + yield* Effect.promise(() => fs.rename(tmp, renamed)) + + const after = yield* project.fromDirectory(renamed) + + expect(after.project.id).toBe(before.project.id) + expect(after.project.worktree).toBe(renamed) + expect(after.project.sandboxes).not.toContain(renamed) + expect(yield* sessionProject(sessionID)).toBe(before.project.id) + expect(yield* projectCount()).toBe(1) + }), + ) +}) diff --git a/packages/opencode/test/project/project-directory.test.ts b/packages/opencode/test/project/project-directory.test.ts index 112271c2c470..6e32aaeabfb2 100644 --- a/packages/opencode/test/project/project-directory.test.ts +++ b/packages/opencode/test/project/project-directory.test.ts @@ -103,7 +103,7 @@ describe("Project directory persistence", () => { }), ) - it.live("stores a separately opened clone as a secondary directory", () => + it.live("stores a separately opened clone under its own project", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) const bare = tmp + "-project-directory-bare" @@ -116,14 +116,13 @@ describe("Project directory persistence", () => { const project = yield* Project.Service const main = yield* project.fromDirectory(tmp) - yield* project.fromDirectory(clone) + const second = yield* project.fromDirectory(clone) - expect(yield* directories(main.project.id)).toEqual( - [ - { directory: AbsolutePath.make(tmp), strategy: undefined }, - { directory: AbsolutePath.make(clone), strategy: undefined }, - ].toSorted((a, b) => a.directory.localeCompare(b.directory)), - ) + expect(second.project.id).not.toBe(main.project.id) + expect(yield* directories(main.project.id)).toEqual([{ directory: AbsolutePath.make(tmp), strategy: undefined }]) + expect(yield* directories(second.project.id)).toEqual([ + { directory: AbsolutePath.make(clone), strategy: undefined }, + ]) }), ) @@ -147,11 +146,11 @@ describe("Project directory persistence", () => { }), ) - it.live("records the active directory under its newly resolved project id", () => + it.live("keeps recording the active directory under its minted id when an origin appears", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) const project = yield* Project.Service - yield* project.fromDirectory(tmp) + const first = yield* project.fromDirectory(tmp) const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/collision")) const { db } = yield* Database.Service yield* db @@ -170,33 +169,47 @@ describe("Project directory persistence", () => { $`git remote add origin git@github.com:project-directory-test/collision.git`.cwd(tmp).quiet(), ) - yield* project.fromDirectory(tmp) + const next = yield* project.fromDirectory(tmp) - expect(yield* directories(remoteID)).toEqual([{ directory: AbsolutePath.make(tmp), strategy: undefined }]) + expect(next.project.id).toBe(first.project.id) + expect(yield* directories(first.project.id)).toEqual([{ directory: AbsolutePath.make(tmp), strategy: undefined }]) + expect(yield* directories(remoteID)).toEqual([]) }), ) - it.live("clears stale directories when the project id changes", () => + it.live("clears stale directories when a legacy project id is re-minted", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) const project = yield* Project.Service - const original = yield* project.fromDirectory(tmp) + const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/migration")) const stale = AbsolutePath.make(tmp + "-stale-checkout") const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ + id: remoteID, + worktree: AbsolutePath.make(tmp), + vcs: "git", + time_created: Date.now(), + time_updated: Date.now(), + sandboxes: [], + }) + .run() + .pipe(Effect.orDie) yield* db .insert(ProjectDirectoryTable) - .values({ project_id: original.project.id, directory: stale }) + .values({ project_id: remoteID, directory: stale }) .run() .pipe(Effect.orDie) - const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/migration")) yield* Effect.promise(() => $`git remote add origin git@github.com:project-directory-test/migration.git`.cwd(tmp).quiet(), ) - yield* project.fromDirectory(tmp) + const result = yield* project.fromDirectory(tmp) - expect(yield* directories(original.project.id)).toEqual([]) - expect(yield* directories(remoteID)).toEqual([{ directory: AbsolutePath.make(tmp), strategy: undefined }]) + expect(result.project.id).not.toBe(remoteID) + expect(yield* directories(remoteID)).toEqual([]) + expect(yield* directories(result.project.id)).toEqual([{ directory: AbsolutePath.make(tmp), strategy: undefined }]) }), ) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 804b92b08ec4..2700987f0fbd 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -30,6 +30,8 @@ function remoteProjectID(remote: string) { return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) } +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + /** * Creates a mock ChildProcessSpawner layer that intercepts git subcommands * matching `failArg` and returns exit code 128, while delegating everything @@ -83,7 +85,7 @@ function projectV2FailureLayer() { directory: input, vcs: { type: "git" as const, store: input }, }), - commit: () => Effect.void, + commit: () => Effect.succeed(true), }), ) } @@ -158,7 +160,7 @@ describe("Project.fromDirectory", () => { }), ) - it.live("prefers normalized origin remote over root commit", () => + it.live("mints a per-clone id instead of exposing the remote-derived id", () => Effect.gen(function* () { const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) @@ -166,11 +168,12 @@ describe("Project.fromDirectory", () => { const result = yield* project.fromDirectory(tmp) - expect(result.project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo")) + expect(result.project.id).toMatch(UUID_RE) + expect(result.project.id).not.toBe(remoteProjectID("github.com/Test-Org/Test-Repo")) }), ) - it.live("normalizes equivalent origin URL forms to the same project ID", () => + it.live("gives separate checkouts of the same origin distinct project IDs", () => Effect.gen(function* () { const project = yield* Project.Service const ssh = yield* tmpdirScoped({ git: true }) @@ -181,19 +184,19 @@ describe("Project.fromDirectory", () => { const result = yield* project.fromDirectory(ssh) const next = yield* project.fromDirectory(https) - expect(result.project.id).toBe(remoteProjectID("github.com/owner/repo")) - expect(next.project.id).toBe(result.project.id) + expect(result.project.id).toMatch(UUID_RE) + expect(next.project.id).toMatch(UUID_RE) + expect(next.project.id).not.toBe(result.project.id) }), ) - it.live("migrates cached root project data when origin becomes available", () => + it.live("keeps identity and data when origin becomes available", () => Effect.gen(function* () { const { db } = yield* Database.Service const tmp = yield* tmpdirScoped({ git: true }) const projects = yield* Project.Service const rootResult = yield* projects.fromDirectory(tmp) const rootProject = rootResult.project - const remoteID = remoteProjectID("github.com/acme/app") const sessionID = crypto.randomUUID() as SessionID const workspaceID = WorkspaceV2.ID.ascending() @@ -220,18 +223,18 @@ describe("Project.fromDirectory", () => { const result = yield* projects.fromDirectory(tmp) - expect(result.project.id).toBe(remoteID) + expect(result.project.id).toBe(rootProject.id) expect( yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get().pipe(Effect.orDie), - ).toBeUndefined() + ).toBeDefined() expect( (yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)) ?.project_id, - ).toBe(remoteID) + ).toBe(rootProject.id) expect( (yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie)) ?.project_id, - ).toBe(remoteID) + ).toBe(rootProject.id) }), ) }) @@ -341,7 +344,7 @@ describe("Project.fromDirectory with worktrees", () => { }), ) - it.live("separate clones of the same repo should share project ID", () => + it.live("separate clones of the same repo get distinct project IDs", () => Effect.gen(function* () { const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) @@ -358,7 +361,8 @@ describe("Project.fromDirectory with worktrees", () => { const result = yield* project.fromDirectory(tmp) const next = yield* project.fromDirectory(clone) - expect(next.project.id).toBe(result.project.id) + expect(next.project.id).not.toBe(result.project.id) + expect(next.project.worktree).toBe(clone) }), )