feat: 品牌替换 + 启动优化 + AGENTS.md 模板定制

- 品牌替换:OpenCode/opencode → AirCoding/aircoding(16+ 文件)
- Logo ASCII art:修复 left/right 行数不匹配导致的启动崩溃
- 启动诊断:添加 OPENCODE_PRINT_TIMING 计时探针
- dev 模式默认 --pure 跳过外部插件加载
- AGENTS.md 模板:追加 AirCoding 多 Agent 专项段落
- architect prompt + plugin:强化 AGENTS.md 产出验证
This commit is contained in:
airlongdian
2026-06-14 09:31:29 +08:00
commit e2fd375a1c
5757 changed files with 1170016 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
import { afterEach, expect } from "bun:test"
import { existsSync } from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap"
import { InstanceLayer } from "../../src/project/instance-layer"
import { InstanceStore } from "../../src/project/instance-store"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { waitGlobalBusEvent } from "../server/global-bus"
const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer))
// InstanceBootstrap must run before any code touches the instance —
// originally tracked by PRs #25389 and #25449, now a permanent
// invariant. The plugin config hook writes a marker file; the test
// bodies deliberately avoid Plugin/config directly. The marker only
// appears if InstanceBootstrap ran at the instance boundary.
//
// The boundaries below are transport-agnostic and stay.
afterEach(async () => {
await disposeAllInstances()
})
const bootstrapFixture = Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const marker = path.join(dir, "config-hook-fired")
const pluginFile = path.join(dir, "plugin.ts")
yield* Effect.promise(() =>
Bun.write(
pluginFile,
[
`const MARKER = ${JSON.stringify(marker)}`,
"export default async () => ({",
" config: async () => {",
' await Bun.write(MARKER, "ran")',
" },",
"})",
"",
].join("\n"),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(pluginFile).href],
}),
),
)
return { directory: dir, marker }
})
function waitDisposed(directory: string) {
return waitGlobalBusEvent({
message: "timed out waiting for CLI bootstrap instance disposal",
predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
})
}
it.live("InstanceStore.provide runs InstanceBootstrap before effect", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
const store = yield* InstanceStore.Service
yield* store.provide({ directory: tmp.directory }, Effect.succeed("ok"))
expect(existsSync(tmp.marker)).toBe(true)
}),
)
it.live("CLI bootstrap runs InstanceBootstrap before callback", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
yield* Effect.promise(() => cliBootstrap(tmp.directory, async () => "ok"))
expect(existsSync(tmp.marker)).toBe(true)
}),
)
it.live("CLI bootstrap disposes the instance when the callback rejects", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
const disposed = yield* waitDisposed(tmp.directory).pipe(Effect.forkScoped({ startImmediately: true }))
const exit = yield* Effect.promise(() =>
cliBootstrap(tmp.directory, async () => Promise.reject(new Error("boom"))),
).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toMatchObject({ message: "boom" })
yield* Fiber.join(disposed)
}),
)
it.live("InstanceStore.reload runs InstanceBootstrap", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
const store = yield* InstanceStore.Service
yield* store.reload({ directory: tmp.directory })
expect(existsSync(tmp.marker)).toBe(true)
}),
)

View File

@@ -0,0 +1,245 @@
import { describe, expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { registerDisposer } from "../../src/effect/instance-registry"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
let bootstrapRun: Effect.Effect<void> = Effect.void
const noopBootstrap = Layer.succeed(
InstanceBootstrap.Service,
InstanceBootstrap.Service.of({ run: Effect.suspend(() => bootstrapRun) }),
)
const it = testEffect(
Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)),
)
const setBootstrap = (run: Effect.Effect<void>) =>
Effect.acquireRelease(
Effect.sync(() => {
bootstrapRun = run
}),
() =>
Effect.sync(() => {
bootstrapRun = Effect.void
}),
)
const registerDisposerScoped = (disposer: (directory: string) => Promise<void>) =>
Effect.acquireRelease(
Effect.sync(() => registerDisposer(disposer)),
(off) => Effect.sync(off),
)
describe("InstanceStore", () => {
it.live("loads instance context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const ctx = yield* store.load({ directory: dir })
expect(ctx.directory).toBe(dir)
expect(ctx.worktree).toBe(dir)
}),
)
it.live("runs bootstrap with InstanceRef provided", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
let initializedDirectory: string | undefined
yield* setBootstrap(
Effect.gen(function* () {
initializedDirectory = (yield* InstanceRef)?.directory
}),
)
yield* store.load({ directory: dir })
expect(initializedDirectory).toBe(dir)
}),
)
it.live("caches loaded instance context by directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
let initialized = 0
yield* setBootstrap(
Effect.sync(() => {
initialized++
}),
)
const first = yield* store.load({ directory: dir })
const second = yield* store.load({ directory: dir })
expect(second).toBe(first)
expect(initialized).toBe(1)
}),
)
it.live("dedupes concurrent loads while init is in flight", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let initialized = 0
yield* setBootstrap(
Effect.gen(function* () {
initialized++
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}),
)
const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* setBootstrap(
Effect.sync(() => {
initialized++
}),
)
const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped)
expect(initialized).toBe(1)
yield* Deferred.succeed(release, undefined)
const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)])
expect(secondCtx).toBe(firstCtx)
expect(initialized).toBe(1)
}),
)
it.live("removes failed loads from the cache", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
let attempts = 0
yield* setBootstrap(
Effect.sync(() => {
attempts++
throw new Error("init failed")
}),
)
const failed = yield* store.load({ directory: dir }).pipe(
Effect.as(false),
Effect.catchCause(() => Effect.succeed(true)),
)
expect(failed).toBe(true)
yield* setBootstrap(
Effect.sync(() => {
attempts++
}),
)
const ctx = yield* store.load({ directory: dir })
expect(ctx.directory).toBe(dir)
expect(attempts).toBe(2)
}),
)
it.live("reload replaces the cached context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const first = yield* store.load({ directory: dir })
const second = yield* store.reload({ directory: dir })
const cached = yield* store.load({ directory: dir })
expect(second).not.toBe(first)
expect(cached).toBe(second)
}),
)
it.live("stale dispose does not delete an in-flight reload", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const reloading = yield* Deferred.make<void>()
const releaseReload = yield* Deferred.make<void>()
const disposed: Array<string> = []
yield* registerDisposerScoped(async (directory) => {
disposed.push(directory)
})
const first = yield* store.load({ directory: dir })
yield* setBootstrap(
Effect.gen(function* () {
yield* Deferred.succeed(reloading, undefined)
yield* Deferred.await(releaseReload)
}),
)
const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped)
yield* Deferred.await(reloading)
const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped)
yield* Deferred.succeed(releaseReload, undefined)
const second = yield* Fiber.join(reload)
yield* Fiber.join(staleDispose)
expect(disposed).toEqual([dir])
expect(yield* store.load({ directory: dir })).toBe(second)
}),
)
it.live("dedupes concurrent disposeAll calls", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const disposing = yield* Deferred.make<void>()
const releaseDispose = yield* Deferred.make<() => void>()
const disposed: Array<string> = []
yield* registerDisposerScoped((directory) => {
disposed.push(directory)
Deferred.doneUnsafe(disposing, Effect.void)
return new Promise<void>((resolve) => {
Deferred.doneUnsafe(releaseDispose, Effect.succeed(resolve))
})
})
yield* store.load({ directory: dir })
const first = yield* store.disposeAll().pipe(Effect.forkScoped)
yield* Deferred.await(disposing)
const release = yield* Deferred.await(releaseDispose)
const second = yield* store.disposeAll().pipe(Effect.forkScoped)
expect(disposed).toEqual([dir])
yield* Effect.sync(release)
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
expect(disposed).toEqual([dir])
}),
)
it.live("re-arms disposeAll after completion", () =>
Effect.gen(function* () {
const dir1 = yield* tmpdirScoped({ git: true })
const dir2 = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const disposed: Array<string> = []
yield* registerDisposerScoped(async (directory) => {
disposed.push(directory)
})
yield* store.load({ directory: dir1 })
yield* store.disposeAll()
expect(disposed).toEqual([dir1])
yield* store.load({ directory: dir2 })
yield* store.disposeAll()
expect(disposed).toEqual([dir1, dir2])
}),
)
})

View File

@@ -0,0 +1,167 @@
import { describe, expect } from "bun:test"
import { Project } from "@/project/project"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionID } from "../../src/session/schema"
import { $ } from "bun"
import { tmpdirScoped } from "../fixture/fixture"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
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: ProjectV2.ID }) {
const now = Date.now()
return Database.Service.use(({ db }) =>
db
.insert(SessionTable)
.values({
id: opts.id,
project_id: opts.project,
slug: opts.id,
directory: opts.dir,
title: "test",
version: "0.0.0-test",
time_created: now,
time_updated: now,
})
.run()
.pipe(Effect.orDie),
)
}
function ensureGlobal() {
return Database.Service.use(({ db }) =>
db
.insert(ProjectTable)
.values({
id: ProjectV2.ID.global,
worktree: AbsolutePath.make("/"),
time_created: Date.now(),
time_updated: Date.now(),
sandboxes: [],
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie),
)
}
describe("migrateFromGlobal", () => {
it.live("migrates global sessions on first project creation", () =>
Effect.gen(function* () {
// 1. Start with git init but no commits — creates "global" project row
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet())
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(ProjectV2.ID.global)
// 2. Seed a session under "global" with matching directory
const id = legacySessionID()
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(ProjectV2.ID.global)
// 4. The session should have been migrated to the real project ID
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)
}),
)
it.live("migrates global sessions even when project row already exists", () =>
Effect.gen(function* () {
// 1. Create a repo with a commit — real project ID created immediately
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectV2.ID.global)
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
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* 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 = 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)
}),
)
it.live("does not claim sessions with empty directory", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectV2.ID.global)
yield* ensureGlobal()
// Legacy sessions may lack a directory value.
// Without a matching origin directory, they should remain global.
const id = legacySessionID()
yield* seed({ id, dir: "", project: ProjectV2.ID.global })
yield* projects.fromDirectory(tmp)
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(ProjectV2.ID.global)
}),
)
it.live("does not steal sessions from unrelated directories", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectV2.ID.global)
yield* ensureGlobal()
// Seed a session under "global" but for a DIFFERENT directory
const id = legacySessionID()
yield* seed({ id, dir: "/some/other/dir", project: ProjectV2.ID.global })
yield* projects.fromDirectory(tmp)
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(ProjectV2.ID.global)
}),
)
})

View File

@@ -0,0 +1,169 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Hash } from "@opencode-ai/core/util/hash"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Project } from "@/project/project"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer))
function directories(projectID: ProjectV2.ID) {
return Database.Service.use(({ db }) =>
db
.select()
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, projectID))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) =>
rows
.map((row) => ({ directory: row.directory, type: row.type }))
.toSorted((a, b) => a.directory.localeCompare(b.directory)),
),
),
)
}
describe("Project directory persistence", () => {
it.live("stores the first opened checkout directory", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const project = yield* Project.Service
const result = yield* project.fromDirectory(tmp)
expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
}),
)
it.live("stores a repeatedly opened checkout directory only once", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const project = yield* Project.Service
const result = yield* project.fromDirectory(tmp)
const next = yield* project.fromDirectory(tmp)
expect(next.project.id).toBe(result.project.id)
expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
}),
)
it.live("stores an opened linked worktree directory", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const project = yield* Project.Service
const main = yield* project.fromDirectory(tmp)
const worktree = path.join(tmp, "..", path.basename(tmp) + "-project-directory-worktree")
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`git worktree remove ${worktree}`.cwd(tmp).quiet().nothrow()).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add ${worktree} -b project-directory-${Date.now()}`.cwd(tmp).quiet())
yield* project.fromDirectory(worktree)
expect(yield* directories(main.project.id)).toEqual(
[
{ directory: tmp, type: "main" as const },
{ directory: worktree, type: "git_worktree" as const },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
}),
)
it.live("stores only the linked copy when first opened from an external linked worktree", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const worktree = path.join(tmp, "..", path.basename(tmp) + "-project-directory-first-worktree")
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`git worktree remove ${worktree}`.cwd(tmp).quiet().nothrow()).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${worktree} HEAD`.cwd(tmp).quiet())
const project = yield* Project.Service
const result = yield* project.fromDirectory(worktree)
expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
}),
)
it.live("stores a separately opened clone as a secondary directory", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const bare = tmp + "-project-directory-bare"
const clone = tmp + "-project-directory-clone"
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
const project = yield* Project.Service
const main = yield* project.fromDirectory(tmp)
yield* project.fromDirectory(clone)
expect(yield* directories(main.project.id)).toEqual(
[
{ directory: tmp, type: "main" as const },
{ directory: clone, type: "root" as const },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
}),
)
it.live("stores only the materialized worktree for a bare repository", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const bare = tmp + "-project-directory-bare-store.git"
const worktree = tmp + "-project-directory-bare-worktree"
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`rm -rf ${bare} ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktree} HEAD`.cwd(bare).quiet())
const project = yield* Project.Service
const result = yield* project.fromDirectory(worktree)
expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
}),
)
it.live("records the active directory under its newly resolved project id", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const project = yield* Project.Service
yield* project.fromDirectory(tmp)
const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/collision"))
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({
id: remoteID,
worktree: AbsolutePath.make("/tmp/existing"),
vcs: "git",
time_created: Date.now(),
time_updated: Date.now(),
sandboxes: [],
})
.run()
.pipe(Effect.orDie)
yield* Effect.promise(() =>
$`git remote add origin git@github.com:project-directory-test/collision.git`.cwd(tmp).quiet(),
)
yield* project.fromDirectory(tmp)
expect(yield* directories(remoteID)).toEqual([{ directory: tmp, type: "main" }])
}),
)
})

View File

@@ -0,0 +1,815 @@
import { describe, expect } from "bun:test"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Project } from "@/project/project"
import { $ } from "bun"
import path from "path"
import { tmpdirScoped } from "../fixture/fixture"
import { GlobalBus } from "../../src/bus/global"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { eq } from "drizzle-orm"
import { Hash } from "@opencode-ai/core/util/hash"
import { SessionID } from "@/session/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppProcess } from "@opencode-ai/core/process"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
import { RuntimeFlags } from "@/effect/runtime-flags"
const encoder = new TextEncoder()
const layer = Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer)
const it = testEffect(layer)
function remoteProjectID(remote: string) {
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
}
/**
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
* matching `failArg` and returns exit code 128, while delegating everything
* else to the real CrossSpawnSpawner.
*/
function mockGitFailure(failArg: string) {
return Layer.effect(
ChildProcessSpawner.ChildProcessSpawner,
Effect.gen(function* () {
const real = yield* ChildProcessSpawner.ChildProcessSpawner
return ChildProcessSpawner.make(
Effect.fnUntraced(function* (command) {
const std = ChildProcess.isStandardCommand(command) ? command : undefined
if (std?.command === "git" && std.args.some((a) => a === failArg)) {
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(0),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
stdout: Stream.empty,
stderr: Stream.make(encoder.encode("fatal: simulated failure\n")),
all: Stream.empty,
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
})
}
return yield* real.spawn(command)
}),
)
}),
).pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
}
function projectLayerWithFailure(failArg: string) {
return Project.layer.pipe(
Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
Layer.provide(mockGitFailure(failArg)),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(ProjectCopy.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(FSUtil.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(EventV2Bridge.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(ProjectCopy.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(NodePath.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)
}
const failureIt = (failArg: string) =>
testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer))
const iconDiscoveryIt = testEffect(
Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer),
)
function waitForProjectIcon(id: ProjectV2.ID, attempts = 50): Effect.Effect<Project.Info, never, Project.Service> {
return Effect.gen(function* () {
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)
})
}
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 result = yield* project.fromDirectory(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)
}),
)
it.live("should handle git repository with commits", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const result = yield* project.fromDirectory(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 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 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 result = yield* project.fromDirectory(tmp)
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 result = yield* project.fromDirectory(ssh)
const next = yield* project.fromDirectory(https)
expect(result.project.id).toBe(remoteProjectID("github.com/owner/repo"))
expect(next.project.id).toBe(result.project.id)
}),
)
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 rootResult = yield* projects.fromDirectory(tmp)
const rootProject = rootResult.project
const remoteID = remoteProjectID("github.com/acme/app")
const sessionID = crypto.randomUUID() as SessionID
const workspaceID = WorkspaceV2.ID.ascending()
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(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 result = yield* projects.fromDirectory(tmp)
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(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie))
?.project_id,
).toBe(remoteID)
}),
)
})
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 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 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 result = yield* project.fromDirectory(tmp)
expect(result.project.worktree).toBe(tmp)
expect(result.sandbox).toBe(tmp)
}),
)
})
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 result = yield* project.fromDirectory(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")
yield* Effect.addFinalizer(() =>
Effect.promise(() =>
$`git worktree remove ${worktreePath}`
.cwd(tmp)
.quiet()
.catch(() => {}),
),
)
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet())
const result = yield* project.fromDirectory(worktreePath)
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 result = yield* project.fromDirectory(tmp)
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared")
yield* Effect.addFinalizer(() =>
Effect.promise(() =>
$`git worktree remove ${worktreePath}`
.cwd(tmp)
.quiet()
.catch(() => {}),
),
)
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet())
const next = yield* project.fromDirectory(worktreePath)
expect(next.project.id).toBe(result.project.id)
const cache = path.join(tmp, ".git", "opencode")
const exists = yield* Effect.promise(() => Bun.file(cache).exists())
expect(exists).toBe(true)
}),
)
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
const bare = tmp + "-bare"
const clone = tmp + "-clone"
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
const result = yield* project.fromDirectory(tmp)
const next = yield* project.fromDirectory(clone)
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")
const worktree2 = path.join(tmp, "..", path.basename(tmp) + "-wt2")
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* Effect.promise(() =>
$`git worktree remove ${worktree1}`
.cwd(tmp)
.quiet()
.catch(() => {}),
)
yield* Effect.promise(() =>
$`git worktree remove ${worktree2}`
.cwd(tmp)
.quiet()
.catch(() => {}),
)
}),
)
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* project.fromDirectory(worktree1)
const result = yield* project.fromDirectory(worktree2)
expect(result.project.worktree).toBe(worktree1)
expect(result.project.sandboxes).toContain(worktree2)
expect(result.project.sandboxes).not.toContain(tmp)
}),
)
})
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 result = yield* project.fromDirectory(tmp)
const updated = yield* waitForProjectIcon(result.project.id)
expect(updated.icon?.url).toStartWith("data:")
expect(updated.icon?.url).toContain("base64")
}),
)
it.live("should discover favicon.png in root", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
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* project.discover(result.project)
const updated = yield* project.get(result.project.id)
expect(updated).toBeDefined()
expect(updated!.icon).toBeDefined()
expect(updated!.icon?.url).toStartWith("data:")
expect(updated!.icon?.url).toContain("base64")
expect(updated!.icon?.color).toBeUndefined()
}),
)
it.live("should not discover non-image files", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const result = yield* project.fromDirectory(tmp)
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image"))
yield* project.discover(result.project)
const updated = yield* project.get(result.project.id)
expect(updated).toBeDefined()
expect(updated!.icon).toBeUndefined()
}),
)
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 result = yield* project.fromDirectory(tmp)
yield* project.update({
projectID: result.project.id,
icon: { override: "data:image/png;base64,override" },
})
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* project.discover(updatedProject)
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()
}),
)
})
describe("Project.update", () => {
it.live("should update name", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const result = yield* project.fromDirectory(tmp)
const updated = yield* project.update({
projectID: result.project.id,
name: "New Project Name",
})
expect(updated.name).toBe("New Project Name")
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 result = yield* project.fromDirectory(tmp)
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 = 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 result = yield* project.fromDirectory(tmp)
const updated = yield* project.update({
projectID: result.project.id,
icon: { color: "#ff0000" },
})
expect(updated.icon?.color).toBe("#ff0000")
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 result = yield* project.fromDirectory(tmp)
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 = 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 result = yield* project.fromDirectory(tmp)
const updated = yield* project.update({
projectID: result.project.id,
commands: { start: "npm run dev" },
})
expect(updated.commands?.start).toBe("npm run dev")
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 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)
expect(error).toMatchObject({ _tag: "Project.NotFoundError", projectID: "nonexistent-project-id" })
}
}),
)
it.live("should emit GlobalBus event on update", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const result = yield* project.fromDirectory(tmp)
let eventPayload: any = null
const on = (data: any) => {
eventPayload = data
}
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
yield* project.update({ projectID: result.project.id, name: "Updated Name" })
expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated")
expect(eventPayload.payload.properties.name).toBe("Updated Name")
}),
)
it.live("should update multiple fields at once", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const result = yield* project.fromDirectory(tmp)
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")
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
expect(updated.icon?.color).toBe("#00ff00")
expect(updated.commands?.start).toBe("make start")
}),
)
})
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 result = yield* project.fromDirectory(tmp)
const all = yield* project.list()
expect(all.length).toBeGreaterThan(0)
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 result = yield* project.fromDirectory(tmp)
const found = yield* project.get(result.project.id)
expect(found).toBeDefined()
expect(found!.id).toBe(result.project.id)
}),
)
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 result = yield* project.fromDirectory(tmp)
expect(result.project.time.initialized).toBeUndefined()
yield* project.setInitialized(result.project.id)
const updated = yield* project.get(result.project.id)
expect(updated?.time.initialized).toBeDefined()
}),
)
})
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 result = yield* project.fromDirectory(tmp)
const sandboxDir = path.join(tmp, "sandbox-test")
yield* project.addSandbox(result.project.id, sandboxDir)
let found = yield* project.get(result.project.id)
expect(found?.sandboxes).toContain(sandboxDir)
yield* project.removeSandbox(result.project.id, sandboxDir)
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 result = yield* project.fromDirectory(tmp)
const sandboxDir = path.join(tmp, "sandbox-event")
const events: any[] = []
const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
yield* project.addSandbox(result.project.id, sandboxDir)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
}),
)
})
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)
const barePath = path.join(parentDir, `bare-${Date.now()}.git`)
const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const result = yield* project.fromDirectory(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")
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
}),
)
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 })
const parentDir = path.dirname(tmp1)
const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`)
const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`)
const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`)
const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow()).pipe(
Effect.ignore,
),
)
yield* Effect.promise(() => $`git clone --bare ${tmp1} ${bareA}`.quiet())
yield* Effect.promise(() => $`git clone --bare ${tmp2} ${bareB}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet())
yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet())
const result = yield* project.fromDirectory(worktreeA)
const next = yield* project.fromDirectory(worktreeB)
expect(result.project.id).not.toBe(next.project.id)
const cacheA = path.join(bareA, "opencode")
const cacheB = path.join(bareB, "opencode")
const wrongCache = path.join(parentDir, ".git", "opencode")
expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
}),
)
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)
const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`)
const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const result = yield* project.fromDirectory(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

@@ -0,0 +1,336 @@
import { afterEach, describe, expect } from "bun:test"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { parsePatch } from "diff"
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,
testInstanceStoreLayer,
TestInstance,
tmpdirScoped,
} from "../fixture/fixture"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Git } from "../../src/git"
import { Vcs } from "@/project/vcs"
import { testEffect } from "../lib/effect"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
const layer = Layer.mergeAll(
Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(EventV2Bridge.defaultLayer)),
CrossSpawnSpawner.defaultLayer,
FSUtil.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 }))
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`)
})
const write = Effect.fn("VcsTest.write")(function* (file: string, content: string) {
yield* FSUtil.Service.use((fs) => fs.writeWithDirs(file, content))
})
const remove = Effect.fn("VcsTest.remove")(function* (file: string) {
yield* FSUtil.Service.use((fs) => fs.remove(file))
})
const symlink = (target: string, file: string) => Effect.promise(() => fs.symlink(target, file))
const init = Effect.fn("VcsTest.init")(function* () {
const vcs = yield* Vcs.Service
yield* vcs.init()
return vcs
})
const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () {
const events = yield* EventV2Bridge.Service
const updated = yield* Deferred.make<string | undefined>()
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(() => off)
return updated
})
const publishHeadChangeUntil = Effect.fn("VcsTest.publishHeadChangeUntil")(function* (
pending: Deferred.Deferred<string | undefined>,
head: string,
) {
const events = yield* EventV2Bridge.Service
for (let i = 0; i < 50; i++) {
yield* events.publish(Watcher.Event.Updated, { file: head, event: "change" })
if (yield* Deferred.isDone(pending)) return
yield* Effect.sleep("10 millis")
}
})
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Vcs", () => {
afterEach(async () => {
await disposeAllInstances()
})
it.instance(
"branch() returns current branch name",
() =>
Effect.gen(function* () {
const vcs = yield* init()
const branch = yield* vcs.branch()
expect(branch).toBeDefined()
expect(typeof branch).toBe("string")
}),
{ git: true },
)
it.instance("branch() returns undefined for non-git directories", () =>
Effect.gen(function* () {
const vcs = yield* init()
const branch = yield* vcs.branch()
expect(branch).toBeUndefined()
}),
)
it.instance(
"publishes BranchUpdated when .git/HEAD changes",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const branch = `test-${Math.random().toString(36).slice(2)}`
yield* git(test.directory, ["branch", branch])
const vcs = yield* init()
yield* vcs.branch()
const pending = yield* nextBranchUpdate()
const head = path.join(test.directory, ".git", "HEAD")
yield* write(head, `ref: refs/heads/${branch}\n`)
yield* publishHeadChangeUntil(pending, head)
const updated = yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds"))
expect(updated).toBe(branch)
}),
{ git: true },
)
it.instance(
"branch() reflects the new branch after HEAD change",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const branch = `test-${Math.random().toString(36).slice(2)}`
yield* git(test.directory, ["branch", branch])
const vcs = yield* init()
yield* vcs.branch()
const pending = yield* nextBranchUpdate()
const head = path.join(test.directory, ".git", "HEAD")
yield* write(head, `ref: refs/heads/${branch}\n`)
yield* publishHeadChangeUntil(pending, head)
yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds"))
const current = yield* vcs.branch()
expect(current).toBe(branch)
}),
{ git: true },
)
})
describe("Vcs diff", () => {
afterEach(async () => {
await disposeAllInstances()
})
it.instance(
"defaultBranch() falls back to main",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* git(test.directory, ["branch", "-M", "main"])
const vcs = yield* init()
const branch = yield* vcs.defaultBranch()
expect(branch).toBe("main")
}),
{ git: true },
)
it.instance(
"defaultBranch() uses init.defaultBranch when available",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* git(test.directory, ["branch", "-M", "trunk"])
yield* git(test.directory, ["config", "init.defaultBranch", "trunk"])
const vcs = yield* init()
const branch = yield* vcs.defaultBranch()
expect(branch).toBe("trunk")
}),
{ git: true },
)
worktreeIt.live("detects current branch from the active worktree", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const wt = yield* tmpdirScoped()
yield* git(tmp, ["branch", "-M", "main"])
const dir = path.join(wt, "feature")
yield* git(tmp, ["worktree", "add", "-b", "feature/test", dir, "HEAD"])
const [branch, base] = yield* Effect.gen(function* () {
const vcs = yield* init()
return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
}).pipe(provideInstance(dir))
expect(branch).toBeDefined()
expect(branch).toBe("feature/test")
expect(base).toBe("main")
}),
)
it.instance(
"diff('git') returns uncommitted changes",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* write(path.join(test.directory, "file.txt"), "original\n")
yield* git(test.directory, ["add", "."])
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"])
yield* write(path.join(test.directory, "file.txt"), "changed\n")
const vcs = yield* init()
const diff = yield* vcs.diff("git")
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: "file.txt",
status: "modified",
}),
]),
)
expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git")
}),
{ git: true },
)
it.instance(
"diff('git') handles special filenames",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* write(path.join(test.directory, weird), "hello\n")
const vcs = yield* init()
const diff = yield* vcs.diff("git")
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: weird,
status: "added",
}),
]),
)
}),
{ git: true },
)
it.instance(
"diff('git') keeps batched patches aligned for type changes",
() =>
Effect.gen(function* () {
if (process.platform === "win32") return
const test = yield* TestInstance
yield* write(path.join(test.directory, "a.txt"), "old\n")
yield* write(path.join(test.directory, "b.txt"), "old\n")
yield* git(test.directory, ["add", "."])
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add files"])
yield* remove(path.join(test.directory, "a.txt"))
yield* symlink("target", path.join(test.directory, "a.txt"))
yield* write(path.join(test.directory, "b.txt"), "new\n")
const vcs = yield* init()
const diff = yield* vcs.diff("git")
const a = diff.find((item) => item.file === "a.txt")
const b = diff.find((item) => item.file === "b.txt")
expect(a?.patch).toContain("deleted file mode")
expect(a?.patch).toContain("new file mode")
expect(b?.patch).toContain("+new")
}),
{ git: true },
)
it.instance(
"diff('git') keeps carriage returns inside patch hunks",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* write(path.join(test.directory, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n")
yield* git(test.directory, ["add", "."])
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"])
yield* write(path.join(test.directory, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n")
const vcs = yield* init()
const diff = yield* vcs.diff("git")
const file = diff.find((item) => item.file === "file.txt")
expect(file?.patch).toContain(" same\rdiff --git inside")
expect(file?.patch).toContain("-delete")
expect(() => parsePatch(file?.patch ?? "")).not.toThrow()
}),
{ git: true },
20_000,
)
it.instance(
"diff('branch') returns changes against default branch",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* git(test.directory, ["branch", "-M", "main"])
yield* git(test.directory, ["checkout", "-b", "feature/test"])
yield* write(path.join(test.directory, "branch.txt"), "hello\n")
yield* git(test.directory, ["add", "."])
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "branch file"])
const vcs = yield* init()
const diff = yield* vcs.diff("branch")
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: "branch.txt",
status: "added",
}),
]),
)
}),
{ git: true },
)
})

View File

@@ -0,0 +1,126 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import * as fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Worktree } from "../../src/worktree"
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.instance : it.instance.skip
describe("Worktree.remove", () => {
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}`
const dir = path.join(root, "..", name)
yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet())
yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet())
const real = (yield* Effect.promise(() => $`which git`.quiet().text())).trim()
expect(real).toBeTruthy()
const bin = path.join(root, "bin")
const shim = path.join(bin, "git")
yield* Effect.promise(() => fs.mkdir(bin, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
shim,
[
"#!/bin/bash",
`REAL_GIT=${JSON.stringify(real)}`,
'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
' "$REAL_GIT" "$@" >/dev/null 2>&1',
' echo "fatal: failed to remove worktree: Directory not empty" >&2',
" exit 1",
"fi",
'exec "$REAL_GIT" "$@"',
].join("\n"),
),
)
yield* Effect.promise(() => fs.chmod(shim, 0o755))
const prev = yield* Effect.acquireRelease(
Effect.sync(() => {
const prev = process.env.PATH ?? ""
process.env.PATH = `${bin}${path.delimiter}${prev}`
return prev
}),
(prev) =>
Effect.sync(() => {
process.env.PATH = prev
}),
)
void prev
const ok = yield* svc.remove({ directory: dir })
expect(ok).toBe(true)
expect(
yield* Effect.promise(() =>
fs
.stat(dir)
.then(() => true)
.catch(() => false),
),
).toBe(false)
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(root).quiet().text())
expect(list).not.toContain(`worktree ${dir}`)
const ref = yield* Effect.promise(() =>
$`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
)
expect(ref.exitCode).not.toBe(0)
}),
{ git: true },
)
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}`
const dir = path.join(root, "..", name)
yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet())
yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet())
yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(dir).quiet())
yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow())
yield* Effect.promise(() => Bun.write(path.join(dir, "tracked.txt"), "next\n"))
yield* Effect.promise(() => $`git diff`.cwd(dir).quiet())
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow())
expect(before.exitCode).toBe(0)
const ok = yield* svc.remove({ directory: dir })
expect(ok).toBe(true)
expect(
yield* Effect.promise(() =>
fs
.stat(dir)
.then(() => true)
.catch(() => false),
),
).toBe(false)
const ref = yield* Effect.promise(() =>
$`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
)
expect(ref.exitCode).not.toBe(0)
}),
{ git: true },
)
})

View File

@@ -0,0 +1,320 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { FSUtil } from "@opencode-ai/core/fs-util"
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 { Worktree } from "../../src/worktree"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(
Layer.mergeAll(Worktree.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer),
)
const wintest = process.platform !== "win32" ? it.instance : it.instance.skip
function normalize(input: string) {
return input.replace(/\\/g, "/").toLowerCase()
}
const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () {
const ready = yield* Deferred.make<{ name: string; branch?: string }>()
const on = (evt: GlobalEvent) => {
if (evt.payload.type !== Worktree.Event.Ready.type) return
Deferred.doneUnsafe(ready, Effect.succeed(evt.payload.properties))
}
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
return yield* Deferred.await(ready).pipe(
Effect.timeoutOrElse({
duration: "10 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")),
}),
)
})
const removeCreatedWorktree = (directory: string) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ok = yield* svc.remove({ directory })
if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`))
})
const withCreatedWorktree = <A, E, R>(
input: Parameters<Worktree.Interface["create"]>[0],
use: (created: { info: Worktree.Info; ready: { name: string; branch?: string } }) => Effect.Effect<A, E, R>,
) =>
Effect.acquireUseRelease(
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ready = yield* waitReady().pipe(Effect.forkScoped)
const info = yield* svc.create(input)
const props = yield* Fiber.join(ready)
return { info, ready: props }
}),
use,
({ info }) => removeCreatedWorktree(info.directory),
)
const git = Effect.fn("WorktreeTest.git")(function* (cwd: string, args: string[]) {
const service = yield* Git.Service
const result = yield* service.run(args, { cwd })
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`)
return result.text()
})
const gitResult = Effect.fn("WorktreeTest.gitResult")(function* (cwd: string, args: string[]) {
const service = yield* Git.Service
return yield* service.run(args, { cwd })
})
describe("Worktree", () => {
afterEach(() => disposeAllInstances())
describe("makeWorktreeInfo", () => {
it.instance(
"returns info with name, branch, and directory",
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo()
expect(info.name).toBeDefined()
expect(typeof info.name).toBe("string")
expect(info.branch).toBe(`opencode/${info.name}`)
expect(info.directory).toContain(info.name)
}),
{ git: true },
)
it.instance(
"uses provided name as base",
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "my-feature" })
expect(info.name).toBe("my-feature")
expect(info.branch).toBe("opencode/my-feature")
}),
{ git: true },
)
it.instance(
"slugifies the provided name",
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" })
expect(info.name).toBe("my-feature-branch")
}),
{ git: true },
)
it.instance(
"omits branch for detached info",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
yield* git(test.directory, ["branch", "opencode/my-feature"])
const info = yield* svc.makeWorktreeInfo({ name: "my-feature", detached: true })
expect(info.name).toBe("my-feature")
expect(info.branch).toBeUndefined()
}),
{ git: true },
)
it.instance("fails with NotGitError for non-git directories", () =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.makeWorktreeInfo())
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
expect(error).toBeInstanceOf(Worktree.NotGitError)
if (error instanceof Worktree.NotGitError) expect(error._tag).toBe("WorktreeNotGitError")
}
}),
)
wintest(
"creates detached git worktree when info has no branch",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true })
const ready = yield* waitReady().pipe(Effect.forkScoped)
yield* svc.createFromInfo(info)
const list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
const normalizedList = normalize(list)
const normalizedDir = normalize(info.directory)
expect(normalizedList).toContain(normalizedDir)
const branch = yield* gitResult(info.directory, ["symbolic-ref", "-q", "--short", "HEAD"])
expect(branch.exitCode).not.toBe(0)
const props = yield* Fiber.join(ready)
expect(props.name).toBe(info.name)
expect(props.branch).toBeUndefined()
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
)
})
describe("create + remove lifecycle", () => {
it.instance(
"create returns worktree info and remove cleans up",
() =>
withCreatedWorktree(undefined, ({ info }) =>
Effect.gen(function* () {
expect(info.name).toBeDefined()
expect(info.branch ?? "").toStartWith("opencode/")
expect(info.directory).toBeDefined()
}),
),
{ git: true },
)
it.instance(
"create returns after setup and fires Event.Ready after bootstrap",
() =>
withCreatedWorktree(undefined, ({ info, ready }) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
expect(info.name).toBeDefined()
expect(info.branch ?? "").toStartWith("opencode/")
expect(ready.name).toBe(info.name)
expect(ready.branch).toBe(info.branch)
const list = yield* svc.list()
expect(list).toContainEqual(expect.objectContaining({ name: info.name, branch: info.branch }))
}),
),
{ git: true },
)
it.instance(
"lists the active linked worktree but not the project checkout",
() =>
withCreatedWorktree(undefined, ({ info }) =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const list = yield* svc.list().pipe(provideInstance(info.directory))
expect(list.map((item) => item.name)).toContain(info.name)
expect(list.map((item) => item.name)).not.toContain(path.basename(test.directory).toLowerCase())
}),
),
{ git: true },
)
it.instance(
"create with custom name",
() =>
withCreatedWorktree({ name: "test-workspace" }, ({ info }) =>
Effect.gen(function* () {
expect(info.name).toBe("test-workspace")
expect(info.branch).toBe("opencode/test-workspace")
}),
),
{ git: true },
)
})
describe("createFromInfo", () => {
wintest(
"creates git worktree and boots asynchronously",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" })
const ready = yield* waitReady().pipe(Effect.forkScoped)
yield* svc.createFromInfo(info)
const list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir)
yield* Fiber.join(ready)
yield* removeCreatedWorktree(info.directory)
}),
{ git: true },
)
})
describe("list", () => {
it.instance(
"uses parent folder name when worktree basename matches the primary worktree",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* FSUtil.Service
const svc = yield* Worktree.Service
const parent = path.join(path.dirname(test.directory), `${path.basename(test.directory)}-parent`)
const target = path.join(parent, path.basename(test.directory))
const branch = `same-basename-list-${Date.now()}`
yield* fs.ensureDir(parent)
yield* git(test.directory, ["worktree", "add", "-b", branch, target])
const list = yield* svc.list()
const directory = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({
name: path.basename(parent),
branch,
directory: normalize(directory),
})
yield* svc.remove({ directory: target })
}),
{ git: true },
)
})
describe("remove edge cases", () => {
it.instance(
"remove non-existent directory succeeds silently",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const ok = yield* svc.remove({ directory: path.join(test.directory, "does-not-exist") })
expect(ok).toBe(true)
}),
{ git: true },
)
it.instance("fails with NotGitError for non-git directories", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.remove({ directory: path.join(test.directory, "fake") }))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
expect(error).toBeInstanceOf(Worktree.NotGitError)
if (error instanceof Worktree.NotGitError) expect(error._tag).toBe("WorktreeNotGitError")
}
}),
)
})
})