Skip to content

Commit a369130

Browse files
authored
feat(httpapi): bridge worktree mutations (#24371)
1 parent 474024f commit a369130

6 files changed

Lines changed: 167 additions & 48 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
164164
| `mcp` | `bridged` partial | status only |
165165
| `workspace` | `bridged` | list, get, enter |
166166
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
167-
| experimental JSON routes | `bridged` partial | console reads, tool ids, worktree list, resource list; global session list remains later |
167+
| experimental JSON routes | `bridged` partial | console reads, tool ids, worktree list/mutations, resource list; global session list remains later |
168168
| `session` | `later/special` | large stateful surface plus streaming |
169169
| `sync` | `later` | process/control side effects |
170170
| `event` | `special` | SSE |

packages/opencode/src/server/routes/instance/experimental.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,14 +230,14 @@ export const ExperimentalRoutes = lazy(() =>
230230
description: "Worktree created",
231231
content: {
232232
"application/json": {
233-
schema: resolver(Worktree.Info),
233+
schema: resolver(Worktree.Info.zod),
234234
},
235235
},
236236
},
237237
...errors(400),
238238
},
239239
}),
240-
validator("json", Worktree.CreateInput.optional()),
240+
validator("json", Worktree.CreateInput.zod.optional()),
241241
async (c) =>
242242
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
243243
const body = c.req.valid("json")
@@ -286,7 +286,7 @@ export const ExperimentalRoutes = lazy(() =>
286286
...errors(400),
287287
},
288288
}),
289-
validator("json", Worktree.RemoveInput),
289+
validator("json", Worktree.RemoveInput.zod),
290290
async (c) =>
291291
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
292292
const body = c.req.valid("json")
@@ -315,7 +315,7 @@ export const ExperimentalRoutes = lazy(() =>
315315
...errors(400),
316316
},
317317
}),
318-
validator("json", Worktree.ResetInput),
318+
validator("json", Worktree.ResetInput.zod),
319319
async (c) =>
320320
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
321321
const body = c.req.valid("json")

packages/opencode/src/server/routes/instance/httpapi/experimental.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { InstanceState } from "@/effect"
44
import { MCP } from "@/mcp"
55
import { Project } from "@/project"
66
import { ToolRegistry } from "@/tool"
7+
import { Worktree } from "@/worktree"
78
import { Effect, Layer, Option, Schema } from "effect"
89
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
910
import { Authorization } from "./auth"
@@ -36,6 +37,7 @@ export const ExperimentalPaths = {
3637
consoleOrgs: "/experimental/console/orgs",
3738
toolIDs: "/experimental/tool/ids",
3839
worktree: "/experimental/worktree",
40+
worktreeReset: "/experimental/worktree/reset",
3941
resource: "/experimental/resource",
4042
} as const
4143

@@ -80,6 +82,36 @@ export const ExperimentalApi = HttpApi.make("experimental")
8082
description: "List all sandbox worktrees for the current project.",
8183
}),
8284
),
85+
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
86+
payload: Schema.optional(Worktree.CreateInput),
87+
success: Worktree.Info,
88+
}).annotateMerge(
89+
OpenApi.annotations({
90+
identifier: "worktree.create",
91+
summary: "Create worktree",
92+
description: "Create a new git worktree for the current project and run any configured startup scripts.",
93+
}),
94+
),
95+
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
96+
payload: Worktree.RemoveInput,
97+
success: Schema.Boolean,
98+
}).annotateMerge(
99+
OpenApi.annotations({
100+
identifier: "worktree.remove",
101+
summary: "Remove worktree",
102+
description: "Remove a git worktree and delete its branch.",
103+
}),
104+
),
105+
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
106+
payload: Worktree.ResetInput,
107+
success: Schema.Boolean,
108+
}).annotateMerge(
109+
OpenApi.annotations({
110+
identifier: "worktree.reset",
111+
summary: "Reset worktree",
112+
description: "Reset a worktree branch to the primary default branch.",
113+
}),
114+
),
83115
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
84116
success: Schema.Record(Schema.String, MCP.Resource),
85117
}).annotateMerge(
@@ -113,6 +145,7 @@ export const experimentalHandlers = Layer.unwrap(
113145
const mcp = yield* MCP.Service
114146
const project = yield* Project.Service
115147
const registry = yield* ToolRegistry.Service
148+
const worktreeSvc = yield* Worktree.Service
116149

117150
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
118151
const [state, groups] = yield* Effect.all(
@@ -159,6 +192,28 @@ export const experimentalHandlers = Layer.unwrap(
159192
return yield* project.sandboxes(ctx.project.id)
160193
})
161194

195+
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
196+
payload: Worktree.CreateInput | undefined
197+
}) {
198+
return yield* worktreeSvc.create(ctx.payload)
199+
})
200+
201+
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
202+
payload: Worktree.RemoveInput
203+
}) {
204+
const ctx = yield* InstanceState.context
205+
yield* worktreeSvc.remove(input.payload)
206+
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
207+
return true
208+
})
209+
210+
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
211+
payload: Worktree.ResetInput
212+
}) {
213+
yield* worktreeSvc.reset(ctx.payload)
214+
return true
215+
})
216+
162217
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
163218
return yield* mcp.resources()
164219
})
@@ -169,6 +224,9 @@ export const experimentalHandlers = Layer.unwrap(
169224
.handle("consoleOrgs", listConsoleOrgs)
170225
.handle("toolIDs", toolIDs)
171226
.handle("worktree", worktree)
227+
.handle("worktreeCreate", worktreeCreate)
228+
.handle("worktreeRemove", worktreeRemove)
229+
.handle("worktreeReset", worktreeReset)
172230
.handle("resource", resource),
173231
)
174232
}),
@@ -178,4 +236,5 @@ export const experimentalHandlers = Layer.unwrap(
178236
Layer.provide(MCP.defaultLayer),
179237
Layer.provide(Project.defaultLayer),
180238
Layer.provide(ToolRegistry.defaultLayer),
239+
Layer.provide(Worktree.defaultLayer),
181240
)

packages/opencode/src/server/routes/instance/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
5050
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
5151
app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
5252
app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
53+
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
54+
app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
55+
app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
5356
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
5457
app.get("/provider", (c) => handler(c.req.raw, context))
5558
app.get("/provider/auth", (c) => handler(c.req.raw, context))

packages/opencode/src/worktree/index.ts

Lines changed: 35 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
2020
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
2121
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
2222
import { InstanceState } from "@/effect"
23+
import { zod as effectZod } from "@/util/effect-zod"
24+
import { withStatics } from "@/util/schema"
2325

2426
const log = Log.create({ service: "worktree" })
2527

@@ -39,48 +41,38 @@ export const Event = {
3941
),
4042
}
4143

42-
export const Info = z
43-
.object({
44-
name: z.string(),
45-
branch: z.string(),
46-
directory: z.string(),
47-
})
48-
.meta({
49-
ref: "Worktree",
50-
})
51-
52-
export type Info = z.infer<typeof Info>
53-
54-
export const CreateInput = z
55-
.object({
56-
name: z.string().optional(),
57-
startCommand: z.string().optional().describe("Additional startup script to run after the project's start command"),
58-
})
59-
.meta({
60-
ref: "WorktreeCreateInput",
61-
})
62-
63-
export type CreateInput = z.infer<typeof CreateInput>
64-
65-
export const RemoveInput = z
66-
.object({
67-
directory: z.string(),
68-
})
69-
.meta({
70-
ref: "WorktreeRemoveInput",
71-
})
72-
73-
export type RemoveInput = z.infer<typeof RemoveInput>
74-
75-
export const ResetInput = z
76-
.object({
77-
directory: z.string(),
78-
})
79-
.meta({
80-
ref: "WorktreeResetInput",
81-
})
82-
83-
export type ResetInput = z.infer<typeof ResetInput>
44+
export const Info = Schema.Struct({
45+
name: Schema.String,
46+
branch: Schema.String,
47+
directory: Schema.String,
48+
})
49+
.annotate({ identifier: "Worktree" })
50+
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
51+
export type Info = Schema.Schema.Type<typeof Info>
52+
53+
export const CreateInput = Schema.Struct({
54+
name: Schema.optional(Schema.String),
55+
startCommand: Schema.optional(
56+
Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }),
57+
),
58+
})
59+
.annotate({ identifier: "WorktreeCreateInput" })
60+
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
61+
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
62+
63+
export const RemoveInput = Schema.Struct({
64+
directory: Schema.String,
65+
})
66+
.annotate({ identifier: "WorktreeRemoveInput" })
67+
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
68+
export type RemoveInput = Schema.Schema.Type<typeof RemoveInput>
69+
70+
export const ResetInput = Schema.Struct({
71+
directory: Schema.String,
72+
})
73+
.annotate({ identifier: "WorktreeResetInput" })
74+
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
75+
export type ResetInput = Schema.Schema.Type<typeof ResetInput>
8476

8577
export const NotGitError = NamedError.create(
8678
"WorktreeNotGitError",
@@ -210,7 +202,7 @@ export const layer: Layer.Layer<
210202
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
211203
if (branchCheck.code === 0) continue
212204

213-
return Info.parse({ name, branch, directory })
205+
return { name, branch, directory }
214206
}
215207
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
216208
})

packages/opencode/test/server/httpapi-experimental.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { afterEach, describe, expect, test } from "bun:test"
22
import type { UpgradeWebSocket } from "hono/ws"
3+
import path from "path"
34
import { Flag } from "@opencode-ai/core/flag/flag"
5+
import { GlobalBus } from "@/bus/global"
46
import { Instance } from "../../src/project/instance"
57
import { InstanceRoutes } from "../../src/server/routes/instance"
68
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
79
import { Log } from "../../src/util"
10+
import { Worktree } from "../../src/worktree"
811
import { resetDatabase } from "../fixture/db"
912
import { tmpdir } from "../fixture/fixture"
1013

@@ -18,6 +21,24 @@ function app() {
1821
return InstanceRoutes(websocket)
1922
}
2023

24+
async function waitReady(directory: string) {
25+
return await new Promise<void>((resolve, reject) => {
26+
const timer = setTimeout(() => {
27+
GlobalBus.off("event", onEvent)
28+
reject(new Error("timed out waiting for worktree.ready"))
29+
}, 10_000)
30+
31+
function onEvent(event: { directory?: string; payload: { type?: string } }) {
32+
if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return
33+
clearTimeout(timer)
34+
GlobalBus.off("event", onEvent)
35+
resolve()
36+
}
37+
38+
GlobalBus.on("event", onEvent)
39+
})
40+
}
41+
2142
afterEach(async () => {
2243
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
2344
await Instance.disposeAll()
@@ -67,4 +88,48 @@ describe("experimental HttpApi", () => {
6788
expect(resources.status).toBe(200)
6889
expect(await resources.json()).toEqual({})
6990
})
91+
92+
test("serves worktree mutations through Hono bridge", async () => {
93+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
94+
95+
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
96+
const created = await app().request(ExperimentalPaths.worktree, {
97+
method: "POST",
98+
headers,
99+
body: JSON.stringify({ name: "api-test" }),
100+
})
101+
102+
expect(created.status).toBe(200)
103+
const info = (await created.json()) as Worktree.Info
104+
expect(info).toMatchObject({ name: "api-test", branch: "opencode/api-test" })
105+
await waitReady(info.directory)
106+
107+
const listed = await app().request(ExperimentalPaths.worktree, { headers })
108+
expect(listed.status).toBe(200)
109+
expect(await listed.json()).toContain(info.directory)
110+
111+
await Bun.write(path.join(info.directory, "dirty.txt"), "dirty")
112+
const reset = await app().request(ExperimentalPaths.worktreeReset, {
113+
method: "POST",
114+
headers,
115+
body: JSON.stringify({ directory: info.directory }),
116+
})
117+
118+
expect(reset.status).toBe(200)
119+
expect(await reset.json()).toBe(true)
120+
expect(await Bun.file(path.join(info.directory, "dirty.txt")).exists()).toBe(false)
121+
122+
const removed = await app().request(ExperimentalPaths.worktree, {
123+
method: "DELETE",
124+
headers,
125+
body: JSON.stringify({ directory: info.directory }),
126+
})
127+
128+
expect(removed.status).toBe(200)
129+
expect(await removed.json()).toBe(true)
130+
131+
const afterRemove = await app().request(ExperimentalPaths.worktree, { headers })
132+
expect(afterRemove.status).toBe(200)
133+
expect(await afterRemove.json()).toEqual([])
134+
})
70135
})

0 commit comments

Comments
 (0)