| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324 |
- import { afterEach, describe, expect } from "bun:test"
- import path from "path"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { FSUtil } from "@kirincode-ai/core/fs-util"
- import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
- import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
- import { Git } from "../../src/git"
- import { InstanceBootstrap } from "../../src/project/bootstrap"
- import { InstanceStore } from "../../src/project/instance-store"
- import { Worktree } from "../../src/worktree"
- import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- const it = testEffect(
- LayerNode.compile(LayerNode.group([Worktree.node, FSUtil.node, Git.node]), [
- [InstanceStore.bootstrapNode, InstanceBootstrap.node],
- ]),
- )
- 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")
- }
- }),
- )
- })
- })
|