Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions packages/opencode/src/project/instance-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -51,13 +53,23 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
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"))
Expand Down
21 changes: 20 additions & 1 deletion packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
}
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/test/project/instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 })
Expand Down
24 changes: 24 additions & 0 deletions packages/opencode/test/project/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading