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:
110
packages/opencode/test/project/instance-bootstrap.test.ts
Normal file
110
packages/opencode/test/project/instance-bootstrap.test.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
245
packages/opencode/test/project/instance.test.ts
Normal file
245
packages/opencode/test/project/instance.test.ts
Normal 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])
|
||||
}),
|
||||
)
|
||||
})
|
||||
167
packages/opencode/test/project/migrate-global.test.ts
Normal file
167
packages/opencode/test/project/migrate-global.test.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
169
packages/opencode/test/project/project-directory.test.ts
Normal file
169
packages/opencode/test/project/project-directory.test.ts
Normal 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" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
815
packages/opencode/test/project/project.test.ts
Normal file
815
packages/opencode/test/project/project.test.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
336
packages/opencode/test/project/vcs.test.ts
Normal file
336
packages/opencode/test/project/vcs.test.ts
Normal 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 },
|
||||
)
|
||||
})
|
||||
126
packages/opencode/test/project/worktree-remove.test.ts
Normal file
126
packages/opencode/test/project/worktree-remove.test.ts
Normal 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 },
|
||||
)
|
||||
})
|
||||
320
packages/opencode/test/project/worktree.test.ts
Normal file
320
packages/opencode/test/project/worktree.test.ts
Normal 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")
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user