diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 720549ddaff7..13cb1575be89 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -6,6 +6,8 @@ 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 { existsSync } from "fs" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" import { type InstanceContext } from "./instance-context" import { InstanceBootstrap } from "./bootstrap-service" @@ -51,13 +53,23 @@ const layer: Layer.Layer ({ - directory: input.directory, + : yield* Effect.gen(function* () { + const result = yield* project.fromDirectory(input.directory) + // If the requested directory no longer exists (e.g. the repository + // was moved after the project was created), boot into the directory + // the project actually resolved to instead of failing on the stale + // path. Only do this for resolved git projects, otherwise keep the + // requested directory untouched. + const directory = + existsSync(input.directory) || result.project.id === ProjectV2.ID.global + ? input.directory + : result.sandbox + return { + directory, worktree: result.sandbox, project: result.project, - })), - ) + } + }) yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx)) return ctx }).pipe(Effect.withSpan("InstanceStore.boot")) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 9870377b2169..f086c7fb717f 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -232,9 +232,19 @@ const layer = Layer.effect( if (flags.experimentalIconDiscovery) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) + // If the project's canonical worktree no longer exists on disk but the + // repository still resolves to a valid checkout, the repo has been moved + // or renamed. Adopt the resolved git worktree so the project stays usable + // and clients stop referencing the dead path. + const adoptWorktree = + projectID !== ProjectV2.ID.global && + data.directory !== existing.worktree && + !(yield* fs.exists(existing.worktree).pipe(Effect.orDie)) + const result: Info = { ...existing, - worktree: projectID === ProjectV2.ID.global ? worktree : existing.worktree, + worktree: + projectID === ProjectV2.ID.global ? worktree : adoptWorktree ? data.directory : existing.worktree, vcs: data.vcs?.type ?? fakeVcs, time: { ...existing.time, updated: Date.now() }, } @@ -297,6 +307,15 @@ const layer = Layer.effect( .pipe(Effect.orDie) } + if (adoptWorktree) { + yield* db + .update(SessionTable) + .set({ directory: data.directory }) + .where(and(eq(SessionTable.project_id, projectID), eq(SessionTable.directory, existing.worktree))) + .run() + .pipe(Effect.orDie) + } + yield* saveProjectDirectory({ projectID, directory: data.directory, diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index f78b99ef7d9b..3ed65342109b 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Deferred, Effect, Fiber, Layer } from "effect" +import path from "path" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { InstanceBootstrap } from "../../src/project/bootstrap" @@ -50,6 +51,19 @@ describe("InstanceStore", () => { }), ) + it.live("boots into the resolved worktree when the requested directory no longer exists", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const stale = path.join(tmp, "gone") + const store = yield* InstanceStore.Service + + const ctx = yield* store.load({ directory: stale }) + + expect(ctx.directory).toBe(tmp) + expect(ctx.worktree).toBe(tmp) + }), + ) + it.live("runs bootstrap with InstanceRef provided", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 804b92b08ec4..25af8b1ac0d6 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Project } from "@/project/project" import { $ } from "bun" import path from "path" +import * as fs from "fs/promises" import { tmpdirScoped } from "../fixture/fixture" import { GlobalBus } from "../../src/bus/global" import { Database } from "@opencode-ai/core/database/database" @@ -362,6 +363,29 @@ describe("Project.fromDirectory with worktrees", () => { }), ) + it.live("adopts a moved checkout as the project worktree when the original is gone", () => + Effect.gen(function* () { + const project = yield* Project.Service + const tmp = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Moved-Repo.git`.cwd(tmp).quiet()) + + const result = yield* project.fromDirectory(tmp) + expect(result.project.worktree).toBe(tmp) + + const moved = tmp + "-moved" + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${moved}`.quiet().nothrow()).pipe(Effect.ignore), + ) + yield* Effect.promise(() => fs.rename(tmp, moved)) + + const next = yield* project.fromDirectory(moved) + + expect(next.project.id).toBe(result.project.id) + expect(next.project.worktree).toBe(moved) + expect(next.sandbox).toBe(moved) + }), + ) + it.live("should accumulate multiple worktrees in sandboxes", () => Effect.gen(function* () { const project = yield* Project.Service