Skip to content
Open
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
46 changes: 39 additions & 7 deletions packages/opencode/src/control-plane/adapters/worktree.ts
Original file line number Diff line number Diff line change
@@ -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<typeof WorktreeConfig>
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<string, unknown>).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<string, unknown>), 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) {
Expand All @@ -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,
}),
),
Expand All @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions packages/opencode/src/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })))
Expand Down Expand Up @@ -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) {
Expand All @@ -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 } },
Expand All @@ -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: {
Expand All @@ -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) {
Expand Down
35 changes: 35 additions & 0 deletions packages/opencode/test/server/httpapi-workspace.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading