diff options
| author | Kit Langton <[email protected]> | 2026-04-13 21:23:15 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-13 21:23:15 -0400 |
| commit | 36745caa2a406bfb817e775c8285efc29d4fba26 (patch) | |
| tree | 2a7fca6b37820fa11973e139c3ef47037af126d7 /packages | |
| parent | c2403d0f155b55ad067e742ed6f091312445d24e (diff) | |
| download | opencode-36745caa2a406bfb817e775c8285efc29d4fba26.tar.gz opencode-36745caa2a406bfb817e775c8285efc29d4fba26.zip | |
refactor(worktree): remove async facade exports (#22369)
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/opencode/src/control-plane/adaptors/worktree.ts | 19 | ||||
| -rw-r--r-- | packages/opencode/src/server/instance/experimental.ts | 6 | ||||
| -rw-r--r-- | packages/opencode/src/worktree/index.ts | 22 | ||||
| -rw-r--r-- | packages/opencode/test/project/worktree-remove.test.ts | 208 | ||||
| -rw-r--r-- | packages/opencode/test/project/worktree.test.ts | 297 |
5 files changed, 303 insertions, 249 deletions
diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 6cc4c20a4..2bfb7deba 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 ca8b89fa6..9d2378a8d 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 b6430fa6c..18240524a 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 a6b5bb7c3..5fb2beb28 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) - - await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet() - await $`git reset --hard`.cwd(dir).quiet() - - const real = (await $`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 prev = process.env.PATH ?? "" - process.env.PATH = `${bin}${path.delimiter}${prev}` - - const ok = await (async () => { - try { - return await Instance.provide({ - directory: root, - fn: () => Worktree.remove({ directory: dir }), - }) - } finally { - process.env.PATH = prev - } - })() - - expect(ok).toBe(true) - expect(await Filesystem.exists(dir)).toBe(false) - - const list = await $`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) - }) - - 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) - - 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() - - const before = await $`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 }), - }) - - expect(ok).toBe(true) - expect(await Filesystem.exists(dir)).toBe(false) - - const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow() - expect(ref.exitCode).not.toBe(0) - }) + 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) + + 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 = (yield* Effect.promise(() => $`which git`.quiet().text())).trim() + expect(real).toBeTruthy() + + 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 = 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 = yield* svc.remove({ directory: dir }) + + expect(ok).toBe(true) + expect( + yield* Effect.promise(() => + fs + .stat(dir) + .then(() => true) + .catch(() => false), + ), + ).toBe(false) + + const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(root).quiet().text()) + expect(list).not.toContain(`worktree ${dir}`) + + 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", () => + 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) + + 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 = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow()) + expect(before.exitCode).toBe(0) + + const ok = yield* svc.remove({ directory: dir }) + + expect(ok).toBe(true) + expect( + yield* Effect.promise(() => + fs + .stat(dir) + .then(() => true) + .catch(() => false), + ), + ).toBe(false) + + 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 dd91c772a..c0fe63551 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<any>) { - 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 }) - - 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) - }) - - test("uses provided name as base", async () => { - await using tmp = await tmpdir({ git: true }) - - const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("my-feature")) - - expect(info.name).toBe("my-feature") - expect(info.branch).toBe("opencode/my-feature") - }) - - test("slugifies the provided name", async () => { - await using tmp = await tmpdir({ git: true }) - - 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") - }) + 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", () => { - test("create returns worktree info and remove cleans up", async () => { - await using tmp = await tmpdir({ git: true }) - - const info = await withInstance(tmp.path, () => Worktree.create()) - - expect(info.name).toBeDefined() - expect(info.branch).toStartWith("opencode/") - expect(info.directory).toBeDefined() - - // Wait for bootstrap to complete - await Bun.sleep(1000) - - const ok = await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory })) - expect(ok).toBe(true) - }) - - test("create returns after setup and fires Event.Ready after bootstrap", async () => { - await using tmp = await tmpdir({ git: true }) - const ready = waitReady() - - const info = await withInstance(tmp.path, () => Worktree.create()) - - // create returns before bootstrap completes, but the worktree already exists - expect(info.name).toBeDefined() - expect(info.branch).toStartWith("opencode/") - - 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)) - - // 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) - - // Cleanup - await withInstance(info.directory, () => Instance.dispose()) - await Bun.sleep(100) - await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory })) - }) - - 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 })) - }) + 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", async () => { - await using tmp = await tmpdir({ git: true }) - - const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("from-info-test")) - await withInstance(tmp.path, () => Worktree.createFromInfo(info)) - - // 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 })) - }) + 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", () => { - test("remove non-existent directory succeeds silently", async () => { - await using tmp = await tmpdir({ git: true }) - - const ok = await withInstance(tmp.path, () => - Worktree.remove({ directory: path.join(tmp.path, "does-not-exist") }), - ) - expect(ok).toBe(true) - }) - - 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", - ) - }) + 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) + }), + ), + ) }) }) |
