test: migrate worktree tests to effect runner (#27177)

This commit is contained in:
Kit Langton
2026-05-12 19:55:07 -04:00
committed by GitHub
parent 1b6599f411
commit d8c8322ddf

View File

@@ -1,302 +1,292 @@
import { $ } from "bun"
import { afterEach, describe, expect } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import * as fs from "fs/promises"
import path from "path" import path from "path"
import { Cause, Effect, Exit, Layer } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Instance } from "../../src/project/instance" import { Cause, Deferred, Effect, Exit, Layer } from "effect"
import { WithInstance } from "../../src/project/with-instance" import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { InstanceRuntime } from "../../src/project/instance-runtime" import { Git } from "../../src/git"
import { Worktree } from "../../src/worktree" import { Worktree } from "../../src/worktree"
import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) const it = testEffect(
const wintest = process.platform !== "win32" ? it.live : it.live.skip Layer.mergeAll(Worktree.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer),
)
const wintest = process.platform !== "win32" ? it.instance : it.instance.skip
function normalize(input: string) { function normalize(input: string) {
return input.replace(/\\/g, "/").toLowerCase() return input.replace(/\\/g, "/").toLowerCase()
} }
async function waitReady() { const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () {
const { GlobalBus } = await import("../../src/bus/global") 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))
}
return await new Promise<{ name: string; branch?: string }>((resolve, reject) => { GlobalBus.on("event", on)
const timer = setTimeout(() => { yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
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 } } }) { return Deferred.await(ready).pipe(
if (evt.payload.type !== Worktree.Event.Ready.type) return Effect.race(
clearTimeout(timer) Effect.sleep("10 seconds").pipe(
GlobalBus.off("event", on) Effect.flatMap(() => Effect.fail(new Error("timed out waiting for worktree.ready"))),
resolve(evt.payload.properties) ),
} ),
)
})
GlobalBus.on("event", on) 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", () => { describe("Worktree", () => {
afterEach(() => disposeAllInstances()) afterEach(() => disposeAllInstances())
describe("makeWorktreeInfo", () => { describe("makeWorktreeInfo", () => {
it.live("returns info with name, branch, and directory", () => it.instance(
provideTmpdirInstance( "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.live("uses provided name as base", () =>
provideTmpdirInstance(
() =>
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.live("slugifies the provided name", () =>
provideTmpdirInstance(
() =>
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.live("omits branch for detached info", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
yield* Effect.promise(() => $`git branch opencode/my-feature`.cwd(dir).quiet())
const info = yield* svc.makeWorktreeInfo({ name: "my-feature", detached: true })
expect(info.name).toBe("my-feature")
expect(info.branch).toBeUndefined()
}),
{ git: true },
),
)
it.live("throws NotGitError for non-git directories", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.makeWorktreeInfo()) const info = yield* svc.makeWorktreeInfo()
expect(Exit.isFailure(exit)).toBe(true) expect(info.name).toBeDefined()
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) expect(typeof info.name).toBe("string")
expect(info.branch).toBe(`opencode/${info.name}`)
expect(info.directory).toContain(info.name)
}), }),
), { git: true },
) )
wintest("creates detached git worktree when info has no branch", () => it.instance(
provideTmpdirInstance( "uses provided name as base",
(dir) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true }) const info = yield* svc.makeWorktreeInfo({ name: "my-feature" })
const ready = waitReady()
yield* svc.createFromInfo(info)
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) expect(info.name).toBe("my-feature")
const normalizedList = normalize(list) expect(info.branch).toBe("opencode/my-feature")
const normalizedDir = normalize(info.directory) }),
expect(normalizedList).toContain(normalizedDir) { git: true },
)
const branch = yield* Effect.promise(() => it.instance(
$`git symbolic-ref -q --short HEAD`.cwd(info.directory).quiet().nothrow(), "slugifies the provided name",
) () =>
expect(branch.exitCode).not.toBe(0) Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" })
const props = yield* Effect.promise(() => ready) expect(info.name).toBe("my-feature-branch")
expect(props.name).toBe(info.name) }),
expect(props.branch).toBeUndefined() { git: true },
)
yield* svc.remove({ directory: info.directory }) it.instance(
}), "omits branch for detached info",
{ git: true }, () =>
), 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("throws 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)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
}),
)
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()
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* ready
expect(props.name).toBe(info.name)
expect(props.branch).toBeUndefined()
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
) )
}) })
describe("create + remove lifecycle", () => { describe("create + remove lifecycle", () => {
it.live("create returns worktree info and remove cleans up", () => it.instance(
provideTmpdirInstance( "create returns worktree info and remove cleans up",
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const svc = yield* Worktree.Service
const info = yield* svc.create() const ready = yield* waitReady()
const info = yield* svc.create()
expect(info.name).toBeDefined() expect(info.name).toBeDefined()
expect(info.branch ?? "").toStartWith("opencode/") expect(info.branch ?? "").toStartWith("opencode/")
expect(info.directory).toBeDefined() expect(info.directory).toBeDefined()
yield* Effect.promise(() => Bun.sleep(1000)) yield* ready
const ok = yield* svc.remove({ directory: info.directory }) const ok = yield* svc.remove({ directory: info.directory })
expect(ok).toBe(true) expect(ok).toBe(true)
}), }),
{ git: true }, { git: true },
),
) )
it.live("create returns after setup and fires Event.Ready after bootstrap", () => it.instance(
provideTmpdirInstance( "create returns after setup and fires Event.Ready after bootstrap",
(dir) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const test = yield* TestInstance
const ready = waitReady() const fs = yield* AppFileSystem.Service
const info = yield* svc.create() const svc = yield* Worktree.Service
const ready = yield* waitReady()
const info = yield* svc.create()
expect(info.name).toBeDefined() expect(info.name).toBeDefined()
expect(info.branch ?? "").toStartWith("opencode/") expect(info.branch ?? "").toStartWith("opencode/")
const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) const text = yield* git(test.directory, ["worktree", "list", "--porcelain"])
const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory)) const next = yield* fs.realPath(info.directory).pipe(Effect.catch(() => Effect.succeed(info.directory)))
expect(normalize(text)).toContain(normalize(next)) expect(normalize(text)).toContain(normalize(next))
const props = yield* Effect.promise(() => ready) const props = yield* ready
expect(props.name).toBe(info.name) expect(props.name).toBe(info.name)
expect(props.branch).toBe(info.branch) expect(props.branch).toBe(info.branch)
yield* Effect.promise(() => yield* svc.remove({ directory: info.directory })
WithInstance.provide({ }),
directory: info.directory, { git: true },
fn: () => InstanceRuntime.disposeInstance(Instance.current),
}),
)
yield* Effect.promise(() => Bun.sleep(100))
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
) )
it.live("create with custom name", () => it.instance(
provideTmpdirInstance( "create with custom name",
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const svc = yield* Worktree.Service
const ready = waitReady() const ready = yield* waitReady()
const info = yield* svc.create({ name: "test-workspace" }) const info = yield* svc.create({ name: "test-workspace" })
expect(info.name).toBe("test-workspace") expect(info.name).toBe("test-workspace")
expect(info.branch).toBe("opencode/test-workspace") expect(info.branch).toBe("opencode/test-workspace")
yield* Effect.promise(() => ready) yield* ready
yield* Effect.promise(() => yield* svc.remove({ directory: info.directory })
WithInstance.provide({ }),
directory: info.directory, { git: true },
fn: () => InstanceRuntime.disposeInstance(Instance.current),
}),
)
yield* Effect.promise(() => Bun.sleep(100))
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
) )
}) })
describe("createFromInfo", () => { describe("createFromInfo", () => {
wintest("creates git worktree and boots asynchronously", () => wintest(
provideTmpdirInstance( "creates git worktree and boots asynchronously",
(dir) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const test = yield* TestInstance
const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" }) const svc = yield* Worktree.Service
const ready = waitReady() const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" })
yield* svc.createFromInfo(info) const ready = yield* waitReady()
yield* svc.createFromInfo(info)
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) const list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
const normalizedList = list.replace(/\\/g, "/") const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/") const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir) expect(normalizedList).toContain(normalizedDir)
yield* Effect.promise(() => ready) yield* ready
yield* svc.remove({ directory: info.directory }) yield* svc.remove({ directory: info.directory })
}), }),
{ git: true }, { git: true },
),
) )
}) })
describe("list", () => { describe("list", () => {
it.live("uses parent folder name when worktree basename matches the primary worktree", () => it.instance(
provideTmpdirInstance( "uses parent folder name when worktree basename matches the primary worktree",
(dir) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const test = yield* TestInstance
const parent = path.join(path.dirname(dir), `${path.basename(dir)}-parent`) const fs = yield* AppFileSystem.Service
const target = path.join(parent, path.basename(dir)) const svc = yield* Worktree.Service
const branch = `same-basename-list-${Date.now()}` 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* Effect.promise(() => fs.mkdir(parent, { recursive: true })) yield* fs.ensureDir(parent)
yield* Effect.promise(() => $`git worktree add -b ${branch} ${target}`.cwd(dir).quiet()) yield* git(test.directory, ["worktree", "add", "-b", branch, target])
const list = yield* svc.list() const list = yield* svc.list()
const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target)) const directory = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({ expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({
name: path.basename(parent), name: path.basename(parent),
branch, branch,
directory: normalize(directory), directory: normalize(directory),
}) })
yield* svc.remove({ directory: target }) yield* svc.remove({ directory: target })
}), }),
{ git: true }, { git: true },
),
) )
}) })
describe("remove edge cases", () => { describe("remove edge cases", () => {
it.live("remove non-existent directory succeeds silently", () => it.instance(
provideTmpdirInstance( "remove non-existent directory succeeds silently",
(dir) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const test = yield* TestInstance
const ok = yield* svc.remove({ directory: path.join(dir, "does-not-exist") }) const svc = yield* Worktree.Service
expect(ok).toBe(true) const ok = yield* svc.remove({ directory: path.join(test.directory, "does-not-exist") })
}), expect(ok).toBe(true)
{ git: true }, }),
), { git: true },
) )
it.live("throws NotGitError for non-git directories", () => it.instance("throws NotGitError for non-git directories", () =>
provideTmpdirInstance(() => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
const svc = yield* Worktree.Service const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.remove({ directory: "/tmp/fake" })) const exit = yield* Effect.exit(svc.remove({ directory: path.join(test.directory, "fake") }))
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
}), }),
),
) )
}) })
}) })