瀏覽代碼

feat(httpapi): bridge worktree mutations (#24371)

Kit Langton 4 月之前
父節點
當前提交
a369130226

+ 1 - 1
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                                                                                      |

+ 4 - 4
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")

+ 59 - 0
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),
 )

+ 3 - 0
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))

+ 35 - 43
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" })
     })

+ 65 - 0
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([])
+  })
 })