Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions packages/opencode/src/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,16 +291,15 @@ export const layer: Layer.Layer<

const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
yield* setup(info)
yield* boot(info, startCommand)
yield* boot(info, startCommand).pipe(
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
Effect.forkIn(scope),
)
})

const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
const info = yield* makeWorktreeInfo(input?.name)
yield* setup(info)
yield* boot(info, input?.startCommand).pipe(
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
Effect.forkIn(scope),
)
yield* createFromInfo(info, input?.startCommand)
return info
})

Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/test/project/worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,19 +178,21 @@ describe("Worktree", () => {
})

describe("createFromInfo", () => {
wintest("creates and bootstraps git worktree", () =>
wintest("creates git worktree and boots asynchronously", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo("from-info-test")
const ready = waitReady()
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* Effect.promise(() => ready)
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
Expand Down
148 changes: 148 additions & 0 deletions packages/opencode/test/server/worktree-endpoint-repro.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { withTimeout } from "../../src/util/timeout"
import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"

const stateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
}

Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true

yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES
await resetDatabase()
}),
)
}),
)

const it = testEffect(stateLayer)
type TestServer = ReturnType<typeof HttpRouter.toWebHandler>

function serverScoped() {
return Effect.acquireRelease(
Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })),
(server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore),
)
}

function request(server: TestServer, input: string, init?: RequestInit) {
return Effect.promise(() =>
server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context),
)
}

function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label))
}

function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
return Effect.gen(function* () {
const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
expect(current.status).toBe(200)
const project = (yield* Effect.promise(() => current.json())) as { id: string }
const updated = yield* request(
input.server,
`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ commands: { start: input.command } }),
},
)
expect(updated.status).toBe(200)
})
}

describe("worktree endpoint reproduction", () => {
it.instance(
"direct HttpApi worktree create returns without waiting for boot",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()

const response = yield* withRequestTimeout(
request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
}),
"direct worktree create",
)

expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) })
}),
{ git: true },
)

it.instance(
"workspace worktree create does not hang",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()

const response = yield* withRequestTimeout(
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "worktree", branch: null }),
}),
"workspace worktree create",
8_000,
)

expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
type: "worktree",
directory: expect.any(String),
})
}),
{ git: true },
)

it.instance(
"workspace worktree create returns without waiting for project start command",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()
yield* setProjectStartCommand({
server,
directory: test.directory,
command: 'bun -e "setTimeout(() => {}, 2000)"',
})

const started = Date.now()
const response = yield* withRequestTimeout(
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "worktree", branch: null }),
}),
"workspace worktree create with project start command",
6_000,
)

expect(response.status).toBe(200)
expect(Date.now() - started).toBeLessThan(1_500)
}),
{ git: true },
)
})
Loading