summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-25 15:35:15 -0400
committerGitHub <[email protected]>2026-04-25 15:35:15 -0400
commita36913022609ec90a26037fa3767cdb60eb49597 (patch)
tree28a97e995053ccce5afa6f2720466bf813ed6f2e /packages
parent474024f9e669926b65738a237310c88c3d4adfe3 (diff)
downloadopencode-a36913022609ec90a26037fa3767cdb60eb49597.tar.gz
opencode-a36913022609ec90a26037fa3767cdb60eb49597.zip
feat(httpapi): bridge worktree mutations (#24371)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/specs/effect/http-api.md2
-rw-r--r--packages/opencode/src/server/routes/instance/experimental.ts8
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/experimental.ts59
-rw-r--r--packages/opencode/src/server/routes/instance/index.ts3
-rw-r--r--packages/opencode/src/worktree/index.ts78
-rw-r--r--packages/opencode/test/server/httpapi-experimental.test.ts65
6 files changed, 167 insertions, 48 deletions
diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md
index c57debb24..f9e9948cf 100644
--- a/packages/opencode/specs/effect/http-api.md
+++ b/packages/opencode/specs/effect/http-api.md
@@ -164,7 +164,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
| `mcp` | `bridged` partial | status only |
| `workspace` | `bridged` | list, get, enter |
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
-| experimental JSON routes | `bridged` partial | console reads, tool ids, worktree list, resource list; global session list remains later |
+| experimental JSON routes | `bridged` partial | console reads, tool ids, worktree list/mutations, resource list; global session list remains later |
| `session` | `later/special` | large stateful surface plus streaming |
| `sync` | `later` | process/control side effects |
| `event` | `special` | SSE |
diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts
index 0936f6252..a407590f2 100644
--- a/packages/opencode/src/server/routes/instance/experimental.ts
+++ b/packages/opencode/src/server/routes/instance/experimental.ts
@@ -230,14 +230,14 @@ export const ExperimentalRoutes = lazy(() =>
description: "Worktree created",
content: {
"application/json": {
- schema: resolver(Worktree.Info),
+ schema: resolver(Worktree.Info.zod),
},
},
},
...errors(400),
},
}),
- validator("json", Worktree.CreateInput.optional()),
+ validator("json", Worktree.CreateInput.zod.optional()),
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
const body = c.req.valid("json")
@@ -286,7 +286,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
- validator("json", Worktree.RemoveInput),
+ validator("json", Worktree.RemoveInput.zod),
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
const body = c.req.valid("json")
@@ -315,7 +315,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
- validator("json", Worktree.ResetInput),
+ validator("json", Worktree.ResetInput.zod),
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
const body = c.req.valid("json")
diff --git a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/experimental.ts
index 4bd9ba30a..14f54d457 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/experimental.ts
@@ -4,6 +4,7 @@ import { InstanceState } from "@/effect"
import { MCP } from "@/mcp"
import { Project } from "@/project"
import { ToolRegistry } from "@/tool"
+import { Worktree } from "@/worktree"
import { Effect, Layer, Option, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -36,6 +37,7 @@ export const ExperimentalPaths = {
consoleOrgs: "/experimental/console/orgs",
toolIDs: "/experimental/tool/ids",
worktree: "/experimental/worktree",
+ worktreeReset: "/experimental/worktree/reset",
resource: "/experimental/resource",
} as const
@@ -80,6 +82,36 @@ export const ExperimentalApi = HttpApi.make("experimental")
description: "List all sandbox worktrees for the current project.",
}),
),
+ HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
+ payload: Schema.optional(Worktree.CreateInput),
+ success: Worktree.Info,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "worktree.create",
+ summary: "Create worktree",
+ description: "Create a new git worktree for the current project and run any configured startup scripts.",
+ }),
+ ),
+ HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
+ payload: Worktree.RemoveInput,
+ success: Schema.Boolean,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "worktree.remove",
+ summary: "Remove worktree",
+ description: "Remove a git worktree and delete its branch.",
+ }),
+ ),
+ HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
+ payload: Worktree.ResetInput,
+ success: Schema.Boolean,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "worktree.reset",
+ summary: "Reset worktree",
+ description: "Reset a worktree branch to the primary default branch.",
+ }),
+ ),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
success: Schema.Record(Schema.String, MCP.Resource),
}).annotateMerge(
@@ -113,6 +145,7 @@ export const experimentalHandlers = Layer.unwrap(
const mcp = yield* MCP.Service
const project = yield* Project.Service
const registry = yield* ToolRegistry.Service
+ const worktreeSvc = yield* Worktree.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
@@ -159,6 +192,28 @@ export const experimentalHandlers = Layer.unwrap(
return yield* project.sandboxes(ctx.project.id)
})
+ const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
+ payload: Worktree.CreateInput | undefined
+ }) {
+ return yield* worktreeSvc.create(ctx.payload)
+ })
+
+ const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
+ payload: Worktree.RemoveInput
+ }) {
+ const ctx = yield* InstanceState.context
+ yield* worktreeSvc.remove(input.payload)
+ yield* project.removeSandbox(ctx.project.id, input.payload.directory)
+ return true
+ })
+
+ const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
+ payload: Worktree.ResetInput
+ }) {
+ yield* worktreeSvc.reset(ctx.payload)
+ return true
+ })
+
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
@@ -169,6 +224,9 @@ export const experimentalHandlers = Layer.unwrap(
.handle("consoleOrgs", listConsoleOrgs)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
+ .handle("worktreeCreate", worktreeCreate)
+ .handle("worktreeRemove", worktreeRemove)
+ .handle("worktreeReset", worktreeReset)
.handle("resource", resource),
)
}),
@@ -178,4 +236,5 @@ export const experimentalHandlers = Layer.unwrap(
Layer.provide(MCP.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
+ Layer.provide(Worktree.defaultLayer),
)
diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts
index 2a8fc602a..c006410b8 100644
--- a/packages/opencode/src/server/routes/instance/index.ts
+++ b/packages/opencode/src/server/routes/instance/index.ts
@@ -50,6 +50,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
+ app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
+ app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
+ app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
app.get("/provider", (c) => handler(c.req.raw, context))
app.get("/provider/auth", (c) => handler(c.req.raw, context))
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index b9c7226b6..8d635e80f 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -20,6 +20,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { InstanceState } from "@/effect"
+import { zod as effectZod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
const log = Log.create({ service: "worktree" })
@@ -39,48 +41,38 @@ export const Event = {
),
}
-export const Info = z
- .object({
- name: z.string(),
- branch: z.string(),
- directory: z.string(),
- })
- .meta({
- ref: "Worktree",
- })
-
-export type Info = z.infer<typeof Info>
-
-export const CreateInput = z
- .object({
- name: z.string().optional(),
- startCommand: z.string().optional().describe("Additional startup script to run after the project's start command"),
- })
- .meta({
- ref: "WorktreeCreateInput",
- })
-
-export type CreateInput = z.infer<typeof CreateInput>
-
-export const RemoveInput = z
- .object({
- directory: z.string(),
- })
- .meta({
- ref: "WorktreeRemoveInput",
- })
-
-export type RemoveInput = z.infer<typeof RemoveInput>
-
-export const ResetInput = z
- .object({
- directory: z.string(),
- })
- .meta({
- ref: "WorktreeResetInput",
- })
-
-export type ResetInput = z.infer<typeof ResetInput>
+export const Info = Schema.Struct({
+ name: Schema.String,
+ branch: Schema.String,
+ directory: Schema.String,
+})
+ .annotate({ identifier: "Worktree" })
+ .pipe(withStatics((s) => ({ zod: effectZod(s) })))
+export type Info = Schema.Schema.Type<typeof Info>
+
+export const CreateInput = Schema.Struct({
+ name: Schema.optional(Schema.String),
+ startCommand: Schema.optional(
+ Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }),
+ ),
+})
+ .annotate({ identifier: "WorktreeCreateInput" })
+ .pipe(withStatics((s) => ({ zod: effectZod(s) })))
+export type CreateInput = Schema.Schema.Type<typeof CreateInput>
+
+export const RemoveInput = Schema.Struct({
+ directory: Schema.String,
+})
+ .annotate({ identifier: "WorktreeRemoveInput" })
+ .pipe(withStatics((s) => ({ zod: effectZod(s) })))
+export type RemoveInput = Schema.Schema.Type<typeof RemoveInput>
+
+export const ResetInput = Schema.Struct({
+ directory: Schema.String,
+})
+ .annotate({ identifier: "WorktreeResetInput" })
+ .pipe(withStatics((s) => ({ zod: effectZod(s) })))
+export type ResetInput = Schema.Schema.Type<typeof ResetInput>
export const NotGitError = NamedError.create(
"WorktreeNotGitError",
@@ -210,7 +202,7 @@ export const layer: Layer.Layer<
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
if (branchCheck.code === 0) continue
- return Info.parse({ name, branch, directory })
+ return { name, branch, directory }
}
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
})
diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts
index 38f43e68f..30cbb477e 100644
--- a/packages/opencode/test/server/httpapi-experimental.test.ts
+++ b/packages/opencode/test/server/httpapi-experimental.test.ts
@@ -1,10 +1,13 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
+import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
+import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
import { Log } from "../../src/util"
+import { Worktree } from "../../src/worktree"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
@@ -18,6 +21,24 @@ function app() {
return InstanceRoutes(websocket)
}
+async function waitReady(directory: string) {
+ return await new Promise<void>((resolve, reject) => {
+ const timer = setTimeout(() => {
+ GlobalBus.off("event", onEvent)
+ reject(new Error("timed out waiting for worktree.ready"))
+ }, 10_000)
+
+ function onEvent(event: { directory?: string; payload: { type?: string } }) {
+ if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return
+ clearTimeout(timer)
+ GlobalBus.off("event", onEvent)
+ resolve()
+ }
+
+ GlobalBus.on("event", onEvent)
+ })
+}
+
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
@@ -67,4 +88,48 @@ describe("experimental HttpApi", () => {
expect(resources.status).toBe(200)
expect(await resources.json()).toEqual({})
})
+
+ test("serves worktree mutations through Hono bridge", async () => {
+ await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+
+ const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
+ const created = await app().request(ExperimentalPaths.worktree, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ name: "api-test" }),
+ })
+
+ expect(created.status).toBe(200)
+ const info = (await created.json()) as Worktree.Info
+ expect(info).toMatchObject({ name: "api-test", branch: "opencode/api-test" })
+ await waitReady(info.directory)
+
+ const listed = await app().request(ExperimentalPaths.worktree, { headers })
+ expect(listed.status).toBe(200)
+ expect(await listed.json()).toContain(info.directory)
+
+ await Bun.write(path.join(info.directory, "dirty.txt"), "dirty")
+ const reset = await app().request(ExperimentalPaths.worktreeReset, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ directory: info.directory }),
+ })
+
+ expect(reset.status).toBe(200)
+ expect(await reset.json()).toBe(true)
+ expect(await Bun.file(path.join(info.directory, "dirty.txt")).exists()).toBe(false)
+
+ const removed = await app().request(ExperimentalPaths.worktree, {
+ method: "DELETE",
+ headers,
+ body: JSON.stringify({ directory: info.directory }),
+ })
+
+ expect(removed.status).toBe(200)
+ expect(await removed.json()).toBe(true)
+
+ const afterRemove = await app().request(ExperimentalPaths.worktree, { headers })
+ expect(afterRemove.status).toBe(200)
+ expect(await afterRemove.json()).toEqual([])
+ })
})