refactor(core): move database schema ownership (#29068)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Dax 2026-05-30 21:08:38 -04:00 committed by GitHub
parent 6bcb9cb9bb
commit 7f571d36ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
390 changed files with 11127 additions and 9164 deletions

View file

@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Project } from "@/project/project"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { SessionTable } from "../../src/session/session.sql"
import { ProjectTable } from "../../src/project/project.sql"
import { ProjectID } from "../../src/project/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { $ } from "bun"
@ -15,16 +15,16 @@ import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer))
const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, Database.defaultLayer))
function legacySessionID() {
// Global-session migration covers persisted IDs from before prefixed session IDs.
return crypto.randomUUID() as SessionID
}
function seed(opts: { id: SessionID; dir: string; project: ProjectID }) {
function seed(opts: { id: SessionID; dir: string; project: ProjectV2.ID }) {
const now = Date.now()
Database.use((db) =>
return Database.Service.use(({ db }) =>
db
.insert(SessionTable)
.values({
@ -37,23 +37,25 @@ function seed(opts: { id: SessionID; dir: string; project: ProjectID }) {
time_created: now,
time_updated: now,
})
.run(),
.run()
.pipe(Effect.orDie),
)
}
function ensureGlobal() {
Database.use((db) =>
return Database.Service.use(({ db }) =>
db
.insert(ProjectTable)
.values({
id: ProjectID.global,
id: ProjectV2.ID.global,
worktree: "/",
time_created: Date.now(),
time_updated: Date.now(),
sandboxes: [],
})
.onConflictDoNothing()
.run(),
.run()
.pipe(Effect.orDie),
)
}
@ -68,20 +70,22 @@ describe("migrateFromGlobal", () => {
yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet())
const projects = yield* Project.Service
const { project: pre } = yield* projects.fromDirectory(tmp)
expect(pre.id).toBe(ProjectID.global)
expect(pre.id).toBe(ProjectV2.ID.global)
// 2. Seed a session under "global" with matching directory
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
yield* seed({ id, dir: tmp, project: ProjectV2.ID.global })
// 3. Make a commit so the project gets a real ID
yield* Effect.promise(() => $`git commit --allow-empty -m "root"`.cwd(tmp).quiet())
const { project: real } = yield* projects.fromDirectory(tmp)
expect(real.id).not.toBe(ProjectID.global)
expect(real.id).not.toBe(ProjectV2.ID.global)
// 4. The session should have been migrated to the real project ID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
const row = yield* Database.Service.use(({ db }) =>
db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
)
expect(row).toBeDefined()
expect(row!.project_id).toBe(real.id)
}),
@ -93,22 +97,24 @@ describe("migrateFromGlobal", () => {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
expect(project.id).not.toBe(ProjectV2.ID.global)
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
yield* Effect.sync(() => ensureGlobal())
yield* ensureGlobal()
// 3. Seed a session under "global" with matching directory.
// This simulates a session created before git init that wasn't
// present when the real project row was first created.
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
yield* seed({ id, dir: tmp, project: ProjectV2.ID.global })
// 4. Call fromDirectory again — project row already exists,
// so the current code skips migration entirely. This is the bug.
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
const row = yield* Database.Service.use(({ db }) =>
db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
)
expect(row).toBeDefined()
expect(row!.project_id).toBe(project.id)
}),
@ -119,20 +125,22 @@ describe("migrateFromGlobal", () => {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
expect(project.id).not.toBe(ProjectV2.ID.global)
yield* Effect.sync(() => ensureGlobal())
yield* ensureGlobal()
// Legacy sessions may lack a directory value.
// Without a matching origin directory, they should remain global.
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: "", project: ProjectID.global }))
yield* seed({ id, dir: "", project: ProjectV2.ID.global })
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
const row = yield* Database.Service.use(({ db }) =>
db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
)
expect(row).toBeDefined()
expect(row!.project_id).toBe(ProjectID.global)
expect(row!.project_id).toBe(ProjectV2.ID.global)
}),
)
@ -141,19 +149,21 @@ describe("migrateFromGlobal", () => {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
expect(project.id).not.toBe(ProjectV2.ID.global)
yield* Effect.sync(() => ensureGlobal())
yield* ensureGlobal()
// Seed a session under "global" but for a DIFFERENT directory
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: "/some/other/dir", project: ProjectID.global }))
yield* seed({ id, dir: "/some/other/dir", project: ProjectV2.ID.global })
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
const row = yield* Database.Service.use(({ db }) =>
db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
)
expect(row).toBeDefined()
// Should remain under "global" — not stolen
expect(row!.project_id).toBe(ProjectID.global)
expect(row!.project_id).toBe(ProjectV2.ID.global)
}),
)
})

View file

@ -1,27 +1,25 @@
import { describe, expect, test } from "bun:test"
import { Bus } from "@/bus"
import { describe, expect } from "bun:test"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Project } from "@/project/project"
import * as Log from "@opencode-ai/core/util/log"
import { $ } from "bun"
import path from "path"
import { tmpdirScoped } from "../fixture/fixture"
import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema"
import { Database } from "@/storage/db"
import { ProjectTable } from "@/project/project.sql"
import { SessionTable } from "@/session/session.sql"
import { PermissionTable } from "@/session/session.sql"
import { WorkspaceTable } from "@/control-plane/workspace.sql"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { PermissionTable, 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 { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { AppProcess } from "@opencode-ai/core/process"
import { Project as ProjectV2 } from "@opencode-ai/core/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
import { RuntimeFlags } from "@/effect/runtime-flags"
@ -30,18 +28,11 @@ void Log.init({ print: false })
const encoder = new TextEncoder()
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)
const layer = Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer)
const it = testEffect(layer)
function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
return Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
})
}
function remoteProjectID(remote: string) {
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
}
/**
@ -84,20 +75,22 @@ function projectLayerWithFailure(failArg: string) {
Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
Layer.provide(mockGitFailure(failArg)),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(Bus.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
}
function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.layer>[0]) {
return Project.layer.pipe(
Layer.provide(Bus.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)
}
@ -109,10 +102,11 @@ const iconDiscoveryIt = testEffect(
Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer),
)
function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect<Project.Info> {
function waitForProjectIcon(id: ProjectV2.ID, attempts = 50): Effect.Effect<Project.Info, never, Project.Service> {
return Effect.gen(function* () {
const project = Project.get(id)
if (project?.icon?.url) return project
const project = yield* Project.Service
const info = yield* project.get(id)
if (info?.icon?.url) return info
if (attempts <= 0) throw new Error(`Project icon was not discovered: ${id}`)
yield* Effect.sleep("10 millis")
return yield* waitForProjectIcon(id, attempts - 1)
@ -122,15 +116,16 @@ function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect<Project
describe("Project.fromDirectory", () => {
it.live("should handle git repository with no commits", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp)
expect(result.project).toBeDefined()
expect(result.project.id).toBe(ProjectV2.ID.global)
expect(result.project.vcs).toBe("git")
expect(result.project.worktree).toBe(tmp)
const opencodeFile = path.join(tmp, ".git", "opencode")
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false)
@ -139,119 +134,114 @@ describe("Project.fromDirectory", () => {
it.live("should handle git repository with commits", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp)
expect(result.project).toBeDefined()
expect(result.project.id).not.toBe(ProjectV2.ID.global)
expect(result.project.vcs).toBe("git")
expect(result.project.worktree).toBe(tmp)
}),
)
it.live("returns global for non-git directory", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped()
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.id).toBe(ProjectID.global)
const result = yield* project.fromDirectory(tmp)
expect(result.project.id).toBe(ProjectV2.ID.global)
}),
)
it.live("derives stable project ID from root commit", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
const { project: b } = yield* run((svc) => svc.fromDirectory(tmp))
expect(b.id).toBe(a.id)
const result = yield* project.fromDirectory(tmp)
const next = yield* project.fromDirectory(tmp)
expect(next.project.id).toBe(result.project.id)
}),
)
it.live("prefers normalized origin remote over root commit", () =>
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/Test-Repo.git`.cwd(tmp).quiet())
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
expect(result.project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
}),
)
it.live("normalizes equivalent origin URL forms to the same project ID", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const ssh = yield* tmpdirScoped({ git: true })
const https = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
const result = yield* project.fromDirectory(ssh)
const next = yield* project.fromDirectory(https)
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
expect(result.project.id).toBe(remoteProjectID("github.com/owner/repo"))
expect(next.project.id).toBe(result.project.id)
}),
)
it.live("migrates cached root project 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 { project: rootProject } = yield* projects.fromDirectory(tmp)
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 = WorkspaceID.ascending()
const workspaceID = WorkspaceV2.ID.ascending()
yield* Effect.sync(() => {
Database.use((db) => {
db.insert(SessionTable)
.values({
id: sessionID,
project_id: rootProject.id,
slug: sessionID,
directory: tmp,
title: "test",
version: "0.0.0-test",
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
db.insert(PermissionTable)
.values({
project_id: rootProject.id,
data: [{ permission: "edit", pattern: "*", action: "allow" }],
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
db.insert(WorkspaceTable)
.values({
id: workspaceID,
type: "local",
name: "test",
project_id: rootProject.id,
})
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: rootProject.id,
slug: sessionID,
directory: tmp,
title: "test",
version: "0.0.0-test",
time_created: Date.now(),
time_updated: Date.now(),
})
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(PermissionTable)
.values({
project_id: rootProject.id,
data: [{ permission: "edit", pattern: "*", action: "allow" }],
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(WorkspaceTable)
.values({ id: workspaceID, type: "local", name: "test", project_id: rootProject.id })
.run()
.pipe(Effect.orDie)
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
const { project } = yield* projects.fromDirectory(tmp)
const result = yield* projects.fromDirectory(tmp)
expect(project.id).toBe(remoteID)
expect(
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get()),
).toBeUndefined()
expect(
Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())?.project_id,
).toBe(remoteID)
expect(
Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get()),
).toBeDefined()
expect(
Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get())
?.project_id,
).toBe(remoteID)
expect(result.project.id).toBe(remoteID)
expect(yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get().pipe(Effect.orDie)).toBeUndefined()
expect((yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie))?.project_id).toBe(remoteID)
expect(yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get().pipe(Effect.orDie)).toBeDefined()
expect((yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie))?.project_id).toBe(remoteID)
}),
)
})
@ -259,34 +249,37 @@ describe("Project.fromDirectory", () => {
describe("Project.fromDirectory git failure paths", () => {
it.live("keeps vcs when rev-list exits non-zero (no commits)", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
// rev-list fails because HEAD doesn't exist yet: this is the natural scenario.
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp)
const result = yield* project.fromDirectory(tmp)
expect(result.project.vcs).toBe("git")
expect(result.project.id).toBe(ProjectV2.ID.global)
expect(result.project.worktree).toBe(tmp)
}),
)
failureIt("--show-toplevel").live("handles show-toplevel failure gracefully", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
const result = yield* project.fromDirectory(tmp)
expect(result.project.worktree).toBe(tmp)
expect(result.sandbox).toBe(tmp)
}),
)
failureIt("--git-common-dir").live("handles git-common-dir failure gracefully", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
const result = yield* project.fromDirectory(tmp)
expect(result.project.worktree).toBe(tmp)
expect(result.sandbox).toBe(tmp)
}),
)
})
@ -294,18 +287,20 @@ describe("Project.fromDirectory git failure paths", () => {
describe("Project.fromDirectory with worktrees", () => {
it.live("should set worktree to root when called from root", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
expect(project.sandboxes).not.toContain(tmp)
expect(result.project.worktree).toBe(tmp)
expect(result.sandbox).toBe(tmp)
expect(result.project.sandboxes).not.toContain(tmp)
}),
)
it.live("tracks a linked worktree as the opened project directory", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-worktree")
@ -319,20 +314,21 @@ describe("Project.fromDirectory with worktrees", () => {
)
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet())
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath))
const result = yield* project.fromDirectory(worktreePath)
expect(project.worktree).toBe(worktreePath)
expect(sandbox).toBe(worktreePath)
expect(project.sandboxes).not.toContain(worktreePath)
expect(project.sandboxes).not.toContain(tmp)
expect(result.project.worktree).toBe(worktreePath)
expect(result.sandbox).toBe(worktreePath)
expect(result.project.sandboxes).not.toContain(worktreePath)
expect(result.project.sandboxes).not.toContain(tmp)
}),
)
it.live("worktree should share project ID with main repo", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project: main } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared")
yield* Effect.addFinalizer(() =>
@ -345,9 +341,9 @@ describe("Project.fromDirectory with worktrees", () => {
)
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet())
const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath))
const next = yield* project.fromDirectory(worktreePath)
expect(wt.id).toBe(main.id)
expect(next.project.id).toBe(result.project.id)
const cache = path.join(tmp, ".git", "opencode")
const exists = yield* Effect.promise(() => Bun.file(cache).exists())
@ -357,6 +353,7 @@ describe("Project.fromDirectory with worktrees", () => {
it.live("separate clones of the same repo should share project ID", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
// Create a bare remote, push, then clone into a second directory
@ -368,15 +365,16 @@ describe("Project.fromDirectory with worktrees", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
const { project: b } = yield* run((svc) => svc.fromDirectory(clone))
const result = yield* project.fromDirectory(tmp)
const next = yield* project.fromDirectory(clone)
expect(b.id).toBe(a.id)
expect(next.project.id).toBe(result.project.id)
}),
)
it.live("should accumulate multiple worktrees in sandboxes", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const worktree1 = path.join(tmp, "..", path.basename(tmp) + "-wt1")
@ -400,12 +398,12 @@ describe("Project.fromDirectory with worktrees", () => {
yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet())
yield* run((svc) => svc.fromDirectory(worktree1))
const { project } = yield* run((svc) => svc.fromDirectory(worktree2))
yield* project.fromDirectory(worktree1)
const result = yield* project.fromDirectory(worktree2)
expect(project.worktree).toBe(worktree1)
expect(project.sandboxes).toContain(worktree2)
expect(project.sandboxes).not.toContain(tmp)
expect(result.project.worktree).toBe(worktree1)
expect(result.project.sandboxes).toContain(worktree2)
expect(result.project.sandboxes).not.toContain(tmp)
}),
)
})
@ -413,12 +411,13 @@ describe("Project.fromDirectory with worktrees", () => {
describe("Project.discover", () => {
iconDiscoveryIt.live("discovers favicon from fromDirectory when enabled", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* waitForProjectIcon(project.id)
const result = yield* project.fromDirectory(tmp)
const updated = yield* waitForProjectIcon(result.project.id)
expect(updated.icon?.url).toStartWith("data:")
expect(updated.icon?.url).toContain("base64")
@ -427,15 +426,16 @@ describe("Project.discover", () => {
it.live("should discover favicon.png in root", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
yield* run((svc) => svc.discover(project))
yield* project.discover(result.project)
const updated = Project.get(project.id)
const updated = yield* project.get(result.project.id)
expect(updated).toBeDefined()
expect(updated!.icon).toBeDefined()
expect(updated!.icon?.url).toStartWith("data:")
@ -446,14 +446,15 @@ describe("Project.discover", () => {
it.live("should not discover non-image files", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image"))
yield* run((svc) => svc.discover(project))
yield* project.discover(result.project)
const updated = Project.get(project.id)
const updated = yield* project.get(result.project.id)
expect(updated).toBeDefined()
expect(updated!.icon).toBeUndefined()
}),
@ -461,25 +462,24 @@ describe("Project.discover", () => {
it.live("should not discover favicon when override is set", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,override" },
}),
)
yield* project.update({
projectID: result.project.id,
icon: { override: "data:image/png;base64,override" },
})
const updatedProject = yield* run((svc) => svc.get(project.id))
const updatedProject = yield* project.get(result.project.id)
if (!updatedProject) throw new Error("Project not found")
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
yield* run((svc) => svc.discover(updatedProject))
yield* project.discover(updatedProject)
const updated = Project.get(project.id)
const updated = yield* project.get(result.project.id)
expect(updated).toBeDefined()
expect(updated!.icon?.override).toBe("data:image/png;base64,override")
expect(updated!.icon?.url).toBeUndefined()
@ -490,107 +490,100 @@ describe("Project.discover", () => {
describe("Project.update", () => {
it.live("should update name", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
name: "New Project Name",
}),
)
const updated = yield* project.update({
projectID: result.project.id,
name: "New Project Name",
})
expect(updated.name).toBe("New Project Name")
const fromDb = Project.get(project.id)
const fromDb = yield* project.get(result.project.id)
expect(fromDb?.name).toBe("New Project Name")
}),
)
it.live("should update icon url", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { url: "https://example.com/icon.png" },
}),
)
const updated = yield* project.update({
projectID: result.project.id,
icon: { url: "https://example.com/icon.png" },
})
expect(updated.icon?.url).toBe("https://example.com/icon.png")
const fromDb = Project.get(project.id)
const fromDb = yield* project.get(result.project.id)
expect(fromDb?.icon?.url).toBe("https://example.com/icon.png")
}),
)
it.live("should update icon color", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { color: "#ff0000" },
}),
)
const updated = yield* project.update({
projectID: result.project.id,
icon: { color: "#ff0000" },
})
expect(updated.icon?.color).toBe("#ff0000")
const fromDb = Project.get(project.id)
const fromDb = yield* project.get(result.project.id)
expect(fromDb?.icon?.color).toBe("#ff0000")
}),
)
it.live("should update icon override", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,abc123" },
}),
)
const updated = yield* project.update({
projectID: result.project.id,
icon: { override: "data:image/png;base64,abc123" },
})
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
const fromDb = Project.get(project.id)
const fromDb = yield* project.get(result.project.id)
expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123")
}),
)
it.live("should update commands", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
commands: { start: "npm run dev" },
}),
)
const updated = yield* project.update({
projectID: result.project.id,
commands: { start: "npm run dev" },
})
expect(updated.commands?.start).toBe("npm run dev")
const fromDb = Project.get(project.id)
const fromDb = yield* project.get(result.project.id)
expect(fromDb?.commands?.start).toBe("npm run dev")
}),
)
it.live("should fail when project not found", () =>
Effect.gen(function* () {
const exit = yield* run((svc) =>
svc.update({
projectID: ProjectID.make("nonexistent-project-id"),
name: "Should Fail",
}),
).pipe(Effect.exit)
const project = yield* Project.Service
const exit = yield* project
.update({ projectID: ProjectV2.ID.make("nonexistent-project-id"), name: "Should Fail" })
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
@ -601,8 +594,9 @@ describe("Project.update", () => {
it.live("should emit GlobalBus event on update", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
let eventPayload: any = null
const on = (data: any) => {
@ -611,7 +605,7 @@ describe("Project.update", () => {
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
yield* project.update({ projectID: result.project.id, name: "Updated Name" })
expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated")
@ -621,17 +615,16 @@ describe("Project.update", () => {
it.live("should update multiple fields at once", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
commands: { start: "make start" },
}),
)
const updated = yield* project.update({
projectID: result.project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
commands: { start: "make start" },
})
expect(updated.name).toBe("Multi Update")
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
@ -645,43 +638,49 @@ describe("Project.update", () => {
describe("Project.list and Project.get", () => {
it.live("list returns all projects", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const all = Project.list()
const all = yield* project.list()
expect(all.length).toBeGreaterThan(0)
expect(all.find((p) => p.id === project.id)).toBeDefined()
expect(all.find((p) => p.id === result.project.id)).toBeDefined()
}),
)
it.live("get returns project by id", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const found = Project.get(project.id)
const found = yield* project.get(result.project.id)
expect(found).toBeDefined()
expect(found!.id).toBe(project.id)
expect(found!.id).toBe(result.project.id)
}),
)
test("get returns undefined for unknown id", () => {
const found = Project.get(ProjectID.make("nonexistent"))
expect(found).toBeUndefined()
})
it.live("get returns undefined for unknown id", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const found = yield* project.get(ProjectV2.ID.make("nonexistent"))
expect(found).toBeUndefined()
}),
)
})
describe("Project.setInitialized", () => {
it.live("sets time_initialized on project", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
expect(project.time.initialized).toBeUndefined()
expect(result.project.time.initialized).toBeUndefined()
Project.setInitialized(project.id)
yield* project.setInitialized(result.project.id)
const updated = Project.get(project.id)
const updated = yield* project.get(result.project.id)
expect(updated?.time.initialized).toBeDefined()
}),
)
@ -690,26 +689,28 @@ describe("Project.setInitialized", () => {
describe("Project.addSandbox and Project.removeSandbox", () => {
it.live("addSandbox adds directory and removeSandbox removes it", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const sandboxDir = path.join(tmp, "sandbox-test")
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
yield* project.addSandbox(result.project.id, sandboxDir)
let found = Project.get(project.id)
let found = yield* project.get(result.project.id)
expect(found?.sandboxes).toContain(sandboxDir)
yield* run((svc) => svc.removeSandbox(project.id, sandboxDir))
yield* project.removeSandbox(result.project.id, sandboxDir)
found = Project.get(project.id)
found = yield* project.get(result.project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
}),
)
it.live("addSandbox emits GlobalBus event", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const result = yield* project.fromDirectory(tmp)
const sandboxDir = path.join(tmp, "sandbox-event")
const events: any[] = []
@ -717,7 +718,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
yield* project.addSandbox(result.project.id, sandboxDir)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
}),
@ -727,6 +728,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
describe("Project.fromDirectory with bare repos", () => {
it.live("worktree from bare repo should cache in bare repo, not parent", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const parentDir = path.dirname(tmp)
@ -739,10 +741,10 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
const result = yield* project.fromDirectory(worktreePath)
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(worktreePath)
expect(result.project.id).not.toBe(ProjectV2.ID.global)
expect(result.project.worktree).toBe(worktreePath)
const correctCache = path.join(barePath, "opencode")
const wrongCache = path.join(parentDir, ".git", "opencode")
@ -754,6 +756,7 @@ describe("Project.fromDirectory with bare repos", () => {
it.live("different bare repos under same parent should not share project ID", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp1 = yield* tmpdirScoped({ git: true })
const tmp2 = yield* tmpdirScoped({ git: true })
@ -773,10 +776,10 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet())
yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet())
const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA))
const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB))
const result = yield* project.fromDirectory(worktreeA)
const next = yield* project.fromDirectory(worktreeB)
expect(projA.id).not.toBe(projB.id)
expect(result.project.id).not.toBe(next.project.id)
const cacheA = path.join(bareA, "opencode")
const cacheB = path.join(bareB, "opencode")
@ -790,6 +793,7 @@ describe("Project.fromDirectory with bare repos", () => {
it.live("bare repo without .git suffix is still detected via core.bare", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const parentDir = path.dirname(tmp)
@ -802,10 +806,10 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
const result = yield* project.fromDirectory(worktreePath)
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(worktreePath)
expect(result.project.id).not.toBe(ProjectV2.ID.global)
expect(result.project.worktree).toBe(worktreePath)
const correctCache = path.join(barePath, "opencode")
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)

View file

@ -5,8 +5,8 @@ import { Deferred, Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import fs from "fs/promises"
import path from "path"
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { Bus } from "../../src/bus"
import { disposeAllInstances, provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { FileWatcher } from "../../src/file/watcher"
import { Git } from "../../src/git"
import { Vcs } from "@/project/vcs"
@ -19,11 +19,12 @@ import { testEffect } from "../lib/effect"
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
const layer = Layer.mergeAll(
Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(Bus.layer)),
Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(EventV2Bridge.defaultLayer)),
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
)
const it = testEffect(layer)
const worktreeIt = testEffect(Layer.mergeAll(layer, testInstanceStoreLayer))
const git = Effect.fn("VcsTest.git")(function* (cwd: string, args: string[]) {
const result = yield* Git.Service.use((git) => git.run(args, { cwd }))
@ -47,13 +48,15 @@ const init = Effect.fn("VcsTest.init")(function* () {
})
const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () {
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const updated = yield* Deferred.make<string | undefined>()
const off = yield* bus.subscribeCallback(Vcs.Event.BranchUpdated, (evt) => {
Effect.runSync(Deferred.succeed(updated, evt.properties.branch))
const off = yield* events.listen((event) => {
if (event.type === Vcs.Event.BranchUpdated.type)
Deferred.doneUnsafe(updated, Effect.succeed((event.data as typeof Vcs.Event.BranchUpdated.data.Type).branch))
return Effect.void
})
yield* Effect.addFinalizer(() => Effect.sync(off))
yield* Effect.addFinalizer(() => off)
return updated
})
@ -62,9 +65,9 @@ const publishHeadChangeUntil = Effect.fn("VcsTest.publishHeadChangeUntil")(funct
pending: Deferred.Deferred<string | undefined>,
head: string,
) {
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
for (let i = 0; i < 50; i++) {
yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" })
yield* events.publish(FileWatcher.Event.Updated, { file: head, event: "change" })
if (yield* Deferred.isDone(pending)) return
yield* Effect.sleep("10 millis")
}
@ -183,7 +186,7 @@ describe("Vcs diff", () => {
{ git: true },
)
it.live("detects current branch from the active worktree", () =>
worktreeIt.live("detects current branch from the active worktree", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const wt = yield* tmpdirScoped()

View file

@ -5,17 +5,18 @@ import path from "path"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Worktree } from "../../src/worktree"
import { provideTmpdirInstance } from "../fixture/fixture"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
const wintest = process.platform === "win32" ? it.live : it.live.skip
const wintest = process.platform === "win32" ? it.instance : it.instance.skip
describe("Worktree.remove", () => {
it.live("continues when git remove exits non-zero after detaching", () =>
provideTmpdirInstance(
(root) =>
Effect.gen(function* () {
it.instance(
"continues when git remove exits non-zero after detaching",
() =>
Effect.gen(function* () {
const root = (yield* TestInstance).directory
const svc = yield* Worktree.Service
const name = `remove-regression-${Date.now().toString(36)}`
const branch = `opencode/${name}`
@ -79,15 +80,15 @@ describe("Worktree.remove", () => {
$`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
)
expect(ref.exitCode).not.toBe(0)
}),
{ git: true },
),
}),
{ git: true },
)
wintest("stops fsmonitor before removing a worktree", () =>
provideTmpdirInstance(
(root) =>
Effect.gen(function* () {
wintest(
"stops fsmonitor before removing a worktree",
() =>
Effect.gen(function* () {
const root = (yield* TestInstance).directory
const svc = yield* Worktree.Service
const name = `remove-fsmonitor-${Date.now().toString(36)}`
const branch = `opencode/${name}`
@ -119,8 +120,7 @@ describe("Worktree.remove", () => {
$`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
)
expect(ref.exitCode).not.toBe(0)
}),
{ git: true },
),
}),
{ git: true },
)
})

View file

@ -5,8 +5,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Git } from "../../src/git"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Worktree } from "../../src/worktree"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@ -41,11 +39,6 @@ const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () {
const removeCreatedWorktree = (directory: string) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ctx = yield* Effect.gen(function* () {
return yield* InstanceRef
}).pipe(provideInstance(directory))
if (!ctx) return yield* Effect.die(new Error("missing test instance"))
yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
const ok = yield* svc.remove({ directory })
if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`))
})