feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
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