diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index b09a77eb339a..6c1707a3056d 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -5,6 +5,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { makeEventListener } from "@solid-primitives/event-listener" import { useServerSync } from "./server-sync" import { useServerSDK } from "./server-sdk" +import { trackProjectMoves } from "./project-moves" import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { usePlatform } from "./platform" import { Project } from "@opencode-ai/sdk/v2" @@ -506,6 +507,34 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }) }) + // A moved or renamed project folder keeps its identity through the + // repo-local id, and the server adopts the new path the next time the + // project is opened from its new location. Open-project entries are + // keyed by path, so follow the move: relocate the old entry instead of + // leaving it at the dead location while the new path shows up as a + // separate project. + const seenWorktrees = new Map() + createEffect(() => { + const moves = trackProjectMoves(seenWorktrees, serverSync().data.project) + if (moves.length === 0) return + + batch(() => { + for (const move of moves) { + const open = server.projects.list() + const entry = open.find((project) => project.worktree === move.from) + if (!entry) continue + const index = open.indexOf(entry) + + server.projects.remove(move.from) + server.projects.open(move.to) + server.projects.move(move.to, index) + if (entry.expanded) server.projects.expand(move.to) + else server.projects.collapse(move.to) + if (server.projects.last() === move.from) server.projects.touch(move.to) + } + }) + }) + const enriched = createMemo(() => server.projects.list().map(enrich)) const list = createMemo(() => { const projects = enriched() @@ -624,6 +653,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }), open(directory: string) { const root = rootFor(directory) + // Always touch the server, even when an entry for this path is + // already listed: the entry may be stale (its folder moved away and + // back, or another checkout re-homed here). Touching re-identifies + // the directory server-side and emits the project.updated event + // that reconciles the sidebar. + void serverSync().project.touch(root) if (server.projects.list().find((x) => x.worktree === root)) return void serverSync().project.loadSessions(root) server.projects.open(root) diff --git a/packages/app/src/context/project-moves.test.ts b/packages/app/src/context/project-moves.test.ts new file mode 100644 index 000000000000..44cdc6a0748f --- /dev/null +++ b/packages/app/src/context/project-moves.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { trackProjectMoves } from "./project-moves" + +describe("trackProjectMoves", () => { + test("reports nothing on first sight of a project", () => { + const seen = new Map() + const moves = trackProjectMoves(seen, [{ id: "a", worktree: "/repo" }]) + expect(moves).toEqual([]) + expect(seen.get("a")).toBe("/repo") + }) + + test("reports a move when a known project changes worktree", () => { + const seen = new Map() + trackProjectMoves(seen, [{ id: "a", worktree: "/repo.old" }]) + const moves = trackProjectMoves(seen, [{ id: "a", worktree: "/repo.new" }]) + expect(moves).toEqual([{ id: "a", from: "/repo.old", to: "/repo.new" }]) + expect(seen.get("a")).toBe("/repo.new") + }) + + test("does not report unchanged worktrees", () => { + const seen = new Map() + trackProjectMoves(seen, [{ id: "a", worktree: "/repo" }]) + expect(trackProjectMoves(seen, [{ id: "a", worktree: "/repo" }])).toEqual([]) + }) + + test("reports each transition exactly once", () => { + const seen = new Map() + trackProjectMoves(seen, [{ id: "a", worktree: "/repo.old" }]) + trackProjectMoves(seen, [{ id: "a", worktree: "/repo.new" }]) + expect(trackProjectMoves(seen, [{ id: "a", worktree: "/repo.new" }])).toEqual([]) + }) + + test("tracks multiple projects independently", () => { + const seen = new Map() + trackProjectMoves(seen, [ + { id: "a", worktree: "/one" }, + { id: "b", worktree: "/two" }, + ]) + const moves = trackProjectMoves(seen, [ + { id: "a", worktree: "/one.moved" }, + { id: "b", worktree: "/two" }, + ]) + expect(moves).toEqual([{ id: "a", from: "/one", to: "/one.moved" }]) + }) + + test("two projects swapping does not lose either transition", () => { + const seen = new Map() + trackProjectMoves(seen, [ + { id: "a", worktree: "/one" }, + { id: "b", worktree: "/two" }, + ]) + const moves = trackProjectMoves(seen, [ + { id: "a", worktree: "/two" }, + { id: "b", worktree: "/one" }, + ]) + expect(moves).toEqual([ + { id: "a", from: "/one", to: "/two" }, + { id: "b", from: "/two", to: "/one" }, + ]) + }) +}) diff --git a/packages/app/src/context/project-moves.ts b/packages/app/src/context/project-moves.ts new file mode 100644 index 000000000000..79170c75d0c5 --- /dev/null +++ b/packages/app/src/context/project-moves.ts @@ -0,0 +1,32 @@ +export interface ProjectWorktree { + readonly id: string + readonly worktree: string +} + +export interface ProjectMove { + readonly id: string + readonly from: string + readonly to: string +} + +/** + * Tracks the last seen worktree per project id and reports transitions. + * + * A moved or renamed project folder keeps its identity through the repo-local + * id file, and the server adopts the new path the next time the project is + * opened from its new location. Observing the worktree change here lets the + * client relocate path-keyed state (open-project entries, last-project) so + * the old entry does not linger at the dead location while the new path + * shows up as a separate project. + */ +export function trackProjectMoves(seen: Map, projects: readonly ProjectWorktree[]): ProjectMove[] { + const moves: ProjectMove[] = [] + for (const project of projects) { + const previous = seen.get(project.id) + seen.set(project.id, project.worktree) + if (previous && previous !== project.worktree) { + moves.push({ id: project.id, from: previous, to: project.worktree }) + } + } + return moves +} diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 9b337aa07655..5df9fa26134c 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -448,6 +448,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const projectApi = { loadSessions, + // Ask the server to (re)identify a directory. Instance-scoped, so a stale + // cached instance revalidates and a moved checkout re-homes its project, + // emitting the project.updated event that reconciles the sidebar. + touch(directory: string) { + return sdkFor(directory) + .project.current() + .then(() => undefined) + .catch(() => undefined) + }, meta(directory: string, patch: ProjectMeta) { children.projectMeta(directory, patch) }, 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/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 720549ddaff7..6344dbeb80ec 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs" +import nodePath from "node:path" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode, Node } from "@opencode-ai/core/effect/app-node" import { GlobalBus } from "@/bus/global" @@ -6,6 +8,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceRef } from "@/effect/instance-ref" import { disposeInstance as runDisposers } from "@/effect/instance-registry" import { FSUtil } from "@opencode-ai/core/fs-util" +import { ProjectV2 } from "@opencode-ai/core/project" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" import { type InstanceContext } from "./instance-context" import { InstanceBootstrap } from "./bootstrap-service" @@ -30,6 +33,8 @@ export class Service extends Context.Service()("@opencode/In export const use = serviceUse(Service) +const REVALIDATE_INTERVAL_MS = 5_000 + interface Entry { readonly deferred: Deferred.Deferred } @@ -41,6 +46,7 @@ const layer: Layer.Layer() + const lastRevalidate = new Map() const boot = (input: LoadInput & { directory: string }) => Effect.gen(function* () { @@ -110,7 +116,25 @@ const layer: Layer.Layer Effect.gen(function* () { const existing = cache.get(directory) - if (existing) return yield* restore(Deferred.await(existing.deferred)) + if (existing) { + const ctx = yield* restore(Deferred.await(existing.deferred)) + // A directory opened while its folder was absent (e.g. mid-move + // or before a rename settled) resolves to the global project and + // stays cached that way. If a repo has since appeared at the + // path, transparently reload so the directory re-identifies; + // throttled so a path that stays unresolvable cannot trigger a + // reload on every request. + const last = lastRevalidate.get(directory) ?? 0 + if ( + ctx.project.id === ProjectV2.ID.global && + Date.now() - last > REVALIDATE_INTERVAL_MS && + existsSync(nodePath.join(directory, ".git")) + ) { + lastRevalidate.set(directory, Date.now()) + return yield* restore(reload(input)) + } + return ctx + } const entry: Entry = { deferred: Deferred.makeUnsafe() } cache.set(directory, entry) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 9870377b2169..49e404d4669c 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" @@ -8,6 +10,7 @@ import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus } from "@/bus/global" import { which } from "@opencode-ai/core/util/which" +import { Hash } from "@opencode-ai/core/util/hash" import { Command } from "@/command" import { InstanceState } from "@/effect/instance-state" import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect" @@ -146,6 +149,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 +167,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 +202,76 @@ 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) + } + } + }) + + // When a project's worktree moved on disk (old path dead), sessions and + // workspaces recorded at or under the old path follow it: their directory + // doubles as the runtime cwd, so leaving the dead path makes them fail + // every prompt with ENOENT. The snapshot store is keyed by a hash of the + // worktree path and is carried over for the same reason. Copies and + // sibling clones never reach this path — it only runs when the previous + // worktree no longer exists. + const rehomeDirectories = Effect.fn("Project.rehomeDirectories")(function* ( + id: ProjectV2.ID, + from: string, + to: string, + ) { + const prefix = from.endsWith("/") ? from : `${from}/` + const moved = (directory: string) => + directory === from ? to : directory.startsWith(prefix) ? to + directory.slice(from.length) : undefined + + const sessions = yield* db + .select({ id: SessionTable.id, directory: SessionTable.directory }) + .from(SessionTable) + .where(eq(SessionTable.project_id, id)) + .all() + .pipe(Effect.orDie) + for (const session of sessions) { + const next = moved(session.directory) + if (!next) continue + yield* db + .update(SessionTable) + .set({ directory: next, time_updated: sql`${SessionTable.time_updated}` }) + .where(eq(SessionTable.id, session.id)) + .run() + .pipe(Effect.orDie) + } + + const workspaces = yield* db + .select({ id: WorkspaceTable.id, directory: WorkspaceTable.directory }) + .from(WorkspaceTable) + .where(eq(WorkspaceTable.project_id, id)) + .all() + .pipe(Effect.orDie) + for (const workspace of workspaces) { + const next = workspace.directory ? moved(workspace.directory) : undefined + if (!next) continue + yield* db + .update(WorkspaceTable) + .set({ directory: next }) + .where(eq(WorkspaceTable.id, workspace.id)) + .run() + .pipe(Effect.orDie) + } + + yield* fs + .rename( + path.join(Global.Path.data, "snapshot", id, Hash.fast(from)), + path.join(Global.Path.data, "snapshot", id, Hash.fast(to)), + ) + .pipe(Effect.ignore) }) const saveProjectDirectory = Effect.fn("Project.saveProjectDirectory")(function* (input: { @@ -217,8 +299,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 +350,18 @@ 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) { + const previous = result.worktree + result.worktree = data.directory + result.sandboxes = result.sandboxes.filter((sandbox) => sandbox !== result.worktree) + yield* rehomeDirectories(projectID, previous, data.directory) + } + } if ( projectID !== ProjectV2.ID.global && data.directory !== result.worktree && @@ -289,10 +413,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 +432,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..9e9f1011ab86 --- /dev/null +++ b/packages/opencode/test/project/identity.test.ts @@ -0,0 +1,370 @@ +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 + const nestedID = crypto.randomUUID() as SessionID + yield* seedSession({ id: sessionID, dir: tmp, project: before.project.id }) + yield* seedSession({ id: nestedID, dir: path.join(tmp, "packages", "app"), project: before.project.id }) + const workspaceID = WorkspaceV2.ID.ascending() + yield* Database.Service.use(({ db }) => + db + .insert(WorkspaceTable) + .values({ id: workspaceID, type: "local", name: "test", project_id: before.project.id, directory: tmp }) + .run() + .pipe(Effect.orDie), + ) + 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) + + // Sessions and workspaces recorded at or under the old path follow the + // move — their directory is the runtime cwd and the old path is dead. + const rows = yield* Database.Service.use(({ db }) => + db.select().from(SessionTable).where(eq(SessionTable.project_id, before.project.id)).all().pipe(Effect.orDie), + ) + expect(rows.find((row) => row.id === sessionID)?.directory).toBe(renamed) + expect(rows.find((row) => row.id === nestedID)?.directory).toBe(path.join(renamed, "packages", "app")) + const workspace = yield* Database.Service.use(({ db }) => + db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie), + ) + expect(workspace?.directory).toBe(renamed) + }), + ) +}) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index f78b99ef7d9b..a398adf49257 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,6 +1,9 @@ import { describe, expect } from "bun:test" +import { $ } from "bun" +import path from "path" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { ProjectV2 } from "@opencode-ai/core/project" import { Deferred, Effect, Fiber, Layer } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" @@ -67,6 +70,33 @@ describe("InstanceStore", () => { }), ) + it.live("reloads a directory cached as global once a repo appears there", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const dir = path.join(tmp, "repo") + const store = yield* InstanceStore.Service + + // Opened while the folder is absent (e.g. mid-move): caches as global. + const before = yield* store.load({ directory: dir }) + expect(before.project.id).toBe(ProjectV2.ID.global) + + // The folder shows up at the path (move completed). + yield* Effect.promise(async () => { + await $`git init ${dir}`.quiet() + await $`git config core.fsmonitor false`.cwd(dir).quiet() + await $`git config commit.gpgsign false`.cwd(dir).quiet() + await $`git config user.email test@opencode.test`.cwd(dir).quiet() + await $`git config user.name Test`.cwd(dir).quiet() + await $`git commit --allow-empty -m root`.cwd(dir).quiet() + }) + + const after = yield* store.load({ directory: dir }) + + expect(after.project.id).not.toBe(ProjectV2.ID.global) + expect(after.worktree).toBe(dir) + }), + ) + it.live("caches loaded instance context by directory", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) 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) }), )