| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- import { $ } from "bun"
- import { afterEach, describe, expect } from "bun:test"
- import * as fs from "fs/promises"
- import path from "path"
- import { Cause, Effect, Exit, Layer } from "effect"
- import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
- import { Instance } from "../../src/project/instance"
- import { Worktree } from "../../src/worktree"
- import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
- const wintest = process.platform !== "win32" ? it.live : it.live.skip
- function normalize(input: string) {
- return input.replace(/\\/g, "/").toLowerCase()
- }
- async function waitReady() {
- const { GlobalBus } = await import("../../src/bus/global")
- return await new Promise<{ name: string; branch: string }>((resolve, reject) => {
- const timer = setTimeout(() => {
- GlobalBus.off("event", on)
- reject(new Error("timed out waiting for worktree.ready"))
- }, 10_000)
- function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch: string } } }) {
- if (evt.payload.type !== Worktree.Event.Ready.type) return
- clearTimeout(timer)
- GlobalBus.off("event", on)
- resolve(evt.payload.properties)
- }
- GlobalBus.on("event", on)
- })
- }
- describe("Worktree", () => {
- afterEach(() => Instance.disposeAll())
- describe("makeWorktreeInfo", () => {
- it.live("returns info with name, branch, and directory", () =>
- provideTmpdirInstance(
- () =>
- 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.live("uses provided name as base", () =>
- provideTmpdirInstance(
- () =>
- Effect.gen(function* () {
- const svc = yield* Worktree.Service
- const info = yield* svc.makeWorktreeInfo("my-feature")
- expect(info.name).toBe("my-feature")
- expect(info.branch).toBe("opencode/my-feature")
- }),
- { git: true },
- ),
- )
- it.live("slugifies the provided name", () =>
- provideTmpdirInstance(
- () =>
- Effect.gen(function* () {
- const svc = yield* Worktree.Service
- const info = yield* svc.makeWorktreeInfo("My Feature Branch!")
- expect(info.name).toBe("my-feature-branch")
- }),
- { git: true },
- ),
- )
- it.live("throws NotGitError for non-git directories", () =>
- provideTmpdirInstance(() =>
- 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)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
- }),
- ),
- )
- })
- describe("create + remove lifecycle", () => {
- it.live("create returns worktree info and remove cleans up", () =>
- provideTmpdirInstance(
- () =>
- Effect.gen(function* () {
- const svc = yield* Worktree.Service
- const info = yield* svc.create()
- expect(info.name).toBeDefined()
- expect(info.branch).toStartWith("opencode/")
- expect(info.directory).toBeDefined()
- yield* Effect.promise(() => Bun.sleep(1000))
- const ok = yield* svc.remove({ directory: info.directory })
- expect(ok).toBe(true)
- }),
- { git: true },
- ),
- )
- it.live("create returns after setup and fires Event.Ready after bootstrap", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const svc = yield* Worktree.Service
- const ready = waitReady()
- const info = yield* svc.create()
- expect(info.name).toBeDefined()
- expect(info.branch).toStartWith("opencode/")
- const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
- const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory))
- expect(normalize(text)).toContain(normalize(next))
- const props = yield* Effect.promise(() => ready)
- expect(props.name).toBe(info.name)
- expect(props.branch).toBe(info.branch)
- yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
- yield* Effect.promise(() => Bun.sleep(100))
- yield* svc.remove({ directory: info.directory })
- }),
- { git: true },
- ),
- )
- it.live("create with custom name", () =>
- provideTmpdirInstance(
- () =>
- Effect.gen(function* () {
- const svc = yield* Worktree.Service
- const ready = waitReady()
- const info = yield* svc.create({ name: "test-workspace" })
- expect(info.name).toBe("test-workspace")
- expect(info.branch).toBe("opencode/test-workspace")
- yield* Effect.promise(() => ready)
- yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
- yield* Effect.promise(() => Bun.sleep(100))
- yield* svc.remove({ directory: info.directory })
- }),
- { git: true },
- ),
- )
- })
- describe("createFromInfo", () => {
- wintest("creates and bootstraps git worktree", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const svc = yield* Worktree.Service
- const info = yield* svc.makeWorktreeInfo("from-info-test")
- yield* svc.createFromInfo(info)
- const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
- const normalizedList = list.replace(/\\/g, "/")
- const normalizedDir = info.directory.replace(/\\/g, "/")
- expect(normalizedList).toContain(normalizedDir)
- yield* svc.remove({ directory: info.directory })
- }),
- { git: true },
- ),
- )
- })
- describe("remove edge cases", () => {
- it.live("remove non-existent directory succeeds silently", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const svc = yield* Worktree.Service
- const ok = yield* svc.remove({ directory: path.join(dir, "does-not-exist") })
- expect(ok).toBe(true)
- }),
- { git: true },
- ),
- )
- it.live("throws NotGitError for non-git directories", () =>
- provideTmpdirInstance(() =>
- Effect.gen(function* () {
- const svc = yield* Worktree.Service
- const exit = yield* Effect.exit(svc.remove({ directory: "/tmp/fake" }))
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
- }),
- ),
- )
- })
- })
|