refactor(worktree): remove async facade exports (#22369)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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",
|
)
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user