diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 6cc4c20a48..2bfb7debaa 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -1,4 +1,5 @@ import z from "zod" +import { AppRuntime } from "@/effect/app-runtime" import { Worktree } from "@/worktree" import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" @@ -12,7 +13,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = { name: "Worktree", description: "Create a git worktree", async configure(info) { - const worktree = await Worktree.makeWorktreeInfo(undefined) + const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo())) return { ...info, name: worktree.name, @@ -22,15 +23,19 @@ export const WorktreeAdaptor: WorkspaceAdaptor = { }, async create(info) { const config = WorktreeConfig.parse(info) - await Worktree.createFromInfo({ - name: config.name, - directory: config.directory, - branch: config.branch, - }) + await AppRuntime.runPromise( + Worktree.Service.use((svc) => + svc.createFromInfo({ + name: config.name, + directory: config.directory, + branch: config.branch, + }), + ), + ) }, async remove(info) { const config = WorktreeConfig.parse(info) - await Worktree.remove({ directory: config.directory }) + await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory }))) }, target(info) { const config = WorktreeConfig.parse(info) diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index ca8b89fa6a..9d2378a8d3 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -254,7 +254,7 @@ export const ExperimentalRoutes = lazy(() => validator("json", Worktree.CreateInput.optional()), async (c) => { const body = c.req.valid("json") - const worktree = await Worktree.create(body) + const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body))) return c.json(worktree) }, ) @@ -301,7 +301,7 @@ export const ExperimentalRoutes = lazy(() => validator("json", Worktree.RemoveInput), async (c) => { const body = c.req.valid("json") - await Worktree.remove(body) + await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body))) await Project.removeSandbox(Instance.project.id, body.directory) return c.json(true) }, @@ -327,7 +327,7 @@ export const ExperimentalRoutes = lazy(() => validator("json", Worktree.ResetInput), async (c) => { const body = c.req.valid("json") - await Worktree.reset(body) + await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body))) return c.json(true) }, ) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index b6430fa6c7..18240524a1 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -18,7 +18,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@/filesystem" import { BootstrapRuntime } from "@/effect/bootstrap-runtime" -import { makeRuntime } from "@/effect/run-service" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" @@ -598,25 +597,4 @@ export namespace Worktree { Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), ) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function makeWorktreeInfo(name?: string) { - return runPromise((svc) => svc.makeWorktreeInfo(name)) - } - - export async function createFromInfo(info: Info, startCommand?: string) { - return runPromise((svc) => svc.createFromInfo(info, startCommand)) - } - - export async function create(input?: CreateInput) { - return runPromise((svc) => svc.create(input)) - } - - export async function remove(input: RemoveInput) { - return runPromise((svc) => svc.remove(input)) - } - - export async function reset(input: ResetInput) { - return runPromise((svc) => svc.reset(input)) - } } diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index a6b5bb7c34..5fb2beb286 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -1,96 +1,126 @@ -import { describe, expect, test } from "bun:test" import { $ } from "bun" -import fs from "fs/promises" +import { describe, expect } from "bun:test" +import * as fs from "fs/promises" import path from "path" -import { Instance } from "../../src/project/instance" +import { Effect, Layer } from "effect" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { Worktree } from "../../src/worktree" -import { Filesystem } from "../../src/util/filesystem" -import { tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -const wintest = process.platform === "win32" ? test : test.skip +const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const wintest = process.platform === "win32" ? it.live : it.live.skip describe("Worktree.remove", () => { - test("continues when git remove exits non-zero after detaching", async () => { - await using tmp = await tmpdir({ git: true }) - const root = tmp.path - const name = `remove-regression-${Date.now().toString(36)}` - const branch = `opencode/${name}` - const dir = path.join(root, "..", name) + it.live("continues when git remove exits non-zero after detaching", () => + provideTmpdirInstance( + (root) => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const name = `remove-regression-${Date.now().toString(36)}` + const branch = `opencode/${name}` + const dir = path.join(root, "..", name) - await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet() - await $`git reset --hard`.cwd(dir).quiet() + 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 = (await $`which git`.quiet().text()).trim() - expect(real).toBeTruthy() + 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") - await fs.mkdir(bin, { recursive: true }) - await 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"), - ) - await fs.chmod(shim, 0o755) + 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 = process.env.PATH ?? "" - process.env.PATH = `${bin}${path.delimiter}${prev}` + 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 = await (async () => { - try { - return await Instance.provide({ - directory: root, - fn: () => Worktree.remove({ directory: dir }), - }) - } finally { - process.env.PATH = prev - } - })() + const ok = yield* svc.remove({ directory: dir }) - expect(ok).toBe(true) - expect(await Filesystem.exists(dir)).toBe(false) + expect(ok).toBe(true) + expect( + yield* Effect.promise(() => + fs + .stat(dir) + .then(() => true) + .catch(() => false), + ), + ).toBe(false) - const list = await $`git worktree list --porcelain`.cwd(root).quiet().text() - expect(list).not.toContain(`worktree ${dir}`) + const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(root).quiet().text()) + expect(list).not.toContain(`worktree ${dir}`) - const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow() - expect(ref.exitCode).not.toBe(0) - }) + 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", async () => { - await using tmp = await tmpdir({ git: true }) - const root = tmp.path - const name = `remove-fsmonitor-${Date.now().toString(36)}` - const branch = `opencode/${name}` - const dir = path.join(root, "..", name) + wintest("stops fsmonitor before removing a worktree", () => + provideTmpdirInstance( + (root) => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const name = `remove-fsmonitor-${Date.now().toString(36)}` + const branch = `opencode/${name}` + const dir = path.join(root, "..", name) - await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet() - await $`git reset --hard`.cwd(dir).quiet() - await $`git config core.fsmonitor true`.cwd(dir).quiet() - await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow() - await Bun.write(path.join(dir, "tracked.txt"), "next\n") - await $`git diff`.cwd(dir).quiet() + 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 = await $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow() - expect(before.exitCode).toBe(0) + const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow()) + expect(before.exitCode).toBe(0) - const ok = await Instance.provide({ - directory: root, - fn: () => Worktree.remove({ directory: dir }), - }) + const ok = yield* svc.remove({ directory: dir }) - expect(ok).toBe(true) - expect(await Filesystem.exists(dir)).toBe(false) + expect(ok).toBe(true) + expect( + yield* Effect.promise(() => + fs + .stat(dir) + .then(() => true) + .catch(() => false), + ), + ).toBe(false) - const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow() - expect(ref.exitCode).not.toBe(0) - }) + 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 }, + ), + ) }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index dd91c772aa..c0fe635514 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -1,16 +1,16 @@ import { $ } from "bun" -import { afterEach, describe, expect, test } from "bun:test" - -const wintest = process.platform !== "win32" ? test : test.skip -import fs from "fs/promises" +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 { tmpdir } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -function withInstance(directory: string, fn: () => Promise) { - return Instance.provide({ directory, fn }) -} +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() @@ -40,134 +40,175 @@ describe("Worktree", () => { afterEach(() => Instance.disposeAll()) describe("makeWorktreeInfo", () => { - test("returns info with name, branch, and directory", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("returns info with name, branch, and directory", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const info = yield* svc.makeWorktreeInfo() - const info = await withInstance(tmp.path, () => Worktree.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 }, + ), + ) - expect(info.name).toBeDefined() - expect(typeof info.name).toBe("string") - expect(info.branch).toBe(`opencode/${info.name}`) - expect(info.directory).toContain(info.name) - }) + it.live("uses provided name as base", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const info = yield* svc.makeWorktreeInfo("my-feature") - test("uses provided name as base", async () => { - await using tmp = await tmpdir({ git: true }) + expect(info.name).toBe("my-feature") + expect(info.branch).toBe("opencode/my-feature") + }), + { git: true }, + ), + ) - const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("my-feature")) + 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") - expect(info.branch).toBe("opencode/my-feature") - }) + expect(info.name).toBe("my-feature-branch") + }), + { git: true }, + ), + ) - test("slugifies the provided name", async () => { - await using tmp = await tmpdir({ 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()) - const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("My Feature Branch!")) - - expect(info.name).toBe("my-feature-branch") - }) - - test("throws NotGitError for non-git directories", async () => { - await using tmp = await tmpdir() - - await expect(withInstance(tmp.path, () => Worktree.makeWorktreeInfo())).rejects.toThrow("WorktreeNotGitError") - }) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) + }), + ), + ) }) describe("create + remove lifecycle", () => { - test("create returns worktree info and remove cleans up", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("create returns worktree info and remove cleans up", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const info = yield* svc.create() - const info = await withInstance(tmp.path, () => Worktree.create()) + expect(info.name).toBeDefined() + expect(info.branch).toStartWith("opencode/") + expect(info.directory).toBeDefined() - expect(info.name).toBeDefined() - expect(info.branch).toStartWith("opencode/") - expect(info.directory).toBeDefined() + yield* Effect.promise(() => Bun.sleep(1000)) - // Wait for bootstrap to complete - await Bun.sleep(1000) + const ok = yield* svc.remove({ directory: info.directory }) + expect(ok).toBe(true) + }), + { git: true }, + ), + ) - const ok = await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory })) - expect(ok).toBe(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() - test("create returns after setup and fires Event.Ready after bootstrap", async () => { - await using tmp = await tmpdir({ git: true }) - const ready = waitReady() + expect(info.name).toBeDefined() + expect(info.branch).toStartWith("opencode/") - const info = await withInstance(tmp.path, () => Worktree.create()) + 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)) - // create returns before bootstrap completes, but the worktree already exists - expect(info.name).toBeDefined() - expect(info.branch).toStartWith("opencode/") + const props = yield* Effect.promise(() => ready) + expect(props.name).toBe(info.name) + expect(props.branch).toBe(info.branch) - const text = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text() - const dir = await fs.realpath(info.directory).catch(() => info.directory) - expect(normalize(text)).toContain(normalize(dir)) + yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory)) + yield* Effect.promise(() => Bun.sleep(100)) + yield* svc.remove({ directory: info.directory }) + }), + { git: true }, + ), + ) - // Event.Ready fires after bootstrap finishes in the background - const props = await ready - expect(props.name).toBe(info.name) - expect(props.branch).toBe(info.branch) + 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" }) - // Cleanup - await withInstance(info.directory, () => Instance.dispose()) - await Bun.sleep(100) - await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory })) - }) + expect(info.name).toBe("test-workspace") + expect(info.branch).toBe("opencode/test-workspace") - test("create with custom name", async () => { - await using tmp = await tmpdir({ git: true }) - const ready = waitReady() - - const info = await withInstance(tmp.path, () => Worktree.create({ name: "test-workspace" })) - - expect(info.name).toBe("test-workspace") - expect(info.branch).toBe("opencode/test-workspace") - - // Cleanup - await ready - await withInstance(info.directory, () => Instance.dispose()) - await Bun.sleep(100) - await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory })) - }) + 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", async () => { - await using tmp = await tmpdir({ git: true }) + 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 info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("from-info-test")) - await withInstance(tmp.path, () => Worktree.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) - // Worktree should exist in git (normalize slashes for Windows) - const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text() - const normalizedList = list.replace(/\\/g, "/") - const normalizedDir = info.directory.replace(/\\/g, "/") - expect(normalizedList).toContain(normalizedDir) - - // Cleanup - await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory })) - }) + yield* svc.remove({ directory: info.directory }) + }), + { git: true }, + ), + ) }) describe("remove edge cases", () => { - test("remove non-existent directory succeeds silently", async () => { - await using tmp = await tmpdir({ git: true }) + 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 }, + ), + ) - const ok = await withInstance(tmp.path, () => - Worktree.remove({ directory: path.join(tmp.path, "does-not-exist") }), - ) - expect(ok).toBe(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" })) - test("throws NotGitError for non-git directories", async () => { - await using tmp = await tmpdir() - - await expect(withInstance(tmp.path, () => Worktree.remove({ directory: "/tmp/fake" }))).rejects.toThrow( - "WorktreeNotGitError", - ) - }) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) + }), + ), + ) }) })