refactor(worktree): remove async facade exports (#22369)

This commit is contained in:
Kit Langton
2026-04-13 21:23:15 -04:00
committed by GitHub
parent c2403d0f15
commit 36745caa2a
5 changed files with 267 additions and 213 deletions

View File

@@ -1,4 +1,5 @@
import z from "zod" import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Worktree } from "@/worktree" import { Worktree } from "@/worktree"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
@@ -12,7 +13,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree", name: "Worktree",
description: "Create a git worktree", description: "Create a git worktree",
async configure(info) { async configure(info) {
const worktree = await Worktree.makeWorktreeInfo(undefined) const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
return { return {
...info, ...info,
name: worktree.name, name: worktree.name,
@@ -22,15 +23,19 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
}, },
async create(info) { async create(info) {
const config = WorktreeConfig.parse(info) const config = WorktreeConfig.parse(info)
await Worktree.createFromInfo({ await AppRuntime.runPromise(
name: config.name, Worktree.Service.use((svc) =>
directory: config.directory, svc.createFromInfo({
branch: config.branch, name: config.name,
}) directory: config.directory,
branch: config.branch,
}),
),
)
}, },
async remove(info) { async remove(info) {
const config = WorktreeConfig.parse(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) { target(info) {
const config = WorktreeConfig.parse(info) const config = WorktreeConfig.parse(info)

View File

@@ -254,7 +254,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.CreateInput.optional()), validator("json", Worktree.CreateInput.optional()),
async (c) => { async (c) => {
const body = c.req.valid("json") 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) return c.json(worktree)
}, },
) )
@@ -301,7 +301,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.RemoveInput), validator("json", Worktree.RemoveInput),
async (c) => { async (c) => {
const body = c.req.valid("json") 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) await Project.removeSandbox(Instance.project.id, body.directory)
return c.json(true) return c.json(true)
}, },
@@ -327,7 +327,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.ResetInput), validator("json", Worktree.ResetInput),
async (c) => { async (c) => {
const body = c.req.valid("json") const body = c.req.valid("json")
await Worktree.reset(body) await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
return c.json(true) return c.json(true)
}, },
) )

View File

@@ -18,7 +18,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node" import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime" import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { makeRuntime } from "@/effect/run-service"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
@@ -598,25 +597,4 @@ export namespace Worktree {
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer), 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))
}
} }

View File

@@ -1,96 +1,126 @@
import { describe, expect, test } from "bun:test"
import { $ } from "bun" 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 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 { Worktree } from "../../src/worktree"
import { Filesystem } from "../../src/util/filesystem" import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir } 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", () => { describe("Worktree.remove", () => {
test("continues when git remove exits non-zero after detaching", async () => { it.live("continues when git remove exits non-zero after detaching", () =>
await using tmp = await tmpdir({ git: true }) provideTmpdirInstance(
const root = tmp.path (root) =>
const name = `remove-regression-${Date.now().toString(36)}` Effect.gen(function* () {
const branch = `opencode/${name}` const svc = yield* Worktree.Service
const dir = path.join(root, "..", name) 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() yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet())
await $`git reset --hard`.cwd(dir).quiet() yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet())
const real = (await $`which git`.quiet().text()).trim() const real = (yield* Effect.promise(() => $`which git`.quiet().text())).trim()
expect(real).toBeTruthy() expect(real).toBeTruthy()
const bin = path.join(root, "bin") const bin = path.join(root, "bin")
const shim = path.join(bin, "git") const shim = path.join(bin, "git")
await fs.mkdir(bin, { recursive: true }) yield* Effect.promise(() => fs.mkdir(bin, { recursive: true }))
await Bun.write( yield* Effect.promise(() =>
shim, Bun.write(
[ shim,
"#!/bin/bash", [
`REAL_GIT=${JSON.stringify(real)}`, "#!/bin/bash",
'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then', `REAL_GIT=${JSON.stringify(real)}`,
' "$REAL_GIT" "$@" >/dev/null 2>&1', 'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
' echo "fatal: failed to remove worktree: Directory not empty" >&2', ' "$REAL_GIT" "$@" >/dev/null 2>&1',
" exit 1", ' echo "fatal: failed to remove worktree: Directory not empty" >&2',
"fi", " exit 1",
'exec "$REAL_GIT" "$@"', "fi",
].join("\n"), 'exec "$REAL_GIT" "$@"',
) ].join("\n"),
await fs.chmod(shim, 0o755) ),
)
yield* Effect.promise(() => fs.chmod(shim, 0o755))
const prev = process.env.PATH ?? "" const prev = yield* Effect.acquireRelease(
process.env.PATH = `${bin}${path.delimiter}${prev}` 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 () => { const ok = yield* svc.remove({ directory: dir })
try {
return await Instance.provide({
directory: root,
fn: () => Worktree.remove({ directory: dir }),
})
} finally {
process.env.PATH = prev
}
})()
expect(ok).toBe(true) expect(ok).toBe(true)
expect(await Filesystem.exists(dir)).toBe(false) expect(
yield* Effect.promise(() =>
fs
.stat(dir)
.then(() => true)
.catch(() => false),
),
).toBe(false)
const list = await $`git worktree list --porcelain`.cwd(root).quiet().text() const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(root).quiet().text())
expect(list).not.toContain(`worktree ${dir}`) expect(list).not.toContain(`worktree ${dir}`)
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow() const ref = yield* Effect.promise(() =>
expect(ref.exitCode).not.toBe(0) $`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 () => { wintest("stops fsmonitor before removing a worktree", () =>
await using tmp = await tmpdir({ git: true }) provideTmpdirInstance(
const root = tmp.path (root) =>
const name = `remove-fsmonitor-${Date.now().toString(36)}` Effect.gen(function* () {
const branch = `opencode/${name}` const svc = yield* Worktree.Service
const dir = path.join(root, "..", name) 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() yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet())
await $`git reset --hard`.cwd(dir).quiet() yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet())
await $`git config core.fsmonitor true`.cwd(dir).quiet() yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(dir).quiet())
await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow() yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow())
await Bun.write(path.join(dir, "tracked.txt"), "next\n") yield* Effect.promise(() => Bun.write(path.join(dir, "tracked.txt"), "next\n"))
await $`git diff`.cwd(dir).quiet() yield* Effect.promise(() => $`git diff`.cwd(dir).quiet())
const before = await $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow() const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow())
expect(before.exitCode).toBe(0) expect(before.exitCode).toBe(0)
const ok = await Instance.provide({ const ok = yield* svc.remove({ directory: dir })
directory: root,
fn: () => Worktree.remove({ directory: dir }),
})
expect(ok).toBe(true) expect(ok).toBe(true)
expect(await Filesystem.exists(dir)).toBe(false) 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() const ref = yield* Effect.promise(() =>
expect(ref.exitCode).not.toBe(0) $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
}) )
expect(ref.exitCode).not.toBe(0)
}),
{ git: true },
),
)
}) })

View File

@@ -1,16 +1,16 @@
import { $ } from "bun" import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import * as fs from "fs/promises"
const wintest = process.platform !== "win32" ? test : test.skip
import fs from "fs/promises"
import path from "path" 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 { Instance } from "../../src/project/instance"
import { Worktree } from "../../src/worktree" 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>) { const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
return Instance.provide({ directory, fn }) const wintest = process.platform !== "win32" ? it.live : it.live.skip
}
function normalize(input: string) { function normalize(input: string) {
return input.replace(/\\/g, "/").toLowerCase() return input.replace(/\\/g, "/").toLowerCase()
@@ -40,134 +40,175 @@ describe("Worktree", () => {
afterEach(() => Instance.disposeAll()) afterEach(() => Instance.disposeAll())
describe("makeWorktreeInfo", () => { describe("makeWorktreeInfo", () => {
test("returns info with name, branch, and directory", async () => { it.live("returns info with name, branch, and directory", () =>
await using tmp = await tmpdir({ git: true }) 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() it.live("uses provided name as base", () =>
expect(typeof info.name).toBe("string") provideTmpdirInstance(
expect(info.branch).toBe(`opencode/${info.name}`) () =>
expect(info.directory).toContain(info.name) Effect.gen(function* () {
}) const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo("my-feature")
test("uses provided name as base", async () => { expect(info.name).toBe("my-feature")
await using tmp = await tmpdir({ git: true }) 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.name).toBe("my-feature-branch")
expect(info.branch).toBe("opencode/my-feature") }),
}) { git: true },
),
)
test("slugifies the provided name", async () => { it.live("throws NotGitError for non-git directories", () =>
await using tmp = await tmpdir({ git: true }) 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(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
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")
})
}) })
describe("create + remove lifecycle", () => { describe("create + remove lifecycle", () => {
test("create returns worktree info and remove cleans up", async () => { it.live("create returns worktree info and remove cleans up", () =>
await using tmp = await tmpdir({ git: true }) 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() yield* Effect.promise(() => Bun.sleep(1000))
expect(info.branch).toStartWith("opencode/")
expect(info.directory).toBeDefined()
// Wait for bootstrap to complete const ok = yield* svc.remove({ directory: info.directory })
await Bun.sleep(1000) expect(ok).toBe(true)
}),
{ git: true },
),
)
const ok = await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory })) it.live("create returns after setup and fires Event.Ready after bootstrap", () =>
expect(ok).toBe(true) 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 () => { expect(info.name).toBeDefined()
await using tmp = await tmpdir({ git: true }) expect(info.branch).toStartWith("opencode/")
const ready = waitReady()
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 const props = yield* Effect.promise(() => ready)
expect(info.name).toBeDefined() expect(props.name).toBe(info.name)
expect(info.branch).toStartWith("opencode/") expect(props.branch).toBe(info.branch)
const text = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text() yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
const dir = await fs.realpath(info.directory).catch(() => info.directory) yield* Effect.promise(() => Bun.sleep(100))
expect(normalize(text)).toContain(normalize(dir)) yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
)
// Event.Ready fires after bootstrap finishes in the background it.live("create with custom name", () =>
const props = await ready provideTmpdirInstance(
expect(props.name).toBe(info.name) () =>
expect(props.branch).toBe(info.branch) Effect.gen(function* () {
const svc = yield* Worktree.Service
const ready = waitReady()
const info = yield* svc.create({ name: "test-workspace" })
// Cleanup expect(info.name).toBe("test-workspace")
await withInstance(info.directory, () => Instance.dispose()) expect(info.branch).toBe("opencode/test-workspace")
await Bun.sleep(100)
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
test("create with custom name", async () => { yield* Effect.promise(() => ready)
await using tmp = await tmpdir({ git: true }) yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
const ready = waitReady() yield* Effect.promise(() => Bun.sleep(100))
yield* svc.remove({ directory: info.directory })
const info = await withInstance(tmp.path, () => Worktree.create({ name: "test-workspace" })) }),
{ git: true },
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 }))
})
}) })
describe("createFromInfo", () => { describe("createFromInfo", () => {
wintest("creates and bootstraps git worktree", async () => { wintest("creates and bootstraps git worktree", () =>
await using tmp = await tmpdir({ git: true }) 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")) const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
await withInstance(tmp.path, () => Worktree.createFromInfo(info)) const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir)
// Worktree should exist in git (normalize slashes for Windows) yield* svc.remove({ directory: info.directory })
const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text() }),
const normalizedList = list.replace(/\\/g, "/") { git: true },
const normalizedDir = info.directory.replace(/\\/g, "/") ),
expect(normalizedList).toContain(normalizedDir) )
// Cleanup
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
}) })
describe("remove edge cases", () => { describe("remove edge cases", () => {
test("remove non-existent directory succeeds silently", async () => { it.live("remove non-existent directory succeeds silently", () =>
await using tmp = await tmpdir({ git: true }) 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, () => it.live("throws NotGitError for non-git directories", () =>
Worktree.remove({ directory: path.join(tmp.path, "does-not-exist") }), provideTmpdirInstance(() =>
) Effect.gen(function* () {
expect(ok).toBe(true) const svc = yield* Worktree.Service
}) const exit = yield* Effect.exit(svc.remove({ directory: "/tmp/fake" }))
test("throws NotGitError for non-git directories", async () => { expect(Exit.isFailure(exit)).toBe(true)
await using tmp = await tmpdir() if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
}),
await expect(withInstance(tmp.path, () => Worktree.remove({ directory: "/tmp/fake" }))).rejects.toThrow( ),
"WorktreeNotGitError", )
)
})
}) })
}) })