diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index af8f5d8d438a..999663502581 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -1,29 +1,60 @@ -import { Schema } from "effect" +import path from "node:path" +import { Effect, Schema } from "effect" import { type WorkspaceAdapter, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ name: WorkspaceInfo.fields.name, branch: Schema.String, directory: Schema.String, + extra: WorkspaceInfo.fields.extra, }) +type WorktreeConfig = Schema.Schema.Type const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig) async function loadWorktree() { - const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")]) - return { AppRuntime, Worktree } + const [{ AppRuntime }, { InstanceState }, { Worktree }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/effect/instance-state"), + import("@/worktree"), + ]) + return { AppRuntime, InstanceState, Worktree } +} + +function rootDirectory(info: WorktreeConfig) { + const extra = info.extra + if (!extra || typeof extra !== "object" || Array.isArray(extra)) return info.directory + const root = (extra as Record).root + return typeof root === "string" ? root : info.directory +} + +function extraWithRoot(extra: unknown, root: string) { + if (!extra || typeof extra !== "object" || Array.isArray(extra)) return { root } + return { ...(extra as Record), root } } export const WorktreeAdapter: WorkspaceAdapter = { name: "Worktree", description: "Create a git worktree", async configure(info) { - const { AppRuntime, Worktree } = await loadWorktree() - const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo())) + const { AppRuntime, InstanceState, Worktree } = await loadWorktree() + const next = await AppRuntime.runPromise( + Effect.gen(function* () { + const ctx = yield* InstanceState.context + const worktree = yield* Worktree.Service.use((svc) => svc.makeWorktreeInfo()) + const relative = path.relative(ctx.worktree, ctx.directory) + const directory = + relative && !relative.startsWith("..") && !path.isAbsolute(relative) + ? path.join(worktree.directory, relative) + : worktree.directory + return { ...worktree, directory, root: worktree.directory } + }), + ) return { ...info, name: next.name, branch: next.branch, directory: next.directory, + extra: next.directory === next.root ? info.extra : extraWithRoot(info.extra, next.root), } }, async create(info) { @@ -33,7 +64,8 @@ export const WorktreeAdapter: WorkspaceAdapter = { Worktree.Service.use((svc) => svc.createFromInfo({ name: config.name, - directory: config.directory, + directory: rootDirectory(config), + target: config.directory, branch: config.branch, }), ), @@ -42,7 +74,7 @@ export const WorktreeAdapter: WorkspaceAdapter = { async remove(info) { const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) - await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory }))) + await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: rootDirectory(config) }))) }, target(info) { const config = decodeWorktreeConfig(info) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index f4e4d2721ceb..6f9a2721f643 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -46,6 +46,7 @@ export const Info = Schema.Struct({ name: Schema.String, branch: Schema.String, directory: Schema.String, + target: Schema.optional(Schema.String), }) .annotate({ identifier: "Worktree" }) .pipe(withStatics((s) => ({ zod: effectZod(s) }))) @@ -244,6 +245,7 @@ export const layer: Layer.Layer< const workspaceID = yield* InstanceState.workspaceID const projectID = ctx.project.id const extra = startCommand?.trim() + const target = info.target ?? info.directory const populated = yield* git(["reset", "--hard"], { cwd: info.directory }) if (populated.code !== 0) { @@ -258,14 +260,14 @@ export const layer: Layer.Layer< return } - const booted = yield* store.load({ directory: info.directory }).pipe( + const booted = yield* store.load({ directory: target }).pipe( Effect.as(true), Effect.catch((error) => Effect.sync(() => { const message = errorMessage(error) - log.error("worktree bootstrap failed", { directory: info.directory, message }) + log.error("worktree bootstrap failed", { directory: target, message }) GlobalBus.emit("event", { - directory: info.directory, + directory: target, project: ctx.project.id, workspace: workspaceID, payload: { type: Event.Failed.type, properties: { message } }, @@ -277,7 +279,7 @@ export const layer: Layer.Layer< if (!booted) return GlobalBus.emit("event", { - directory: info.directory, + directory: target, project: ctx.project.id, workspace: workspaceID, payload: { @@ -286,7 +288,7 @@ export const layer: Layer.Layer< }, }) - yield* runStartScripts(info.directory, { projectID, extra }) + yield* runStartScripts(target, { projectID, extra }) }) const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 21bf4120c951..5a70a642ff6c 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -1,3 +1,4 @@ +import { $ } from "bun" import { afterEach, describe, expect, mock } from "bun:test" import { NodeServices } from "@effect/platform-node" import { mkdir } from "node:fs/promises" @@ -231,6 +232,40 @@ describe("workspace HttpApi", () => { }), ) + it.live("creates a real git worktree workspace from a git subdirectory", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const subdir = path.join(dir, "apps", "web") + yield* Effect.promise(() => mkdir(subdir, { recursive: true })) + yield* Effect.promise(() => Bun.write(path.join(subdir, "package.json"), "{}\n")) + yield* Effect.promise(() => $`git add apps/web/package.json`.cwd(dir).quiet()) + yield* Effect.promise(() => $`git commit -m "add web app"`.cwd(dir).quiet()) + + const created = yield* request(WorkspacePaths.list, subdir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }) + + const body = yield* Effect.promise(() => created.text()) + expect({ status: created.status, body }).toMatchObject({ status: 200 }) + const workspace = JSON.parse(body) as Workspace.Info + + try { + expect(workspace.directory?.endsWith(path.join("apps", "web"))).toBe(true) + const url = new URL(`http://localhost${InstancePaths.path}`) + url.searchParams.set("workspace", workspace.id) + const response = yield* request(url.toString(), subdir) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: workspace.directory }) + } finally { + yield* request(WorkspacePaths.remove.replace(":id", workspace.id), subdir, { method: "DELETE" }) + } + }), + ) + it.live("documents legacy Hono accepting the TUI payload shape", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true