mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 17:33:22 +00:00
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:
parent
6bcb9cb9bb
commit
7f571d36ea
390 changed files with 11127 additions and 9164 deletions
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`))
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue