Compare commits

...
Author SHA1 Message Date
James Long b8a7f03ced feat(core): make worktree APIs project-based 2026-09-13 22:39:42 +00:00
54 changed files with 1172 additions and 1187 deletions
@@ -491,7 +491,8 @@ async function openDraft(
if (request.method() !== "POST") return
const path = new URL(request.url()).pathname
if (path === "/api/worktree") {
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
expect(new URL(request.url()).searchParams.has("location[directory]")).toBe(false)
expect(request.postDataJSON()).toMatchObject({ projectID })
calls.push("worktree")
worktreeRequests.push(request.postDataJSON())
}
@@ -236,7 +236,7 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
const requested = page.waitForRequest(
(request) =>
new URL(request.url()).pathname === "/api/worktree" &&
new URL(request.url()).searchParams.get("location[directory]") === directory &&
new URL(request.url()).searchParams.get("projectID") === "proj_settings_demo" &&
request.method() === "GET",
)
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
@@ -265,7 +265,7 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
refresh.resolve()
})
test("worktree deletion sends the project location separately from the target", async ({ page }) => {
test("worktree deletion sends the project ID and target without a location", async ({ page }) => {
const removed = new Set<string>()
await page.route(
(url) => url.pathname === "/api/worktree",
@@ -297,8 +297,8 @@ test("worktree deletion sends the project location separately from the target",
)
await remove.click()
const request = await deleting
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
expect(request.postDataJSON()).toEqual({ directory: sandboxes[0], force: true })
expect(new URL(request.url()).searchParams.has("location[directory]")).toBe(false)
expect(request.postDataJSON()).toEqual({ projectID: "proj_settings_demo", directory: sandboxes[0], force: true })
await expect(settings.getByText(sandboxes[0], { exact: true })).toHaveCount(0)
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
})
@@ -98,7 +98,7 @@ for (const theme of ["light", "dark"] as const) {
const refreshed = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === "/api/worktree" &&
new URL(response.url()).searchParams.get("location[directory]") === root &&
new URL(response.url()).searchParams.get("projectID") === projectID &&
response.request().method() === "GET",
)
view.worktrees.push({ directory: workspace, strategy: "git" })
@@ -228,7 +228,7 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
const loaded = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === "/api/worktree" &&
new URL(response.url()).searchParams.get("location[directory]") === root &&
new URL(response.url()).searchParams.get("projectID") === projectID &&
response.request().method() === "GET",
)
await page.goto(
@@ -54,7 +54,7 @@ for (const interaction of ["hover", "focus"] as const) {
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
calls.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
calls.push(new URL(route.request().url()).searchParams.get("projectID") ?? "")
await inventory.promise
await route.fallback()
},
@@ -74,7 +74,7 @@ for (const interaction of ["hover", "focus"] as const) {
await worktrees[interaction]()
await requested
await expect(worktrees).toHaveAttribute("aria-selected", "false")
await expect.poll(() => calls).toEqual([directory])
await expect.poll(() => calls).toEqual([project.id])
expect(sessions).toEqual([])
if (interaction === "hover") {
@@ -90,7 +90,7 @@ for (const interaction of ["hover", "focus"] as const) {
inventory.resolve()
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
expect(calls).toEqual([directory])
expect(calls).toEqual([project.id])
await expect.poll(() => sessions.toSorted()).toEqual(sandboxes.toSorted())
})
}
@@ -124,9 +124,9 @@ for (const nested of [false, true]) {
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
const requested = new URL(route.request().url()).searchParams.get("location[directory]") ?? ""
const requested = new URL(route.request().url()).searchParams.get("projectID") ?? ""
calls.worktrees.push(requested)
if (requested === other.canonical) return route.fulfill({ json: [{ directory: other.canonical }] })
if (requested === other.id) return route.fulfill({ json: [{ directory: other.canonical }] })
await route.fallback()
},
)
@@ -146,7 +146,7 @@ for (const nested of [false, true]) {
await worktrees.click()
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
expect(calls.projects).toBe(1)
expect(calls.worktrees.toSorted()).toEqual([directory, other.canonical].toSorted())
expect(calls.worktrees.toSorted()).toEqual([project.id, other.id].toSorted())
})
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
import { Worktree } from "@opencode/schema/worktree"
const Json = Schema.Json.pipe(
Schema.decodeTo(Schema.Unknown, {
@@ -88,22 +89,25 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("websearchProviders", "/api/websearch/provider", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
query: Schema.Struct({ projectID: Schema.String }),
success: Json,
}),
)
.add(
HttpApiEndpoint.post("worktreeCreate", "/api/worktree", {
payload: JsonPayload,
payload: Worktree.CreateInput,
success: Json,
}),
)
.add(
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", {
payload: Worktree.RemoveInput,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/refresh", {
payload: Schema.Struct({ projectID: Schema.String }),
success: NoContent,
}),
)
+1 -7
View File
@@ -285,12 +285,6 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
canonical: project.canonical ?? config.directory,
})
},
projectCurrent: () =>
Effect.succeed({
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
}),
configPreferences: () => Effect.succeed(preferences.current),
configUpdatePreferences: (ctx) =>
Effect.sync(() => {
@@ -308,7 +302,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
})),
]),
worktreeCreate: (ctx) => {
const input = record(ctx.payload) ? ctx.payload : {}
const input = ctx.payload
return Effect.succeed({
directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${
typeof input.name === "string" ? input.name : "copy"
+1 -2
View File
@@ -39,8 +39,7 @@ export function createHomeController() {
const id = selectedProject()?.id
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
if (root) void ctx.sync.worktrees.load(root)
void ctx.sync.worktrees.load(id)
})
function setSelection(next: HomeProjectSelection) {
@@ -67,12 +67,13 @@ export function createNewSessionWorkspaceController(input: {
const [worktrees, worktreeActions] = createResource(worktreeSource, async (source) => ({
projectID: source.projectID,
items: await serverSDK.api.worktree
.list({ location: { directory: source.directory } })
.list({ projectID: source.projectID })
.catch(() => (currentProject()?.id === source.projectID ? currentProject()?.worktrees : undefined) ?? []),
}))
onCleanup(
serverSDK.event.listen((event) => {
if (event.type === "worktree.updated") void worktreeActions.refetch()
if (event.type === "worktree.updated" && event.data.projectID === currentProject()?.id)
void worktreeActions.refetch()
}),
)
// `latest` only skips Suspense once the resource has resolved at least once. Before that it
@@ -49,7 +49,7 @@ test("bootstraps projects through the native store setter and preserves subseque
expect(store.config).toEqual({})
// A refetch keeps the inventory a view already loaded for this project.
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "project"), [
{ directory: "/repo" },
{ directory: "/repo/feature", strategy: "git" },
])
@@ -1,9 +1,5 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/runtime/server/types"
import type {
LocationGetInput,
LocationGetOutput,
ProjectListOutput,
} from "@opencode/client/promise"
import type { LocationGetInput, LocationGetOutput, ProjectListOutput } from "@opencode/client/promise"
import { showToast } from "@/shell/notifications/toast"
import { getFilename } from "@opencode/util/path"
import { retry } from "@opencode/util/retry"
@@ -97,7 +93,7 @@ export async function bootstrapGlobal(input: {
data.map((project) =>
withWorktreeInventory(
project,
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.id)),
),
),
),
+6 -10
View File
@@ -21,7 +21,6 @@ import { createConnectionSync, reconnectOrder } from "./server-sync/connection"
import { usePlatform } from "@/runtime/platform/platform"
import type { Data } from "@opencode/client/solid"
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
import { sameDirectory } from "@/workspaces/paths"
type GlobalStore = {
path: Path
@@ -85,11 +84,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
scope: serverSDK.scope,
queryClient,
api: () => serverSDK.api.worktree,
updated: (directory, items) =>
updated: (projectID, items) =>
setGlobalStore("project", (projects) =>
projects.map((project) =>
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
),
projects.map((project) => (project.id === projectID ? withWorktreeInventory(project, items) : project)),
),
})
const bootstrap = useQuery(() => ({
@@ -164,8 +161,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
// The refresh queue re-syncs two directories at a time, held ones first. Syncing every active
// directory here as well sent the whole catalog fan-out for all of them at once.
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach(
(directory) => queue.push(directory),
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach((directory) =>
queue.push(directory),
)
},
})
@@ -212,7 +209,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
projects.map((project) =>
project.id === update.id
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.id))
: project,
),
)
@@ -222,8 +219,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
connection.handleEvent({ type: event.type })
if (event.type === "project.updated") applyProjectUpdate(event.data)
if (event.type === "worktree.updated") {
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
if (root) void worktrees.refresh(root)
void worktrees.refresh(event.data.projectID)
void bootstrap.refetch()
return
}
@@ -37,13 +37,21 @@ export function SessionWorkspaceMenu(props: {
props.onOpenChange?.(open)
if (!open) return
const sdk = serverSDK
const list = () =>
sdk.api.worktree
.list({ projectID: props.project.id })
.then((items) =>
setDirectories(
items
.map((item) => item.directory)
.filter((directory) => !sameDirectory(props.project.worktree, directory)),
),
)
.catch(() => undefined)
void list()
void sdk.api.worktree
.list({ location: { directory: props.directory } })
.then((items) =>
setDirectories(
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
),
)
.refresh({ projectID: props.project.id })
.then(list)
.catch(() => undefined)
}
const move = async (selection: "create" | string) => {
@@ -21,7 +21,7 @@ export function workspaceInventoryQuery(context: ServerCtx, client: QueryClient,
(await client.fetchQuery(workspaceProjectsQuery(context.sdk)))
.filter((project) => projectID === undefined || project.id === projectID)
.map(async (project) => {
const worktrees = (await context.sync.worktrees.load(project.canonical)) ?? [
const worktrees = (await context.sync.worktrees.load(project.id)) ?? [
{ directory: project.canonical },
...project.sandboxes.map((directory) => ({ directory })),
]
@@ -215,7 +215,7 @@ export const SettingsWorkspaces: Component<{
}
const removed = await context.sdk.api.worktree
.remove({
location: { directory: workspace.project.worktree },
projectID: workspace.project.id,
directory: workspace.directory,
force,
})
@@ -243,7 +243,7 @@ export const SettingsWorkspaces: Component<{
})
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await queryClient.invalidateQueries({
queryKey: worktreeInventoryKey(context.sdk.scope, workspace.project.worktree),
queryKey: worktreeInventoryKey(context.sdk.scope, workspace.project.id),
})
await queryClient.invalidateQueries({ queryKey: [context.sdk.scope, "settings-workspace-inventory"] })
} finally {
+2 -4
View File
@@ -60,13 +60,11 @@ describe("worktree creation", () => {
}),
).toBe("/created")
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
strategy: "git",
projectID: project.id,
from: input.canonical,
branch: "clone-only",
})
expect(requests.find((request) => request.method === "POST")?.url).toBe(
`http://localhost:3000/api/worktree?location%5Bdirectory%5D=${encodeURIComponent(input.directory)}`,
)
expect(requests.find((request) => request.method === "POST")?.url).toBe("http://localhost:3000/api/worktree")
expect(
requests
.filter((request) => request.method === "GET")
+1 -2
View File
@@ -10,8 +10,7 @@ export async function createWorktree(input: {
}) {
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
const created = await input.api.worktree.create({
location: { directory: input.directory },
strategy: "git",
projectID: project.id,
from: project.canonical,
branch: input.branch,
})
+29 -8
View File
@@ -7,6 +7,8 @@ import { normalizeProjectInfo, updateProjectInfo } from "@/runtime/server/global
function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
const client = new QueryClient()
const discovery = Promise.withResolvers<void>()
const discoveries: string[] = []
const calls: string[] = []
const updates: Array<[string, WorktreeDirectory[]]> = []
const inventory = createWorktreeInventory({
@@ -14,14 +16,20 @@ function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
queryClient: client,
api: () => ({
list: (input) => {
const directory = input!.location!.directory!
const directory = input.projectID
calls.push(directory)
return list(directory)
},
refresh: (input) => {
discoveries.push(input.projectID)
return discovery.promise
},
}),
updated: (directory, items) => updates.push([directory, items]),
updated: (directory, items) => {
updates.push([directory, items])
},
})
return { client, calls, updates, inventory }
return { client, calls, updates, inventory, discovery, discoveries }
}
describe("createWorktreeInventory", () => {
@@ -32,7 +40,7 @@ describe("createWorktreeInventory", () => {
return [{ directory }, { directory: `${directory}/feature`, strategy: "git" }]
})
const first = setupResult.inventory.load("/repo")
const second = setupResult.inventory.load("/repo/")
const second = setupResult.inventory.load("/repo")
expect(setupResult.calls).toEqual(["/repo"])
gate.resolve()
expect(await first).toHaveLength(2)
@@ -42,7 +50,8 @@ describe("createWorktreeInventory", () => {
expect(setupResult.updates).toEqual([
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
])
expect(setupResult.inventory.cached("/repo/")).toHaveLength(2)
expect(setupResult.inventory.cached("/repo")).toHaveLength(2)
expect(setupResult.discoveries).toEqual(["/repo"])
setupResult.client.clear()
})
@@ -70,13 +79,25 @@ describe("createWorktreeInventory", () => {
setupResult.client.clear()
})
test("keys are partitioned by server and normalized by path", () => {
test("keys are partitioned by server and use opaque project IDs", () => {
const remote = "https://remote.example" as typeof ServerScope.local
expect(worktreeInventoryKey(ServerScope.local, "C:\\Repo\\")).toEqual(
worktreeInventoryKey(ServerScope.local, "C:/Repo"),
expect(worktreeInventoryKey(ServerScope.local, "project")).not.toEqual(
worktreeInventoryKey(ServerScope.local, "project/"),
)
expect(worktreeInventoryKey(ServerScope.local, "/repo")).not.toEqual(worktreeInventoryKey(remote, "/repo"))
})
test("shows saved inventory during discovery and re-reads it on an inventory event", async () => {
const rows = [{ directory: "/repo" }]
const result = setup(async () => [...rows])
expect(await result.inventory.load("project")).toEqual(rows)
rows.push({ directory: "/external" })
result.discovery.resolve()
expect(await result.inventory.refresh("project")).toEqual(rows)
expect(result.calls).toEqual(["project", "project"])
expect(result.discoveries).toEqual(["project"])
result.client.clear()
})
})
describe("withWorktreeInventory", () => {
+26 -17
View File
@@ -3,11 +3,10 @@ import type { WorktreeDirectory } from "@opencode/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerScope } from "@/runtime/server/scope"
import type { Project } from "@/runtime/server/types"
import { pathKey } from "./path-key"
import { sameDirectory } from "./paths"
export function worktreeInventoryKey(scope: ServerScope, directory: string) {
return [scope, "worktree", pathKey(directory)] as const
export function worktreeInventoryKey(scope: ServerScope, projectID: string) {
return [scope, "worktree", projectID] as const
}
// Project metadata arrives without worktrees; a loaded inventory supplies the workspace list.
@@ -22,22 +21,21 @@ export function withWorktreeInventory(project: Project, worktrees: readonly Work
}
}
// Listing a project's worktrees boots its Location on the server and runs discovery, so only
// projects the user is looking at are loaded. Historical projects stay metadata-only.
// Reads use saved inventory; explicit demand also discovers external worktree changes.
export function createWorktreeInventory(input: {
scope: ServerScope
queryClient: QueryClient
api: () => Pick<ServerApi["worktree"], "list">
updated: (directory: string, worktrees: WorktreeDirectory[]) => void
api: () => Pick<ServerApi["worktree"], "list" | "refresh">
updated: (projectID: string, worktrees: WorktreeDirectory[]) => void
}) {
const options = (directory: string) => ({
queryKey: worktreeInventoryKey(input.scope, directory),
const options = (projectID: string) => ({
queryKey: worktreeInventoryKey(input.scope, projectID),
queryFn: () =>
input
.api()
.list({ location: { directory } })
.list({ projectID })
.then((items) => {
input.updated(directory, items)
input.updated(projectID, items)
return items
}),
// `worktree.updated` and reconnect invalidation drive refreshes; time alone does not re-list.
@@ -46,13 +44,24 @@ export function createWorktreeInventory(input: {
retry: false,
})
return {
cached: (directory: string) =>
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, directory)),
load: (directory: string) => input.queryClient.fetchQuery(options(directory)).catch(() => undefined),
cached: (projectID: string) =>
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, projectID)),
load: async (projectID: string) => {
const discover = !input.queryClient.getQueryState(worktreeInventoryKey(input.scope, projectID))
const items = await input.queryClient.fetchQuery(options(projectID)).catch(() => undefined)
if (discover) {
// The worktree.updated event re-reads inventory only when discovery actually changes it.
void input
.api()
.refresh({ projectID })
.catch(() => undefined)
}
return items
},
// Only inventories some view already demanded are refreshed.
refresh: (directory: string) => {
if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, directory))) return Promise.resolve()
return input.queryClient.fetchQuery({ ...options(directory), staleTime: 0 }).catch(() => undefined)
refresh: (projectID: string) => {
if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, projectID))) return Promise.resolve()
return input.queryClient.fetchQuery({ ...options(projectID), staleTime: 0 }).catch(() => undefined)
},
}
}
+1 -3
View File
@@ -45,9 +45,7 @@ const context = createSimpleContext({
const id = current()?.project.id
if (!id || serverSDK.connection.status() !== "connected") return
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
// Key it by the metadata root so the result merges into the same global project record.
const root = server.ctx.sync.data.project.find((project) => project.id === id)?.worktree
if (root) void server.ctx.sync.worktrees.load(root)
void server.ctx.sync.worktrees.load(id)
})
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
+7 -12
View File
@@ -1987,37 +1987,32 @@ export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type WorktreeListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type WorktreeListInput = { readonly projectID: Project.ID }
export type WorktreeListOutput = Worktree.List
export type WorktreeListOperation<E = never> = (input?: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
export type WorktreeListOperation<E = never> = (input: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
export type WorktreeCreateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy?: Worktree.StrategyID | undefined
readonly projectID: Project.ID
readonly from?: AbsolutePath | undefined
readonly branch?: string | undefined
readonly directory?: AbsolutePath | undefined
readonly name?: string | undefined
}
export type WorktreeCreateOutput = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input?: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
export type WorktreeCreateOperation<E = never> = (input: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
export type WorktreeRemoveInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly projectID: Project.ID
readonly directory: AbsolutePath
readonly force: boolean
}
export type WorktreeRemoveOutput = void
export type WorktreeRemoveOperation<E = never> = (input: WorktreeRemoveInput) => Effect.Effect<WorktreeRemoveOutput, E>
export type WorktreeRefreshInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type WorktreeRefreshInput = { readonly projectID: Project.ID }
export type WorktreeRefreshOutput = void
export type WorktreeRefreshOperation<E = never> = (
input?: WorktreeRefreshInput,
input: WorktreeRefreshInput,
) => Effect.Effect<WorktreeRefreshOutput, E>
export interface WorktreeApi<E = never> {
+11 -13
View File
@@ -1459,21 +1459,20 @@ const EndpointReferenceList = (raw: RawClient["server.reference"]) => (input?: R
const adaptGroupReference = (raw: RawClient["server.reference"]) => ({ list: EndpointReferenceList(raw) })
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input?: WorktreeListInput) =>
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input: WorktreeListInput) =>
preserveEffect<WorktreeListOutput>()(
raw["worktree.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
raw["worktree.list"]({ query: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: WorktreeCreateInput) =>
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: WorktreeCreateInput) =>
preserveEffect<WorktreeCreateOutput>()(
raw["worktree.create"]({
query: { location: input?.["location"] },
payload: {
strategy: input?.["strategy"],
from: input?.["from"],
branch: input?.["branch"],
directory: input?.["directory"],
name: input?.["name"],
projectID: input["projectID"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
},
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1481,14 +1480,13 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: W
const EndpointWorktreeRemove = (raw: RawClient["server.worktree"]) => (input: WorktreeRemoveInput) =>
preserveEffect<WorktreeRemoveOutput>()(
raw["worktree.remove"]({
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
payload: { projectID: input["projectID"], directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input?: WorktreeRefreshInput) =>
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input: WorktreeRefreshInput) =>
preserveEffect<WorktreeRefreshOutput>()(
raw["worktree.refresh"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
raw["worktree.refresh"]({ payload: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
+15 -17
View File
@@ -1981,33 +1981,32 @@ export function make(options: ClientOptions) {
),
},
worktree: {
list: (input?: WorktreeListInput, requestOptions?: RequestOptions) =>
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
request<WorktreeListOutput>(
{
method: "GET",
path: `/api/worktree`,
query: { location: input?.["location"] },
query: { projectID: input["projectID"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
),
create: (input?: WorktreeCreateInput, requestOptions?: RequestOptions) =>
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
request<WorktreeCreateOutput>(
{
method: "POST",
path: `/api/worktree`,
query: { location: input?.["location"] },
body: {
strategy: input?.["strategy"],
from: input?.["from"],
branch: input?.["branch"],
directory: input?.["directory"],
name: input?.["name"],
projectID: input["projectID"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
},
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -2017,22 +2016,21 @@ export function make(options: ClientOptions) {
{
method: "DELETE",
path: `/api/worktree`,
query: { location: input["location"] },
body: { directory: input["directory"], force: input["force"] },
body: { projectID: input["projectID"], directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
refresh: (input?: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
request<WorktreeRefreshOutput>(
{
method: "POST",
path: `/api/worktree/refresh`,
query: { location: input?.["location"] },
body: { projectID: input["projectID"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
+12 -25
View File
@@ -6304,48 +6304,41 @@ export type ReferenceListOutput = {
data: Array<ReferenceInfo>
}
export type WorktreeListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeListOutput = WorktreeList
export type WorktreeCreateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly strategy?: {
readonly strategy?: string
readonly projectID: {
readonly projectID: string
readonly from?: string
readonly branch?: string
readonly directory?: string
readonly name?: string
}["strategy"]
}["projectID"]
readonly from?: {
readonly strategy?: string
readonly projectID: string
readonly from?: string
readonly branch?: string
readonly directory?: string
readonly name?: string
}["from"]
readonly branch?: {
readonly strategy?: string
readonly projectID: string
readonly from?: string
readonly branch?: string
readonly directory?: string
readonly name?: string
}["branch"]
readonly directory?: {
readonly strategy?: string
readonly projectID: string
readonly from?: string
readonly branch?: string
readonly directory?: string
readonly name?: string
}["directory"]
readonly name?: {
readonly strategy?: string
readonly projectID: string
readonly from?: string
readonly branch?: string
readonly directory?: string
@@ -6356,20 +6349,14 @@ export type WorktreeCreateInput = {
export type WorktreeCreateOutput = WorktreeInfo
export type WorktreeRemoveInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly directory: string; readonly force: boolean }["force"]
readonly projectID: { readonly projectID: string; readonly directory: string; readonly force: boolean }["projectID"]
readonly directory: { readonly projectID: string; readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly projectID: string; readonly directory: string; readonly force: boolean }["force"]
}
export type WorktreeRemoveOutput = void
export type WorktreeRefreshInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeRefreshOutput = void
+22 -19
View File
@@ -54,7 +54,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
expect(client.experimental.persistentPty.read).toBeFunction()
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
expect(Object.keys(client.project)).toEqual(["list", "update"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
@@ -336,7 +336,7 @@ test("file.read returns binary content from the public HTTP contract", async ()
)
})
test("all worktree operations use location-based routes without a project parameter", async () => {
test("all worktree operations require a project ID", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
@@ -350,38 +350,41 @@ test("all worktree operations use location-based routes without a project parame
},
})
expect(await client.worktree.list()).toEqual([{ directory: "/tmp/project" }])
expect(await client.worktree.list({ projectID: "project" })).toEqual([{ directory: "/tmp/project" }])
expect(
await client.worktree.create({
strategy: "git",
projectID: "project",
directory: "/tmp/worktrees",
name: "api",
}),
).toEqual({ directory: "/tmp/worktrees/api" })
await client.worktree.remove({
projectID: "project",
directory: "/tmp/worktrees/api",
force: false,
})
await client.worktree.refresh()
await client.worktree.refresh({ projectID: "project" })
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/worktree"],
["GET", "http://localhost:3000/api/worktree?projectID=project"],
["POST", "http://localhost:3000/api/worktree"],
["DELETE", "http://localhost:3000/api/worktree"],
["POST", "http://localhost:3000/api/worktree/refresh"],
])
expect(await requests[1]?.json()).toEqual({
strategy: "git",
projectID: "project",
directory: "/tmp/worktrees",
name: "api",
})
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
expect(await requests[2]?.json()).toEqual({ projectID: "project", directory: "/tmp/worktrees/api", force: false })
expect(await requests[3]?.json()).toEqual({ projectID: "project" })
})
test("worktree operations send the configuration location separately from their payload", async () => {
test("worktree operations use the explicit project even with default location headers", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
headers: { "x-opencode-directory": "/unrelated" },
fetch: async (input, init) => {
const request = new Request(input, init)
requests.push(request)
@@ -391,24 +394,24 @@ test("worktree operations send the configuration location separately from their
return Response.json({ directory: "/configured/task" })
},
})
expect(await client.worktree.create({ location: { directory: "/repo/nested" }, name: "task" })).toEqual({
expect(await client.worktree.create({ projectID: "project", name: "task" })).toEqual({
directory: "/configured/task",
})
expect(requests[0]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await requests[0]?.json()).toEqual({ name: "task" })
expect(requests[0]?.url).toBe("http://localhost:3000/api/worktree")
expect(await requests[0]?.json()).toEqual({ projectID: "project", name: "task" })
await client.worktree.remove({
location: { directory: "/repo/nested" },
projectID: "project",
directory: "/configured/task",
force: true,
})
await client.worktree.refresh({ location: { directory: "/repo/nested" } })
expect(requests[1]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await requests[1]?.json()).toEqual({ directory: "/configured/task", force: true })
expect(requests[2]?.url).toBe("http://localhost:3000/api/worktree/refresh?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await client.worktree.list({ location: { directory: "/repo/nested" } })).toEqual([
await client.worktree.refresh({ projectID: "project" })
expect(requests[1]?.url).toBe("http://localhost:3000/api/worktree")
expect(await requests[1]?.json()).toEqual({ projectID: "project", directory: "/configured/task", force: true })
expect(requests[2]?.url).toBe("http://localhost:3000/api/worktree/refresh")
expect(await client.worktree.list({ projectID: "project" })).toEqual([
{ directory: "/configured/task", strategy: "git" },
])
expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?projectID=project")
})
test("workspace.destroy returns the transition result", async () => {
+2 -2
View File
@@ -7,7 +7,7 @@ import { Config } from "../../config.js"
import { Global } from "@opencode/util/global"
import { Location } from "../../location.js"
import { AbsolutePath } from "../../schema.js"
import { Worktree } from "../../worktree.js"
import { WorktreeStrategies } from "../../worktree/strategies.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
@@ -16,7 +16,7 @@ export const Plugin = define({
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
const worktrees = yield* Worktree.Service
const worktrees = yield* WorktreeStrategies.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, worktrees.reload())
yield* worktrees.transform((editor) => {
for (const entry of loaded.entries) {
+2 -4
View File
@@ -25,8 +25,7 @@ import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
import { InstancePlugins } from "./plugin/instance.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { WorktreeRefresh } from "./worktree/refresh.js"
import { Worktree } from "./worktree.js"
import { WorktreeStrategies } from "./worktree/strategies.js"
import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
import { ShellSelect } from "./shell/select.js"
@@ -72,8 +71,7 @@ const nodes = [
PluginHooks.node,
InstancePlugins.node,
PluginSupervisor.node,
WorktreeRefresh.node,
Worktree.node,
WorktreeStrategies.node,
FileSystemSearch.node,
FileSystem.node,
ShellSelect.node,
+10 -21
View File
@@ -31,6 +31,7 @@ import { Workspace } from "../workspace.js"
import { Vcs } from "../vcs.js"
import { WebSearch } from "../websearch.js"
import { Worktree } from "../worktree.js"
import { WorktreeStrategies } from "../worktree/strategies.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
@@ -71,6 +72,8 @@ export const make = Effect.fn("PluginHost.make")(function* (
const persistentPty = yield* PersistentPty.Service
const locations = yield* LocationServiceMap.Service
const worktrees = yield* Worktree.Service
const worktreeStrategies = yield* WorktreeStrategies.Service
const currentWorktreeStrategies = location.workspaceID ? undefined : worktreeStrategies
const locationInfo = () =>
new Location.Info({
directory: location.directory,
@@ -90,21 +93,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
const atWorktree = <A, E>(
ref: Location.Ref | undefined,
run: (service: Worktree.Interface) => Effect.Effect<A, E>,
) => {
if (ref?.workspaceID) return Effect.fail(new Worktree.UnsupportedLocationError({ directory: ref.directory }))
if (!ref || isCurrentLocation(ref)) return run(worktrees)
return Effect.gen(function* () {
// Defer this import: Plugin's construction depends on this host. Same-location setup calls never wait on themselves.
const { Plugin } = yield* Effect.promise(() => import("../plugin.js"))
const plugins = yield* Plugin.Service
const target = yield* Worktree.Service
yield* plugins.awaitActivation
return yield* run(target)
}).pipe(Effect.provide(locations.get(ref)))
}
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
@@ -484,13 +472,13 @@ export const make = Effect.fn("PluginHost.make")(function* (
}),
},
worktree: {
list: (input) => atWorktree(locationRef(input), (service) => service.list()),
create: (input) => atWorktree(locationRef(input), (service) => service.create(input)),
refresh: (input) => atWorktree(locationRef(input), (service) => service.refresh()).pipe(Effect.asVoid),
remove: (input) => atWorktree(locationRef(input), (service) => service.remove(input)),
reload: worktrees.reload,
list: worktrees.list,
create: (input) => worktrees.create(input, currentWorktreeStrategies),
refresh: (input) => worktrees.refresh(input, currentWorktreeStrategies).pipe(Effect.asVoid),
remove: (input) => worktrees.remove(input, currentWorktreeStrategies),
reload: worktreeStrategies.reload,
transform: (callback) =>
worktrees.transform((editor) =>
worktreeStrategies.transform((editor) =>
callback({
add: (definition) =>
editor.add({
@@ -553,6 +541,7 @@ export const requirements = LayerNode.group([
Vcs.node,
WebSearch.node,
Worktree.node,
WorktreeStrategies.node,
Generate.node,
Permission.node,
PluginHooks.node,
+4 -1
View File
@@ -29,6 +29,7 @@ import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
import { Worktree } from "../worktree.js"
import { WorktreeStrategies } from "../worktree/strategies.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
import { FileAccess } from "../file-access.js"
@@ -139,6 +140,7 @@ const services = [
Watcher.Service,
WellKnown.Service,
Worktree.Service,
WorktreeStrategies.Service,
] as const
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
@@ -188,11 +190,13 @@ export const requirements = LayerNode.group([
Watcher.node,
WellKnown.node,
Worktree.node,
WorktreeStrategies.node,
])
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ConfigWorktreePlugin.Plugin,
BrowserPlugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
@@ -239,7 +243,6 @@ const post = [
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
+100 -103
View File
@@ -1,18 +1,19 @@
export * as Worktree from "./worktree.js"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { FSUtil } from "@opencode/util/fs-util"
import { Git } from "./git.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Global } from "@opencode/util/global"
import { ProjectSchema } from "./project/schema.js"
import { Node } from "@opencode/util/effect/app-node"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Slug } from "./util/slug.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { Project } from "./project.js"
import { Worktree } from "@opencode/schema/worktree"
import { WorktreeTable } from "./worktree/sql.js"
import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
@@ -21,7 +22,9 @@ import type { EffectDrizzleSqlite } from "./database/drizzle.js"
import { ProjectTable } from "./project/sql.js"
import { AppProcess } from "@opencode/util/process"
import { ChildProcess } from "effect/unstable/process"
import { State } from "./state.js"
import { WorktreeStrategies } from "./worktree/strategies.js"
export type { Strategy, Editor } from "./worktree/strategies.js"
export { DirectoryUnavailableError } from "./worktree/directory.js"
export { OperationError } from "@opencode/schema/worktree"
@@ -52,7 +55,7 @@ export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedError<SourceDirectoryNotFoundError>()(
"Worktree.SourceDirectoryNotFoundError",
{ projectID: ProjectSchema.ID, directory: Schema.optional(AbsolutePath) },
{ projectID: Project.ID, directory: Schema.optional(AbsolutePath) },
) {}
export class DestinationExistsError extends Schema.TaggedError<DestinationExistsError>()(
@@ -70,33 +73,17 @@ export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavail
{ strategy: StrategyID },
) {}
export class UnsupportedLocationError extends Schema.TaggedError<UnsupportedLocationError>()(
"Worktree.UnsupportedLocationError",
{ directory: AbsolutePath },
) {}
export type Error =
| Project.NotFoundError
| SourceDirectoryNotFoundError
| DestinationExistsError
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| UnsupportedLocationError
| Worktree.OperationError
| AppProcess.AppProcessError
| Git.WorktreeError
export interface Strategy {
readonly id: StrategyID
readonly create: (input: {
sourceDirectory: AbsolutePath
directory: AbsolutePath
branch?: string
}) => Effect.Effect<Info, unknown>
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly ListEntry[], unknown>
}
export const Event = Worktree.Event
interface StoredInput {
@@ -108,19 +95,18 @@ interface StoredInput {
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export interface Editor {
readonly add: (strategy: Strategy) => void
readonly configure: (settings: { readonly directory: AbsolutePath }) => void
export interface Interface {
readonly list: (input: { projectID: Project.ID }) => Effect.Effect<List, Project.NotFoundError>
// The plugin bridge supplies its registry so canonical-project setup can use registrations made so far.
readonly create: (input: CreateInput, current?: WorktreeStrategies.Interface) => Effect.Effect<Info, Error>
readonly remove: (input: RemoveInput, current?: WorktreeStrategies.Interface) => Effect.Effect<void, Error>
readonly refresh: (
input: { projectID: Project.ID },
current?: WorktreeStrategies.Interface,
) => Effect.Effect<RefreshResult, Error>
}
export interface Interface extends State.Transformable<Editor> {
readonly list: () => Effect.Effect<List, Error>
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: () => Effect.Effect<RefreshResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Worktree") {}
const layer = Layer.effect(
Service,
@@ -130,39 +116,28 @@ const layer = Layer.effect(
const db = database.db
const bus = yield* Bus.Service
const processService = yield* AppProcess.Service
const location = yield* Location.Service
const global = yield* Global.Service
const projectID = location.project.id
const local = location.workspaceID
? Effect.fail(new UnsupportedLocationError({ directory: location.directory }))
: Effect.void
const locations = yield* LocationServiceMap.Service
const gitStrategy = yield* WorktreeGit.make
const state = State.create({
name: "worktree",
initial: () => ({
directory: AbsolutePath.make(path.join(global.data, "worktree", projectID.slice(0, 6))),
strategies: new Map<StrategyID, Strategy>([[gitStrategy.id, gitStrategy]]),
selected: gitStrategy.id,
}),
editor: (value): Editor => ({
configure: (settings) => {
value.directory = settings.directory
},
add: (strategy) => {
value.strategies.delete(strategy.id)
value.strategies.set(strategy.id, strategy)
value.selected = strategy.id
},
}),
const project = Effect.fnUntraced(function* (projectID: Project.ID) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie)
if (!row) return yield* new Project.NotFoundError({ projectID })
return row
})
const changed = Effect.fnUntraced(function* (update: boolean) {
const load = Effect.fnUntraced(function* (directory: AbsolutePath, current?: WorktreeStrategies.Interface) {
if (!(yield* fs.isDir(directory))) return yield* new DirectoryUnavailableError({ directory })
if (current?.directory === directory) return current.get()
const { Plugin } = yield* Effect.promise(() => import("./plugin.js"))
const context = yield* locations.contextEffect(Location.Ref.make({ directory }))
yield* Context.get(context, Plugin.Service).awaitActivation
return Context.get(context, WorktreeStrategies.Service).get()
})
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
})
const ops = {
const inventory = (projectID: Project.ID) => ({
list: Effect.fnUntraced(function* () {
const rows = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
@@ -214,28 +189,30 @@ const layer = Layer.effect(
Effect.orDie,
Effect.map((row) => row !== undefined),
),
}
})
const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined) {
const sourceDirectory = input ?? location.project.directory
const source = Effect.fnUntraced(function* (projectID: Project.ID, sourceDirectory: AbsolutePath) {
const resolved = yield* canonical(fs, sourceDirectory)
if ((yield* ops.find(resolved)) === undefined)
if ((yield* inventory(projectID).find(resolved)) === undefined)
return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved })
return resolved
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID, strategies: ReadonlyMap<StrategyID, Strategy>) {
const getStrategy = Effect.fnUntraced(function* (
id: StrategyID,
strategies: ReadonlyMap<StrategyID, WorktreeStrategies.Strategy>,
) {
const found = strategies.get(id)
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
return found
})
const create = Effect.fn("Worktree.create")(function* (input: CreateInput = {}) {
yield* local
const current = state.get()
const selected = yield* getStrategy(input.strategy ?? current.selected, current.strategies)
const directory = input.directory ?? current.directory
const sourceDirectory = yield* source(input.from)
const create = Effect.fn("Worktree.create")(function* (input: CreateInput, current?: WorktreeStrategies.Interface) {
const row = yield* project(input.projectID)
const settings = yield* load(row.worktree, current)
const selected = yield* getStrategy(settings.selected, settings.strategies)
const directory = input.directory ?? settings.directory
const sourceDirectory = yield* source(input.projectID, input.from ?? row.worktree)
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
@@ -255,19 +232,14 @@ const layer = Layer.effect(
.pipe(Effect.mapError((error) => operationError(selected.id, "create", error)))
const result = { directory: yield* canonical(fs, created.directory) }
yield* changed(
yield* ops.create({
input.projectID,
yield* inventory(input.projectID).create({
directory: result.directory,
strategy: selected.id,
replace: true,
}),
)
const project = yield* db
.select({ commands: ProjectTable.commands })
.from(ProjectTable)
.where(eq(ProjectTable.id, projectID))
.get()
.pipe(Effect.orDie)
const command = project?.commands?.start?.trim()
const command = row.commands?.start?.trim()
if (command) {
const windows = process.platform === "win32"
yield* processService
@@ -286,39 +258,66 @@ const layer = Layer.effect(
.pipe(Effect.flatMap(AppProcess.requireSuccess))
}
return result
})
}, Effect.scoped)
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
yield* local
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput, current?: WorktreeStrategies.Interface) {
const row = yield* project(input.projectID)
const ops = inventory(input.projectID)
const worktreeDirectory = yield* canonical(fs, input.directory)
const stored = yield* ops.find(worktreeDirectory)
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), state.get().strategies)
// Inspect only an already-loaded canonical registry. Removing must never boot config or plugins.
const strategies =
current?.directory === row.worktree
? current.get().strategies
: yield* locations.contextEffectOption(Location.Ref.make({ directory: row.worktree })).pipe(
Effect.map(
Option.match({
onSome: (context) => Context.get(context, WorktreeStrategies.Service).get().strategies,
onNone: () => new Map([[gitStrategy.id, gitStrategy]]),
}),
),
)
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), strategies)
yield* strategy
.remove({
directory: worktreeDirectory,
force: input.force,
})
.pipe(Effect.mapError((error) => operationError(strategy.id, "remove", error)))
yield* changed(yield* ops.remove(worktreeDirectory))
})
yield* changed(input.projectID, yield* ops.remove(worktreeDirectory))
}, Effect.scoped)
const refresh = Effect.fn("Worktree.refresh")(function* () {
yield* local
const refresh = Effect.fn("Worktree.refresh")(function* (
input: { projectID: Project.ID },
current?: WorktreeStrategies.Interface,
) {
const row = yield* project(input.projectID)
const settings = yield* load(row.worktree, current)
const ops = inventory(input.projectID)
const stored = yield* ops.list()
const checked = yield* Effect.forEach(
stored,
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
{ concurrency: "unbounded" },
)
const strategies = Array.from(state.get().strategies.values()).toReversed()
const strategies = Array.from(settings.strategies.values()).toReversed()
const discovered = new Map<AbsolutePath, StoredInput>()
// A location's plugin instances only discover its own checkout, not sibling clones.
if (checked.some((item) => item.directory === location.project.directory && item.exists)) {
// Unowned rows are checkout/discovery roots. Managed children are enumerated by their backend.
const roots = new Set([
row.worktree,
...checked.filter((item) => item.exists && !item.strategy).map((item) => item.directory),
])
for (const directory of roots) {
if (!(yield* fs.isDir(directory))) continue
for (const strategy of strategies) {
const entries = yield* strategy.list(location.project.directory).pipe(
const entries = yield* strategy.list(directory).pipe(
Effect.mapError((error) => operationError(strategy.id, "list", error)),
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.catch((error) =>
Effect.logWarning("worktree discovery failed", { directory, strategy: strategy.id, error }).pipe(
Effect.as([]),
),
),
)
for (const entry of entries) {
const directory = yield* canonical(fs, entry.directory).pipe(
@@ -343,16 +342,14 @@ const layer = Layer.effect(
}),
)
.pipe(Effect.orDie)
yield* changed(changes.updated.length > 0 || changes.removed.length > 0)
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
}, Effect.scoped)
return Service.of({
transform: state.transform,
reload: state.reload,
list: Effect.fn("Worktree.list")(function* () {
yield* refresh()
return yield* ops.list()
list: Effect.fn("Worktree.list")(function* (input) {
yield* project(input.projectID)
return yield* inventory(input.projectID).list()
}),
create,
remove,
@@ -361,10 +358,10 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({
export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.global> = Node.makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, Location.node, Global.node],
layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, LocationServiceMap.node],
})
function operationError(strategy: StrategyID, operation: string, error: unknown) {
+2 -2
View File
@@ -5,7 +5,7 @@ import { Worktree } from "@opencode/schema/worktree"
import { FSUtil } from "@opencode/util/fs-util"
import { Git } from "../git.js"
import { canonical, DirectoryUnavailableError } from "./directory.js"
import type { ListEntry, Strategy } from "../worktree.js"
import type { Strategy } from "./strategies.js"
export const make = Effect.gen(function* () {
const fs = yield* FSUtil.Service
@@ -33,7 +33,7 @@ export const make = Effect.gen(function* () {
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "worktree" }) as const),
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.undefined),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
).pipe(Effect.map((items) => items.filter((item): item is Worktree.ListEntry => item !== undefined)))
}),
} satisfies Strategy
})
-28
View File
@@ -1,28 +0,0 @@
export * as WorktreeRefresh from "./refresh.js"
import { Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Location } from "../location.js"
import { Plugin } from "../plugin.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Worktree } from "../worktree.js"
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const plugins = yield* Plugin.Service
const worktrees = yield* Worktree.Service
if (location.workspaceID) return
yield* plugins.awaitActivation.pipe(
Effect.andThen(worktrees.refresh()),
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
Effect.forkScoped,
)
}),
)
export const node = makeLocationNode({
name: "worktree-refresh",
layer,
deps: [Worktree.node, Location.node, Plugin.node, PluginSupervisor.node],
})
+74
View File
@@ -0,0 +1,74 @@
export * as WorktreeStrategies from "./strategies.js"
import { Context, Effect, Layer } from "effect"
import path from "path"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Global } from "@opencode/util/global"
import { Worktree } from "@opencode/schema/worktree"
import { AbsolutePath } from "../schema.js"
import { Git } from "../git.js"
import { Location } from "../location.js"
import { State } from "../state.js"
import { WorktreeGit } from "./git.js"
import { FSUtil } from "@opencode/util/fs-util"
export interface Strategy {
readonly id: Worktree.StrategyID
readonly create: (input: {
sourceDirectory: AbsolutePath
directory: AbsolutePath
branch?: string
}) => Effect.Effect<Worktree.Info, unknown>
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly Worktree.ListEntry[], unknown>
}
export interface Editor {
readonly add: (strategy: Strategy) => void
readonly configure: (settings: { readonly directory: AbsolutePath }) => void
}
export interface Interface extends State.Transformable<Editor> {
readonly directory: AbsolutePath
readonly get: () => {
readonly directory: AbsolutePath
readonly strategies: ReadonlyMap<Worktree.StrategyID, Strategy>
readonly selected: Worktree.StrategyID
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WorktreeStrategies") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const global = yield* Global.Service
const git = yield* WorktreeGit.make
const state = State.create({
name: "worktree",
initial: () => ({
directory: AbsolutePath.make(path.join(global.data, "worktree", location.project.id.slice(0, 6))),
strategies: new Map<Worktree.StrategyID, Strategy>([[git.id, git]]),
selected: git.id,
}),
editor: (value): Editor => ({
configure: (settings) => {
value.directory = settings.directory
},
add: (strategy) => {
value.strategies.delete(strategy.id)
value.strategies.set(strategy.id, strategy)
value.selected = strategy.id
},
}),
})
return Service.of({ ...state, directory: location.directory })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Location.node, Global.node, Git.node, FSUtil.node],
})
+2
View File
@@ -33,6 +33,7 @@ import { Tool } from "@opencode/core/tool"
import { Vcs } from "@opencode/core/vcs"
import { WebSearch } from "@opencode/core/websearch"
import { Worktree } from "@opencode/core/worktree"
import { WorktreeStrategies } from "@opencode/core/worktree/strategies"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
import { emptyMcpLayer } from "../fixture/mcp"
@@ -96,6 +97,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
Watcher.node,
WebSearch.node,
Worktree.node,
WorktreeStrategies.node,
]),
[
Location.node.replace(tempLocationLayer),
+103 -99
View File
@@ -13,6 +13,7 @@ import { Bus } from "@opencode/core/bus"
import { Project } from "@opencode/core/project"
import { ProjectTable } from "@opencode/core/project/sql"
import { Worktree } from "@opencode/core/worktree"
import { WorktreeStrategies } from "@opencode/core/worktree/strategies"
import { WorktreeDirectory } from "@opencode/core/worktree/directory"
import { WorktreeTable } from "@opencode/core/worktree/sql"
import { WorktreeGit } from "@opencode/core/worktree/git"
@@ -57,33 +58,51 @@ function worktreeLayer(
data: string,
workspaceID?: Workspace.ID,
) {
return AppNodeBuilder.build(LayerNode.group([Worktree.node, Git.node, FSUtil.node, Location.node, Global.node]), [
Database.node.replace(Layer.succeed(Database.Service, database)),
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
Global.node.replace(Global.layerWith({ data })),
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of({
directory,
workspaceID,
project: { id: projectID, directory, canonical: directory },
}),
return AppNodeBuilder.build(
LayerNode.group([Worktree.node, WorktreeStrategies.node, Git.node, FSUtil.node, Location.node, Global.node]),
[
Database.node.replace(Layer.succeed(Database.Service, database)),
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
Global.node.replace(Global.layerWith({ data })),
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of({
directory,
workspaceID,
project: { id: projectID, directory, canonical: directory },
}),
),
),
),
]).pipe(Layer.fresh)
],
).pipe(Layer.fresh)
}
function abs(input: string) {
return AbsolutePath.make(input)
}
const gitWorktree = Worktree.StrategyID.make("git")
const setup = Effect.fnUntraced(function* () {
return yield* Fixture
})
// Bind the fixture's project and already-registered plugin strategies for backend tests.
const fixtureWorktree = Effect.fnUntraced(function* () {
const input = yield* Fixture
const service = yield* Worktree.Service
const strategies = yield* WorktreeStrategies.Service
return {
transform: strategies.transform,
reload: strategies.reload,
list: () => service.list({ projectID: input.projectID }),
create: (options: Omit<Worktree.CreateInput, "projectID"> = {}) =>
service.create({ projectID: input.projectID, ...options }, strategies),
remove: (options: Omit<Worktree.RemoveInput, "projectID">) =>
service.remove({ projectID: input.projectID, ...options }, strategies),
refresh: () => service.refresh({ projectID: input.projectID }, strategies),
}
})
function makeFixture() {
return Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
@@ -154,19 +173,18 @@ describe("Worktree", () => {
}),
)
it.effect("reports unavailable strategy ids", () =>
it.live("reports unavailable recorded strategy ids", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const unavailable = Worktree.StrategyID.make("acme/missing")
const error = yield* worktree
.create({
strategy: unavailable,
from: input.sourceDirectory,
directory: abs(`${input.root.path}-missing-strategy`),
name: "worktree",
})
.pipe(Effect.flip)
yield* input.db
.update(WorktreeTable)
.set({ strategy: unavailable })
.where(eq(WorktreeTable.project_id, input.projectID))
.run()
.pipe(Effect.orDie)
const error = yield* worktree.remove({ directory: input.sourceDirectory, force: false }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
if (error instanceof Worktree.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
}),
@@ -180,11 +198,10 @@ describe("Worktree", () => {
.where(eq(WorktreeTable.project_id, input.projectID))
.run()
.pipe(Effect.orDie)
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const error = yield* worktree
.create({
strategy: gitWorktree,
from: input.sourceDirectory,
directory: abs(`${input.root.path}-missing-source`),
name: "worktree",
@@ -199,7 +216,7 @@ describe("Worktree", () => {
it.live("creates and removes a git worktree directory", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const bus = yield* Bus.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-created"))
@@ -211,7 +228,6 @@ describe("Worktree", () => {
yield* Effect.yieldNow
const created = yield* worktree.create({
strategy: gitWorktree,
directory: parent,
name: "worktree",
})
@@ -234,17 +250,15 @@ describe("Worktree", () => {
it.live("defaults to the TUI worktree directory and suffixes duplicate names", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const global = yield* Global.Service
const parent = path.join(global.data, "worktree", "worktr")
const created = yield* worktree.create({
strategy: gitWorktree,
from: input.sourceDirectory,
name: "task",
})
const duplicate = yield* worktree.create({
strategy: gitWorktree,
from: input.sourceDirectory,
name: "task",
})
@@ -260,7 +274,7 @@ describe("Worktree", () => {
it.live("runs the project setup script with worktree paths", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-setup"))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
@@ -276,7 +290,6 @@ describe("Worktree", () => {
.run()
.pipe(Effect.orDie)
const created = yield* worktree.create({
strategy: gitWorktree,
directory: parent,
name: "worktree",
})
@@ -290,7 +303,7 @@ describe("Worktree", () => {
}),
)
projectIt.live("creates worktrees and runs setup from the selected clone", () =>
projectIt.live("uses canonical configuration with an explicit source clone", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -313,7 +326,7 @@ describe("Worktree", () => {
const selected = yield* projects.resolve(clone)
const database = yield* Database.Service
const bus = yield* Bus.Service
const context = yield* Layer.build(worktreeLayer(selected.directory, selected.id, database, bus, root.path))
const context = yield* Layer.build(worktreeLayer(main, selected.id, database, bus, root.path))
const worktrees = Context.get(context, Worktree.Service)
const config = yield* Config.Test
yield* config.setEntries([
@@ -332,14 +345,17 @@ describe("Worktree", () => {
},
})
const created = yield* worktrees.create({
strategy: gitWorktree,
from: selected.canonical,
name: "selected-clone",
})
const created = yield* worktrees.create(
{
projectID: initial.id,
from: selected.canonical,
name: "selected-clone",
},
Context.get(context, WorktreeStrategies.Service),
)
expect(selected.id).toBe(initial.id)
expect(created.directory).toBe(abs(path.join(clone, ".lane/trees/selected-clone")))
expect(created.directory).toBe(abs(path.join(main, ".lane/trees/selected-clone")))
expect((yield* projects.list()).find((project) => project.id === initial.id)?.canonical).toBe(main)
expect(yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(created.directory).text())).toBe(
yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(clone).text()),
@@ -355,7 +371,7 @@ describe("Worktree", () => {
it.live("creates a git worktree from a selected branch", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const parent = abs(`${input.root.path}-branch-worktree`)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
yield* Effect.promise(async () => {
@@ -363,7 +379,6 @@ describe("Worktree", () => {
})
const created = yield* worktree.create({
strategy: gitWorktree,
branch: "feature-base",
directory: parent,
name: "worktree",
@@ -380,13 +395,12 @@ describe("Worktree", () => {
it.live("does not interpret a branch as a git option", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const parent = abs(`${input.root.path}-option-worktree`)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
const error = yield* worktree
.create({
strategy: gitWorktree,
branch: "--no-checkout",
directory: parent,
name: "worktree",
@@ -401,12 +415,11 @@ describe("Worktree", () => {
it.live("rejects a missing source directory", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const error = yield* worktree
.create({
strategy: gitWorktree,
from: abs(path.join(temp, "does-not-exist")),
directory: abs(`${input.root.path}-missing-directory`),
name: "worktree",
@@ -420,7 +433,7 @@ describe("Worktree", () => {
it.live("creates from another managed worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const sourceParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-source"))
const targetParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-target"))
@@ -431,7 +444,6 @@ describe("Worktree", () => {
]).pipe(Effect.asVoid),
)
const source = yield* worktree.create({
strategy: gitWorktree,
from: input.sourceDirectory,
directory: sourceParent,
name: "source",
@@ -443,7 +455,6 @@ describe("Worktree", () => {
.pipe(Effect.orDie)
const created = yield* worktree.create({
strategy: gitWorktree,
from: source.directory,
directory: targetParent,
name: "target",
@@ -458,12 +469,11 @@ describe("Worktree", () => {
it.live("requires force to remove a dirty git worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
const created = yield* worktree.create({
strategy: gitWorktree,
from: input.sourceDirectory,
directory: parent,
name: "worktree",
@@ -488,7 +498,7 @@ describe("Worktree", () => {
it.live("preserves worktrees whose stored strategy is unavailable", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const unavailable = abs(`${input.root.path}-worktree-unavailable`)
yield* Effect.promise(() => fs.mkdir(unavailable))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
@@ -508,7 +518,7 @@ describe("Worktree", () => {
it.live("adds a numeric suffix when a worktree directory already exists", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-suffix"))
const target = abs(path.join(parent, "worktree-3"))
@@ -517,7 +527,6 @@ describe("Worktree", () => {
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
const created = yield* worktree.create({
strategy: gitWorktree,
from: input.sourceDirectory,
directory: parent,
name: "worktree",
@@ -538,7 +547,7 @@ describe("Worktree", () => {
it.live("fails after ten worktree directory conflicts", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-conflicts"))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
@@ -552,7 +561,6 @@ describe("Worktree", () => {
const error = yield* worktree
.create({
strategy: gitWorktree,
from: input.sourceDirectory,
directory: parent,
name: "worktree",
@@ -567,7 +575,7 @@ describe("Worktree", () => {
it.live("does not publish an event when refresh finds no directory changes", () =>
Effect.gen(function* () {
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const bus = yield* Bus.Service
const event = yield* bus.subscribe(Worktree.Event.Updated).pipe(
Stream.take(1),
@@ -589,7 +597,7 @@ describe("Worktree", () => {
it.live("refresh discovers and prunes an externally managed git worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const bus = yield* Bus.Service
const target = abs(`${input.root.path}-worktree-external`)
const unchanged = abs(`${input.root.path}-worktree-existing`)
@@ -641,7 +649,7 @@ describe("Worktree", () => {
() =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
const stale = abs(`${input.root.path}-worktree-stale`)
const target = abs(`${input.root.path}-worktree-after-stale`)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(target, { recursive: true, force: true })))
@@ -666,7 +674,7 @@ describe("Worktree", () => {
Effect.gen(function* () {
const input = yield* setup()
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
yield* worktree.refresh()
@@ -674,7 +682,7 @@ describe("Worktree", () => {
}),
)
it.live("refresh with no roots is a no-op", () =>
it.live("refresh seeds the canonical checkout when inventory is empty", () =>
Effect.gen(function* () {
const input = yield* setup()
yield* input.db
@@ -682,10 +690,10 @@ describe("Worktree", () => {
.where(eq(WorktreeTable.project_id, input.projectID))
.run()
.pipe(Effect.orDie)
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
expect(yield* worktree.refresh()).toEqual({
updated: [],
updated: [input.sourceDirectory],
removed: [],
})
}),
@@ -700,7 +708,7 @@ describe("Worktree", () => {
.values({ project_id: input.projectID, directory: missing })
.run()
.pipe(Effect.orDie)
const worktree = yield* Worktree.Service
const worktree = yield* fixtureWorktree()
expect(yield* worktree.refresh()).toEqual({ updated: [], removed: [missing] })
@@ -711,7 +719,7 @@ describe("Worktree", () => {
it.live("defaults to Git and configured directory without depending on Config", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const worktrees = yield* fixtureWorktree()
const parent = abs(path.join(input.root.path, "configured"))
const registration = yield* worktrees.transform((editor) => editor.configure({ directory: parent }))
const created = yield* worktrees.create({ name: "configured" })
@@ -731,7 +739,7 @@ describe("Worktree", () => {
it.live("selects the last active registration and restores earlier strategies on disposal", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const worktrees = yield* fixtureWorktree()
const git = yield* WorktreeGit.make
const parent = abs(path.join(input.root.path, "strategies"))
const first = yield* worktrees.transform((editor) =>
@@ -761,7 +769,7 @@ describe("Worktree", () => {
it.live("does not fall back to Git when a registered strategy fails", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const worktrees = yield* fixtureWorktree()
const git = yield* WorktreeGit.make
yield* worktrees.transform((editor) =>
editor.add({
@@ -774,19 +782,13 @@ describe("Worktree", () => {
const error = yield* worktrees.create({ directory: parent, name: "failure" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.OperationError)
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
const explicit = yield* worktrees.create({
directory: parent,
name: "explicit",
strategy: gitWorktree,
})
expect(yield* stored(input.projectID)).toContainEqual({ directory: explicit.directory, strategy: "git" })
}),
)
it.live("rejects a source override belonging to another project", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const worktrees = yield* fixtureWorktree()
const projects = yield* Project.Service
const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
yield* Effect.promise(() => initRepo(other.path))
@@ -798,9 +800,9 @@ describe("Worktree", () => {
}),
)
it.live("cannot remove a worktree from another project through the current location", () =>
it.live("cannot remove a worktree belonging to another project", () =>
Effect.gen(function* () {
const worktrees = yield* Worktree.Service
const worktrees = yield* fixtureWorktree()
const projects = yield* Project.Service
const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
yield* Effect.promise(() => initRepo(other.path))
@@ -814,7 +816,7 @@ describe("Worktree", () => {
}),
)
it.live("rejects workspace-qualified locations before running worktree operations", () =>
it.live("list is independent of an ambient workspace-qualified location", () =>
Effect.gen(function* () {
const input = yield* setup()
const database = yield* Database.Service
@@ -831,23 +833,17 @@ describe("Worktree", () => {
),
)
const worktrees = Context.get(context, Worktree.Service)
const directory = abs(path.join(input.root.path, "not-created"))
const errors = yield* Effect.all([
worktrees.list().pipe(Effect.flip),
worktrees.create({ directory, name: "task" }).pipe(Effect.flip),
worktrees.remove({ directory: input.sourceDirectory, force: true }).pipe(Effect.flip),
worktrees.refresh().pipe(Effect.flip),
expect(yield* worktrees.list({ projectID: input.projectID })).toEqual([
{ directory: input.sourceDirectory, strategy: undefined },
])
for (const error of errors) expect(error).toBeInstanceOf(Worktree.UnsupportedLocationError)
expect(yield* fs.existsSafe(directory)).toBe(false)
expect(yield* fs.isDir(input.sourceDirectory)).toBe(true)
}),
)
it.live("list invokes the location's strategies before returning inventory", () =>
it.live("only refresh discovers and prunes; list reads saved inventory", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const worktrees = yield* fixtureWorktree()
const git = yield* WorktreeGit.make
const directory = abs(path.join(input.root.path, "discovered"))
yield* Effect.promise(() => fs.mkdir(directory))
@@ -863,18 +859,24 @@ describe("Worktree", () => {
}),
}),
)
expect(yield* worktrees.list()).not.toContainEqual({ directory, strategy: "discovered-copy" })
expect(sources).toEqual([])
yield* worktrees.refresh()
expect(yield* worktrees.list()).toContainEqual({ directory, strategy: "discovered-copy" })
expect(sources).toEqual([input.sourceDirectory])
expect(yield* stored(input.projectID)).toContainEqual({ directory, strategy: "discovered-copy" })
yield* Effect.promise(() => fs.rmdir(directory))
expect(yield* worktrees.list()).toContainEqual({ directory, strategy: "discovered-copy" })
yield* worktrees.refresh()
expect(yield* worktrees.list()).not.toContainEqual({ directory, strategy: "discovered-copy" })
expect(sources).toEqual([input.sourceDirectory, input.sourceDirectory])
}),
)
it.live("list surfaces strategy discovery failures", () =>
it.live("a failed strategy does not block list or other discovery", () =>
Effect.gen(function* () {
const worktrees = yield* Worktree.Service
const input = yield* setup()
const worktrees = yield* fixtureWorktree()
const git = yield* WorktreeGit.make
yield* worktrees.transform((editor) =>
editor.add({
@@ -883,9 +885,10 @@ describe("Worktree", () => {
list: () => Effect.fail(new Error("Cannot enumerate worktrees")),
}),
)
const error = yield* worktrees.list().pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.OperationError)
if (error instanceof Worktree.OperationError) expect(error.message).toContain("Cannot enumerate worktrees")
const target = abs(path.join(input.root.path, "external"))
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* worktrees.refresh()
expect(yield* worktrees.list()).toContainEqual({ directory: target, strategy: "git" })
}),
)
@@ -893,9 +896,10 @@ describe("Worktree", () => {
Effect.gen(function* () {
const input = yield* setup()
const config = yield* Config.Test
const worktrees = yield* Worktree.Service
const worktrees = yield* fixtureWorktree()
const bus = yield* Bus.Service
const reloaded = yield* Queue.unbounded<void>()
const strategies = yield* WorktreeStrategies.Service
const documents = [
new Document({
type: "document",
@@ -914,8 +918,8 @@ describe("Worktree", () => {
yield* ConfigWorktreePlugin.Plugin.effect(
host({ event: { subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)) } }),
).pipe(
Effect.provideService(Worktree.Service, {
...worktrees,
Effect.provideService(WorktreeStrategies.Service, {
...strategies,
reload: () => worktrees.reload().pipe(Effect.tap(() => Queue.offer(reloaded, undefined))),
}),
)
@@ -941,7 +945,7 @@ describe("Worktree", () => {
const config = yield* Config.Test
const projects = yield* Project.Service
const global = yield* Global.Service
const worktrees = yield* Worktree.Service
const worktrees = yield* fixtureWorktree()
const linked = abs(path.join(input.root.path, "linked"))
const nested = abs(path.join(linked, "src"))
const home = abs(path.join(input.root.path, "home"))
+77 -178
View File
@@ -12052,44 +12052,12 @@
"operationId": "worktree.list",
"parameters": [
{
"name": "location",
"name": "projectID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12105,18 +12073,11 @@
}
},
"400": {
"description": "WorktreeError | InvalidRequestError",
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/WorktreeErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
@@ -12130,56 +12091,25 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Discover worktrees through the requested location's strategies and return its project's inventory.",
"description": "Return the project's saved worktree inventory without loading configuration or running discovery.",
"summary": "List worktrees"
},
"post": {
"tags": ["worktree"],
"operationId": "worktree.create",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"200": {
@@ -12218,9 +12148,19 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
"description": "Load the project's canonical configuration, create a local worktree using its selected strategy, then run the project's setup script.",
"summary": "Create worktree",
"requestBody": {
"content": {
@@ -12236,48 +12176,7 @@
"delete": {
"tags": ["worktree"],
"operationId": "worktree.remove",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -12309,9 +12208,19 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Remove a managed worktree from the requested location's project using its recorded strategy.",
"description": "Remove a saved project worktree using its recorded, already-available strategy. Does not load configuration.",
"summary": "Remove worktree",
"requestBody": {
"content": {
@@ -12329,48 +12238,7 @@
"post": {
"tags": ["worktree"],
"operationId": "worktree.refresh",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -12402,10 +12270,37 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Discover worktrees from the requested location and reconcile the shared project inventory.",
"summary": "Refresh worktrees"
"description": "Load the project's canonical configuration and discover worktrees across known checkout roots using all available strategies.",
"summary": "Refresh worktrees",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/workspace": {
@@ -20103,7 +19998,7 @@
"Worktree.CreateInput": {
"type": "object",
"properties": {
"strategy": {
"projectID": {
"type": "string"
},
"from": {
@@ -20119,6 +20014,7 @@
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
},
"Worktree.Directory": {
@@ -20153,6 +20049,9 @@
"Worktree.RemoveInput": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
},
"directory": {
"type": "string"
},
@@ -20160,7 +20059,7 @@
"type": "boolean"
}
},
"required": ["directory", "force"],
"required": ["projectID", "directory", "force"],
"additionalProperties": false
},
"WorktreeErrorEncoded": {
@@ -20296,7 +20195,7 @@
},
{
"name": "worktree",
"description": "Location-scoped worktree management routes."
"description": "Project-based worktree management routes."
},
{
"name": "workspace",
+2 -2
View File
@@ -54,7 +54,6 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof PtyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof WorktreeGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
@@ -90,6 +89,7 @@ type ApiGroups<
| typeof DebugGroup
| typeof MigrationGroup
| typeof WorkspaceGroup
| typeof WorktreeGroup
| typeof GenerateGroup
| typeof PersistentPtyGroup
| LocationGroups<LocationId>
@@ -176,7 +176,7 @@ const makeApiFromGroup = <
.add(PersistentPtyGroup)
.add(ShellGroup.middleware(locationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(WorktreeGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
.add(WorkspaceGroup)
.add(VcsGroup.middleware(locationMiddleware))
.add(DebugGroup)
+41 -48
View File
@@ -1,7 +1,8 @@
import { Worktree } from "@opencode/schema/worktree"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
import { Project } from "@opencode/schema/project"
import { ProjectNotFoundError } from "../errors.js"
const root = "/api/worktree"
@@ -19,66 +20,58 @@ export class WorktreeError extends Schema.Error<WorktreeError>("WorktreeError")(
export const WorktreeGroup = HttpApiGroup.make("server.worktree")
.add(
HttpApiEndpoint.get("worktree.list", root, {
query: LocationQuery,
query: Schema.Struct({ projectID: Project.ID }),
success: Worktree.List,
error: WorktreeError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "worktree.list",
summary: "List worktrees",
description:
"Discover worktrees through the requested location's strategies and return its project's inventory.",
}),
),
error: ProjectNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.list",
summary: "List worktrees",
description:
"Return the project's saved worktree inventory without loading configuration or running discovery.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.create", root, {
query: LocationQuery,
payload: Worktree.CreateInput,
success: Worktree.Info,
error: WorktreeError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "worktree.create",
summary: "Create worktree",
description:
"Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
}),
),
error: [WorktreeError, ProjectNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.create",
summary: "Create worktree",
description:
"Load the project's canonical configuration, create a local worktree using its selected strategy, then run the project's setup script.",
}),
),
)
.add(
HttpApiEndpoint.delete("worktree.remove", root, {
query: LocationQuery,
payload: Worktree.RemoveInput,
success: HttpApiSchema.NoContent,
error: WorktreeError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "worktree.remove",
summary: "Remove worktree",
description: "Remove a managed worktree from the requested location's project using its recorded strategy.",
}),
),
error: [WorktreeError, ProjectNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.remove",
summary: "Remove worktree",
description:
"Remove a saved project worktree using its recorded, already-available strategy. Does not load configuration.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.refresh", `${root}/refresh`, {
query: LocationQuery,
payload: Schema.Struct({ projectID: Project.ID }),
success: HttpApiSchema.NoContent,
error: WorktreeError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "worktree.refresh",
summary: "Refresh worktrees",
description: "Discover worktrees from the requested location and reconcile the shared project inventory.",
}),
),
error: [WorktreeError, ProjectNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.refresh",
summary: "Refresh worktrees",
description:
"Load the project's canonical configuration and discover worktrees across known checkout roots using all available strategies.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Location-scoped worktree management routes." }))
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Project-based worktree management routes." }))
+3 -2
View File
@@ -9,18 +9,19 @@ export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Sc
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Schema.Struct({
strategy: optional(StrategyID),
projectID: Project.ID,
from: optional(AbsolutePath),
branch: optional(Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()))),
directory: optional(AbsolutePath).annotate({
description:
"Parent directory for the new worktree. Uses the location's configuration, then defaults to the server's data directory under worktree/<first six project ID characters>.",
"Parent directory for the new worktree. Uses the project's canonical configuration, then defaults to the server's data directory under worktree/<first six project ID characters>.",
}),
name: optional(Schema.String),
}).annotate({ identifier: "Worktree.CreateInput" })
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
export const RemoveInput = Schema.Struct({
projectID: Project.ID,
directory: AbsolutePath,
force: Schema.Boolean,
}).annotate({ identifier: "Worktree.RemoveInput" })
+11 -9
View File
@@ -5,28 +5,30 @@ import { Worktree } from "../src/worktree.js"
describe("Worktree.CreateInput", () => {
test("allows the server to choose the destination", () => {
const input = Schema.decodeUnknownSync(Worktree.CreateInput)({
strategy: "git",
projectID: "project",
})
expect(input.directory).toBeUndefined()
expect(Schema.encodeSync(Worktree.CreateInput)({ ...input, directory: undefined })).toEqual({
strategy: "git",
projectID: "project",
})
})
test("preserves an explicit destination", () => {
const input = { strategy: "git", directory: "/custom/worktrees" }
const input = { projectID: "project", directory: "/custom/worktrees" }
expect(Schema.encodeSync(Worktree.CreateInput)(Schema.decodeUnknownSync(Worktree.CreateInput)(input))).toEqual(
input,
)
})
})
test("worktree mutation inputs do not require a project or explicit creation defaults", () => {
const value = Schema.decodeUnknownSync(Worktree.CreateInput)({ name: "task" })
expect(Schema.encodeSync(Worktree.CreateInput)(value)).toEqual({ name: "task" })
expect(Schema.encodeSync(Worktree.CreateInput)(Schema.decodeUnknownSync(Worktree.CreateInput)({}))).toEqual({})
expect(Worktree.CreateInput.fields).not.toHaveProperty("projectID")
expect(Worktree.RemoveInput.fields).not.toHaveProperty("projectID")
test("worktree mutations require a project and configuration owns strategy selection", () => {
const value = Schema.decodeUnknownSync(Worktree.CreateInput)({ projectID: "project", name: "task" })
expect(Schema.encodeSync(Worktree.CreateInput)(value)).toEqual({ projectID: "project", name: "task" })
expect(() => Schema.decodeUnknownSync(Worktree.CreateInput)({})).toThrow()
expect(() => Schema.decodeUnknownSync(Worktree.RemoveInput)({ directory: "/repo/task", force: false })).toThrow()
expect(Worktree.CreateInput.fields).not.toHaveProperty("strategy")
expect(Worktree.CreateInput.fields).not.toHaveProperty("location")
expect(Worktree.RemoveInput.fields).not.toHaveProperty("location")
})
test("inventory contains only the directory and its owning strategy", () => {
+65
View File
@@ -0,0 +1,65 @@
import { expect, test } from "bun:test"
import { mkdir, rm } from "node:fs/promises"
import { join } from "node:path"
import { Plugin } from "@opencode/plugin"
import { initRepo } from "../../core/test/fixture/git"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { OpenCode } from "../src"
test("embedded worktree APIs use project IDs and SDK-registered strategies", async () => {
await using directory = await tmpdir("opencode-sdk-worktree-")
await initRepo(directory.path)
const state = { activated: 0, discovered: 0, removed: 0 }
const plugin = Plugin.define({
id: "sdk-worktrees",
async setup(ctx) {
state.activated++
await ctx.worktree.transform((editor) =>
editor.add({
id: "sdk-copy",
async create(input, { signal }) {
signal.throwIfAborted()
await mkdir(input.directory)
return { directory: input.directory }
},
async remove(input, { signal }) {
signal.throwIfAborted()
await rm(input.directory, { recursive: true })
state.removed++
},
async list(_source, { signal }) {
signal.throwIfAborted()
state.discovered++
return []
},
}),
)
},
})
await using opencode = await OpenCode.create({
plugins: [plugin],
config: { directory: directory.path, project: false },
models: { fetch: false },
fs: { filewatcher: false },
})
const session = await opencode.sessions.create({ location: { directory: directory.path } })
const projectID = session.projectID
expect(await opencode.worktree.list({ projectID })).toHaveLength(1)
expect(state.activated).toBe(0)
const worktree = await opencode.worktree.create({
projectID,
directory: join(directory.path, "copies"),
name: "task",
})
expect(state.activated).toBe(1)
expect(await opencode.worktree.list({ projectID })).toContainEqual({
directory: worktree.directory,
strategy: "sdk-copy",
})
expect(state.discovered).toBe(0)
await opencode.worktree.refresh({ projectID })
expect(state.discovered).toBe(1)
await opencode.worktree.remove({ projectID, directory: worktree.directory, force: false })
expect(state.removed).toBe(1)
expect(await opencode.worktree.list({ projectID })).toHaveLength(1)
})
+37 -33
View File
@@ -1,51 +1,56 @@
import { Git } from "@opencode/core/git"
import { Worktree } from "@opencode/core/worktree"
import { Plugin } from "@opencode/core/plugin"
import { Project } from "@opencode/core/project"
import { ProjectNotFoundError } from "@opencode/protocol/errors"
import { WorktreeError } from "@opencode/protocol/groups/worktree"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
export const WorktreeHandler = HttpApiBuilder.group(Api, "server.worktree", (handlers) =>
handlers
.handle("worktree.list", () => run((worktrees) => worktrees.list()))
.handle("worktree.create", (ctx) => run((worktrees) => worktrees.create(ctx.payload)))
.handle("worktree.remove", (ctx) =>
run((worktrees) => worktrees.remove(ctx.payload)).pipe(Effect.as(HttpApiSchema.NoContent.make())),
)
.handle("worktree.refresh", () =>
run((worktrees) => worktrees.refresh()).pipe(Effect.as(HttpApiSchema.NoContent.make())),
),
)
function run<A>(action: (service: Worktree.Interface) => Effect.Effect<A, Worktree.Error>) {
return Effect.gen(function* () {
const plugins = yield* Plugin.Service
Effect.gen(function* () {
const worktrees = yield* Worktree.Service
yield* plugins.awaitActivation
return yield* action(worktrees)
}).pipe(badRequest)
}
return handlers
.handle("worktree.list", (ctx) =>
worktrees.list(ctx.query).pipe(Effect.catchTag("Project.NotFoundError", missingProject)),
)
.handle("worktree.create", (ctx) => worktrees.create(ctx.payload).pipe(badRequest))
.handle("worktree.remove", (ctx) =>
worktrees.remove(ctx.payload).pipe(badRequest, Effect.as(HttpApiSchema.NoContent.make())),
)
.handle("worktree.refresh", (ctx) =>
worktrees.refresh(ctx.payload).pipe(badRequest, Effect.as(HttpApiSchema.NoContent.make())),
)
}),
)
function badRequest<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
return effect.pipe(
Effect.mapError(
(error) =>
new WorktreeError({
name: "WorktreeError",
data: {
message: message(error),
forceRequired:
error instanceof Git.WorktreeError || error instanceof Worktree.OperationError
? error.forceRequired
: undefined,
},
}),
Effect.catchTag("Project.NotFoundError", missingProject),
Effect.mapError((error) =>
error instanceof ProjectNotFoundError
? error
: new WorktreeError({
name: "WorktreeError",
data: {
message: message(error),
forceRequired:
error instanceof Git.WorktreeError || error instanceof Worktree.OperationError
? error.forceRequired
: undefined,
},
}),
),
)
}
function message(error: Worktree.Error) {
function missingProject(error: Project.NotFoundError) {
return Effect.fail(
new ProjectNotFoundError({ projectID: error.projectID, message: `Project not found: ${error.projectID}` }),
)
}
function message(error: Exclude<Worktree.Error, Project.NotFoundError>) {
if (error instanceof Worktree.SourceDirectoryNotFoundError)
return error.directory
? `Worktree source not found: ${error.directory}`
@@ -54,6 +59,5 @@ function message(error: Worktree.Error) {
if (error instanceof Worktree.DirectoryUnavailableError) return `Worktree directory unavailable: ${error.directory}`
if (error instanceof Worktree.InvalidDirectoryError) return `Invalid worktree directory: ${error.directory}`
if (error instanceof Worktree.StrategyUnavailableError) return `Worktree strategy unavailable: ${error.strategy}`
if (error instanceof Worktree.UnsupportedLocationError) return "Worktree operations only support local locations"
return error.message
}
+2
View File
@@ -14,6 +14,7 @@ import { PermissionSaved } from "@opencode/core/permission/saved"
import { PtyTicket } from "@opencode/core/pty/ticket"
import { PersistentPty } from "@opencode/core/persistent-pty"
import { Project } from "@opencode/core/project"
import { Worktree } from "@opencode/core/worktree"
import { Session } from "@opencode/core/session"
import { Instance } from "@opencode/core/instance/service"
import { SessionTransfer } from "@opencode/core/session/transfer"
@@ -55,6 +56,7 @@ const applicationServiceNodes = [
httpClient,
Job.node,
Project.node,
Worktree.node,
Session.node,
Instance.node,
SessionTransfer.node,
@@ -3,10 +3,10 @@ import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "test.worktree-delegate",
async setup(ctx) {
const directory = ctx.options.directory
if (typeof directory !== "string") throw new Error("Missing target location")
const projectID = ctx.options.projectID ?? ctx.location.project.id
if (typeof projectID !== "string") throw new Error("Missing target project")
await ctx.worktree.create({
location: { directory },
projectID,
name: "delegated",
})
},
+157 -45
View File
@@ -9,8 +9,118 @@ import { startServer } from "./fixture/server"
import { OpenCode } from "@opencode/client"
import { initRepo } from "../../core/test/fixture/git"
it.live("list reads saved inventory even when its checkout is missing, without booting a location", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-list-")))
const directory = path.join(tmp.path, "repo")
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await initRepo(directory)
})
const server = yield* startServer(path.join(tmp.path, "config"))
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
yield* Effect.promise(async () => {
const session = await api.session.create({ location: { directory } })
const loaded = await api.debug.location.list()
await fs.rm(directory, { recursive: true })
expect(await api.worktree.list({ projectID: session.projectID })).toEqual([{ directory }])
await expect(api.worktree.create({ projectID: session.projectID })).rejects.toMatchObject({
name: "WorktreeError",
data: { message: `Worktree directory unavailable: ${directory}` },
})
await expect(api.worktree.refresh({ projectID: session.projectID })).rejects.toMatchObject({
name: "WorktreeError",
data: { message: `Worktree directory unavailable: ${directory}` },
})
expect(await api.debug.location.list()).toEqual(loaded)
const missing = await fetch(`${server.base}/api/worktree?projectID=unknown`, { headers: server.headers })
expect(missing.status).toBe(404)
const legacy = await fetch(`${server.base}/api/worktree?location[directory]=${directory}`, {
headers: server.headers,
})
expect(legacy.status).toBe(400)
})
}),
)
it.live("refresh discovers both clones while list alone never discovers external worktrees", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-clones-")))
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
const a = path.join(tmp.path, "a")
const b = path.join(tmp.path, "b")
yield* Effect.promise(async () => {
await fs.mkdir(first)
await initRepo(first)
await $`git remote add origin https://github.com/example/clones.git`.cwd(first).quiet()
await $`git clone --no-hardlinks ${first} ${second}`.quiet()
await $`git remote set-url origin https://github.com/example/clones.git`.cwd(second).quiet()
})
const server = yield* startServer(path.join(tmp.path, "config"))
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
yield* Effect.promise(async () => {
const session = await api.session.create({ location: { directory: first } })
const other = await api.session.create({ location: { directory: second } })
const projectID = session.projectID
expect(other.projectID).toBe(projectID)
await $`git worktree add --detach ${a} HEAD`.cwd(first).quiet()
await $`git worktree add --detach ${b} HEAD`.cwd(second).quiet()
expect(await api.worktree.list({ projectID })).toHaveLength(2)
await api.worktree.refresh({ projectID })
expect(await api.worktree.list({ projectID })).toEqual(
expect.arrayContaining([
{ directory: first },
{ directory: second },
{ directory: a, strategy: "git" },
{ directory: b, strategy: "git" },
]),
)
await fs.rm(second, { recursive: true })
await $`git worktree remove ${a}`.cwd(first).quiet()
await api.worktree.refresh({ projectID })
const rows = await api.worktree.list({ projectID })
expect(rows).not.toContainEqual({ directory: a, strategy: "git" })
expect(rows).not.toContainEqual({ directory: second })
expect(rows).toContainEqual({ directory: b, strategy: "git" })
})
}),
)
it.live("remove uses bundled Git without loading configuration and enforces project ownership", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-remove-")))
const directory = path.join(tmp.path, "repo")
const other = path.join(tmp.path, "other")
const linked = path.join(tmp.path, "linked")
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await fs.mkdir(other)
await initRepo(directory)
await initRepo(other)
await $`git remote add origin https://github.com/example/remove.git`.cwd(directory).quiet()
await $`git remote add origin https://github.com/example/other.git`.cwd(other).quiet()
await $`git worktree add --detach ${linked} HEAD`.cwd(directory).quiet()
})
const server = yield* startServer(path.join(tmp.path, "config"))
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
yield* Effect.promise(async () => {
const session = await api.session.create({ location: { directory: linked } })
const foreign = await api.session.create({ location: { directory: other } })
const loaded = await api.debug.location.list()
await expect(
api.worktree.remove({ projectID: foreign.projectID, directory: linked, force: true }),
).rejects.toMatchObject({ name: "WorktreeError" })
expect(await fs.stat(linked).then((stat) => stat.isDirectory())).toBe(true)
await api.worktree.remove({ projectID: session.projectID, directory: linked, force: false })
expect(await api.debug.location.list()).toEqual(loaded)
expect(await api.worktree.list({ projectID: session.projectID })).toEqual([{ directory }])
})
}),
)
it.live(
"lists, creates, and removes worktrees through the same location",
"lists, creates, and removes worktrees by project ID",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-endpoint-")))
@@ -22,8 +132,10 @@ it.live(
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* startServer(path.join(tmp.path, "config"))
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
const session = yield* Effect.promise(() => api.session.create({ location: { directory: project } }))
const url = new URL("/api/worktree", server.base)
url.searchParams.set("location[directory]", project)
url.searchParams.set("projectID", session.projectID)
const initial = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
@@ -34,7 +146,7 @@ it.live(
fetch(url, {
method: "POST",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
body: JSON.stringify({ projectID: session.projectID, directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
@@ -51,7 +163,11 @@ it.live(
fetch(url, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
body: JSON.stringify({
projectID: session.projectID,
directory: path.join(destination, "api"),
force: false,
}),
}),
)
expect(removed.status).toBe(204)
@@ -60,7 +176,7 @@ it.live(
)
it.live(
"derives the project and creation defaults when the SDK omits its input",
"uses project configuration independently of default location headers",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-default-location-")))
@@ -76,25 +192,27 @@ it.live(
const server = yield* startServer(config)
const api = OpenCode.make({
baseUrl: server.base,
headers: { ...server.headers, "x-opencode-directory": encodeURIComponent(project) },
headers: { ...server.headers, "x-opencode-directory": encodeURIComponent("/unrelated-missing-directory") },
})
yield* Effect.promise(async () => {
const created = await api.worktree.create()
const session = await api.session.create({ location: { directory: project } })
const projectID = session.projectID
const created = await api.worktree.create({ projectID })
expect(path.dirname(created.directory)).toBe(destination)
await api.worktree.refresh()
expect(await api.worktree.list()).toContainEqual({
await api.worktree.refresh({ projectID })
expect(await api.worktree.list({ projectID })).toContainEqual({
directory: created.directory,
strategy: "git",
})
await api.worktree.remove({ directory: created.directory, force: false })
expect(await api.worktree.list()).toEqual([{ directory: project }])
await api.worktree.remove({ projectID, directory: created.directory, force: false })
expect(await api.worktree.list({ projectID })).toEqual([{ directory: project }])
})
}),
30_000,
)
it.live(
"uses checkout-local plugins and configuration for clones sharing a project",
"uses canonical plugins for shared clones and remove only uses already-loaded strategies",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-plugins-")))
@@ -113,7 +231,7 @@ it.live(
await fs.mkdir(config)
await Bun.write(path.join(config, "opencode.json"), JSON.stringify({ worktree: { directory: destination } }))
await Bun.write(
path.join(nested, "opencode.json"),
path.join(first, "opencode.json"),
JSON.stringify({
plugins: [
{ package: path.join(import.meta.dir, "fixture/worktree-plugin"), options: { strategy: "test-copy" } },
@@ -124,89 +242,85 @@ it.live(
const server = yield* startServer(config)
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
yield* Effect.promise(async () => {
const a = await api.location.get({ location: { directory: nested } })
const b = await api.location.get({ location: { directory: second } })
expect(a.project.id).toBe(b.project.id)
const custom = await api.worktree.create({ location: { directory: nested }, name: "custom" })
const builtin = await api.worktree.create({ location: { directory: second }, name: "builtin" })
const a = await api.session.create({ location: { directory: nested } })
const b = await api.session.create({ location: { directory: second } })
expect(a.projectID).toBe(b.projectID)
const projectID = a.projectID
const custom = await api.worktree.create({ projectID, name: "custom" })
const builtin = await api.worktree.create({ projectID, from: second, name: "other-clone" })
expect(custom.directory).toBe(path.join(destination, "custom"))
expect(builtin.directory).toBe(path.join(destination, "builtin"))
const otherRows = await api.worktree.list({ location: { directory: second } })
expect(builtin.directory).toBe(path.join(destination, "other-clone"))
const otherRows = await api.worktree.list({ projectID: b.projectID })
expect(otherRows).toContainEqual({ directory: custom.directory, strategy: "test-copy" })
const rows = await api.worktree.list({ location: { directory: nested } })
const rows = await api.worktree.list({ projectID })
expect(rows).toContainEqual({
directory: custom.directory,
strategy: "test-copy",
})
expect(rows).toContainEqual({ directory: builtin.directory, strategy: "git" })
expect(rows).toContainEqual({ directory: builtin.directory, strategy: "test-copy" })
await Bun.write(path.join(custom.directory, "dirty.txt"), "keep me")
await api.debug.location.evict({ location: { directory: first } })
const loaded = await api.debug.location.list()
const remove = new URL("/api/worktree", server.base)
remove.searchParams.set("location[directory]", second)
const unavailable = await fetch(remove, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: custom.directory, force: true }),
body: JSON.stringify({ projectID, directory: custom.directory, force: true }),
})
expect(unavailable.status).toBe(400)
expect(await unavailable.json()).toMatchObject({
data: { message: "Worktree strategy unavailable: test-copy" },
})
expect(await Bun.file(path.join(custom.directory, "dirty.txt")).text()).toBe("keep me")
remove.searchParams.set("location[directory]", nested)
expect(await api.worktree.list({ projectID })).toEqual(rows)
expect(await api.debug.location.list()).toEqual(loaded)
await api.worktree.refresh({ projectID })
const failure = await fetch(remove, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: custom.directory, force: false }),
body: JSON.stringify({ projectID, directory: custom.directory, force: false }),
})
expect(failure.status).toBe(400)
expect(await failure.json()).toMatchObject({ data: { forceRequired: true } })
expect(await Bun.file(path.join(custom.directory, "dirty.txt")).text()).toBe("keep me")
await api.worktree.remove({
location: { directory: nested },
projectID,
directory: custom.directory,
force: true,
})
await api.worktree.remove({
location: { directory: second },
projectID,
directory: builtin.directory,
force: false,
})
expect((await api.worktree.list({ location: { directory: nested } })).filter((row) => row.strategy)).toEqual([])
expect((await api.worktree.list({ projectID })).filter((row) => row.strategy)).toEqual([])
})
}),
30_000,
)
it.live(
"plugin calls await a different location's strategy and directory configuration",
"canonical plugin setup can create using registrations made so far",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-delegate-")))
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target")
const destination = path.join(tmp.path, "copies")
yield* Effect.promise(async () => {
for (const directory of [source, target]) {
for (const directory of [source]) {
await fs.mkdir(directory)
await initRepo(directory)
await $`git remote add origin git@github.com:example/delegate-fixture.git`.cwd(directory).quiet()
}
await Bun.write(
path.join(source, "opencode.json"),
JSON.stringify({
plugins: [
{ package: path.join(import.meta.dir, "fixture/worktree-delegate"), options: { directory: target } },
],
}),
)
await Bun.write(
path.join(target, "opencode.json"),
JSON.stringify({
worktree: { directory: destination },
plugins: [
{ package: path.join(import.meta.dir, "fixture/worktree-plugin"), options: { strategy: "target-copy" } },
{ package: path.join(import.meta.dir, "fixture/worktree-delegate") },
],
}),
)
@@ -214,11 +328,9 @@ it.live(
const server = yield* startServer(path.join(tmp.path, "config"))
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
yield* Effect.promise(async () => {
await api.location.get({ location: { directory: source } })
const url = new URL("/api/plugin/await-activation", server.base)
url.searchParams.set("location[directory]", source)
expect((await fetch(url, { method: "POST", headers: server.headers })).status).toBe(204)
expect(await api.worktree.list({ location: { directory: target } })).toContainEqual({
const session = await api.session.create({ location: { directory: source } })
await api.worktree.refresh({ projectID: session.projectID })
expect(await api.worktree.list({ projectID: session.projectID })).toContainEqual({
directory: path.join(destination, "delegated"),
strategy: "target-copy",
})
+16 -20
View File
@@ -1,4 +1,4 @@
import { batch, createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import { batch, createEffect, createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import type { OpenCodeEvent, SessionInfo } from "@opencode/client"
import path from "path"
import { useTerminalDimensions } from "@opentui/solid"
@@ -124,22 +124,22 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
const current = view()
return current.type === "worktrees" ? current.workspaceID : undefined
}
const [worktrees] = createResource(
() => (view().type === "worktrees" ? view() : undefined),
() =>
client.api.worktree
.list({
location: {
directory: data.project.get(projectID()!)!.canonical,
workspace: workspaceID(),
},
})
.catch((error: unknown) => {
toast.show({ title: "Loading worktrees failed", message: errorMessage(error), variant: "error" })
return []
}),
const [worktrees, worktreeActions] = createResource(projectID, (projectID) =>
client.api.worktree.list({ projectID }).catch((error: unknown) => {
toast.show({ title: "Loading worktrees failed", message: errorMessage(error), variant: "error" })
return []
}),
)
createEffect(() => {
const id = projectID()
if (!id) return
void client.api.worktree
.refresh({ projectID: id })
.then(() => worktreeActions.refetch())
.catch(() => undefined)
})
const [matched] = createResource(
() => {
const value = filter().trim()
@@ -454,17 +454,13 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
setCreating(true)
void client.api.worktree
.create({
location: {
directory: data.project.get(id)!.canonical,
workspace: workspaceID(),
},
projectID: id,
...(value.trim() ? { name: value.trim() } : {}),
})
.then((created) => {
if (closed || creation() !== previous) return
const target = {
directory: created.directory,
...(workspaceID() ? { workspaceID: workspaceID() } : {}),
}
dialog.clear()
route.navigate({ type: "home", location: target })
@@ -47,10 +47,6 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
const paths = useTuiPaths()
const shortcuts = Keymap.useShortcuts()
const location = createMemo(() => sessionData.location.info(props.location))
const worktreeLocation = () => ({
directory: props.location?.directory ?? location()?.directory ?? paths.cwd,
workspace: props.location?.workspaceID ?? location()?.workspaceID,
})
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving)
@@ -81,10 +77,10 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
})
const [directories, { refetch }] = createResource(
() => (props.fixture || props.initialRemoving ? undefined : worktreeLocation()),
async (location, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
() => (props.fixture || props.initialRemoving ? undefined : props.projectID),
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
try {
const directories = await client.api.worktree.list({ location })
const directories = await client.api.worktree.list({ projectID })
setLoadError(undefined)
return directories
} catch (error) {
@@ -96,7 +92,14 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
}
},
)
const directoryData = createMemo(() => directories() ?? props.initialDirectories)
onMount(() => {
if (props.fixture || props.initialRemoving) return
void discover().catch(() => undefined)
})
function discover() {
return client.api.worktree.refresh({ projectID: props.projectID }).then(() => refetch())
}
const directoryData = createMemo(() => directories.latest ?? props.initialDirectories)
// Show the locked error view only when we have nothing to display. A refresh
// that fails after the list rendered keeps the list and its actions.
const showError = createMemo(() => Boolean(loadError()) && !directoryData())
@@ -229,7 +232,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
setWorking(true)
const request = {
directory: selected.directory,
location: worktreeLocation(),
projectID: props.projectID,
}
const error = await client.api.worktree
.remove({
@@ -385,7 +388,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
command: "dialog.move_session.refresh",
title: "refresh",
selection: "none",
onTrigger: () => void refetch(),
onTrigger: () => void discover().catch(toast.error),
},
]
}
+1 -1
View File
@@ -39,7 +39,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const project = data.location.info(location)?.project
if (!project) throw new Error("Unable to determine current project")
const result = await client.api.worktree.create({
location: { directory: location.directory, workspace: location.workspaceID },
projectID: project.id,
name,
})
const directory = result.directory
+16 -13
View File
@@ -264,8 +264,8 @@ test("loads Git worktrees only when drilling into a project or its associated di
project: { id: "proj_git", directory: current, canonical: root },
})
if (url.pathname !== "/api/worktree") return undefined
expect(url.searchParams.get("location[directory]")).toBe(root)
expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
expect(url.searchParams.get("projectID")).toBe("proj_git")
expect(url.searchParams.has("location[directory]")).toBe(false)
requests++
return json([{ directory: other, strategy: "git" }, { directory: root }, { directory: current, strategy: "git" }])
},
@@ -286,7 +286,7 @@ test("loads Git worktrees only when drilling into a project or its associated di
const worktrees = await fixture.app.waitForFrame(
(frame) => frame.includes("other-branch") && frame.includes("ctrl+n"),
)
expect(requests).toBe(1)
expect(requests).toBe(2)
expect(worktrees).toContain("Worktrees")
expect(worktrees).toContain("●")
expect(worktrees.indexOf("OpenCode")).toBeLessThan(worktrees.indexOf("current-branch"))
@@ -298,7 +298,7 @@ test("loads Git worktrees only when drilling into a project or its associated di
await fixture.app.waitForFrame((frame) => frame.includes("current-branch") && !frame.includes("OpenCode"))
fixture.app.mockInput.pressArrow("right")
await fixture.app.waitForFrame((frame) => frame.includes("other-branch") && frame.includes("ctrl+n"))
expect(requests).toBe(2)
expect(requests).toBe(4)
fixture.app.mockInput.pressEscape()
const restored = await fixture.app.waitForFrame(
(frame) => frame.includes("current-branch") && !frame.includes("Worktrees"),
@@ -412,9 +412,9 @@ test("does not show the previous project's worktrees while loading another proje
})),
)
if (url.pathname !== "/api/worktree") return undefined
if (url.searchParams.get("location[directory]") === "/tmp/opencode/Alpha")
if (url.searchParams.get("projectID") === "proj_Alpha")
return json([{ directory: "/tmp/opencode/alpha-checkout", strategy: "git" }])
return pending.promise
return pending.promise.then((response) => response.clone())
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Alpha") && frame.includes("Beta"))
@@ -442,7 +442,7 @@ test("does not show the previous project's worktrees while loading another proje
}
})
test.each(["", "search-ui"])("creates a worktree named '%s' and opens it in the current workspace", async (name) => {
test.each(["", "search-ui"])("creates a worktree named '%s' by project and opens its local directory", async (name) => {
const projectID = "proj_git_create"
const root = path.resolve("/tmp/opencode/project")
const created = path.resolve("/tmp/opencode/created-branch")
@@ -464,9 +464,12 @@ test.each(["", "search-ui"])("creates a worktree named '%s' and opens it in the
if (url.pathname === "/api/location")
return json({ directory: root, workspaceID, project: { id: projectID, directory: root, canonical: root } })
if (url.pathname !== "/api/worktree") return undefined
expect(url.searchParams.get("location[directory]")).toBe(root)
expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
if (request.method === "GET") return json([{ directory: root }])
expect(url.searchParams.has("location[directory]")).toBe(false)
expect(url.searchParams.has("location[workspace]")).toBe(false)
if (request.method === "GET") {
expect(url.searchParams.get("projectID")).toBe(projectID)
return json([{ directory: root }])
}
payload = await request.json()
return json({ directory: created })
},
@@ -509,9 +512,9 @@ test.each(["", "search-ui"])("creates a worktree named '%s' and opens it in the
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(payload).toEqual(name ? { name } : {})
expect(fixture.route.data).toEqual({ type: "home", location: { directory: created, workspaceID } })
expect(fixture.location.ref).toEqual({ directory: created, workspaceID })
expect(payload).toEqual({ projectID, ...(name ? { name } : {}) })
expect(fixture.route.data).toEqual({ type: "home", location: { directory: created } })
expect(fixture.location.ref).toEqual({ directory: created })
} finally {
await fixture.dispose()
}
+47 -14
View File
@@ -28,7 +28,7 @@ test.each([
{ name: "an uncached session in a linked worktree", directory: linked, worktree: linked },
{ name: "a session in a linked worktree subdirectory", directory: `${linked}/packages/tui`, worktree: linked },
{ name: "the home/default location", directory: `${clone}/packages/tui`, home: true },
])("passes the current location and uses server worktree defaults for $name", async (input) => {
])("passes the current project and uses server worktree defaults for $name", async (input) => {
const fixture = await renderMove(input)
try {
await fixture.data.project.sync()
@@ -45,9 +45,13 @@ test.each([
await fixture.create()
expect(fixture.requests).toEqual([{ payload: { name: "fresh" }, directory: input.directory, workspace: null }])
expect(fixture.requests).toEqual([
{ payload: { projectID: "proj_test", name: "fresh" }, directory: null, workspace: null },
])
expect(fixture.data.location.info({ directory: created })?.project.canonical).toBe(clone)
expect(fixture.reads.locations.filter((directory) => directory === input.directory)).toHaveLength(input.home ? 3 : 1)
expect(fixture.reads.locations.filter((directory) => directory === input.directory)).toHaveLength(
input.home ? 3 : 1,
)
expect(fixture.reads.session).toBe(input.home ? 0 : 1)
expect(fixture.moves).toEqual([])
if (!input.home) expect(fixture.route.data).toEqual({ type: "home", location: { directory: created } })
@@ -71,11 +75,11 @@ test.each([
const frame = await fixture.create()
expect(fixture.reads.worktrees).toEqual([selected.directory])
expect(fixture.reads.worktrees).toEqual(["proj_test", "proj_test"])
expect(frame).toContain(clone)
expect(frame.indexOf(clone)).toBeLessThan(frame.indexOf(main))
expect(fixture.requests).toEqual([
{ payload: { name: "fresh" }, directory: `${clone}/packages/tui`, workspace: input.workspaceID ?? null },
{ payload: { projectID: "proj_test", name: "fresh" }, directory: null, workspace: null },
])
expect(fixture.data.location.info(selected)?.project.canonical).toBe(clone)
expect(fixture.moves).toEqual([])
@@ -92,7 +96,9 @@ test.each([false, true])("selecting a workspace opens Home without moving a sess
await fixture.app.mockInput.typeText("linked")
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home" && fixture.route.data.location?.directory === linked)
await fixture.app.waitFor(
() => fixture.route.data.type === "home" && fixture.route.data.location?.directory === linked,
)
expect(fixture.route.data).toEqual({ type: "home", location: { directory: linked } })
expect(fixture.moves).toEqual([])
expect(fixture.requests).toEqual([])
@@ -103,7 +109,7 @@ test.each([false, true])("selecting a workspace opens Home without moving a sess
}
})
test("removal uses the current configuration location, not the destination directory", async () => {
test("removal sends project ownership and the destination without a configuration location", async () => {
const fixture = await renderMove({ directory: clone, home: true })
try {
await fixture.move.open()
@@ -114,7 +120,28 @@ test("removal uses the current configuration location, not the destination direc
await fixture.app.waitForFrame((frame) => frame.includes("again to confirm"))
fixture.app.mockInput.pressKey("d", { ctrl: true })
await fixture.app.waitFor(() => fixture.removals.length === 1)
expect(fixture.removals).toEqual([{ payload: { directory: linked, force: false }, directory: clone }])
expect(fixture.removals).toEqual([
{ payload: { projectID: "proj_test", directory: linked, force: false }, directory: null },
])
} finally {
fixture.app.renderer.destroy()
}
})
test("refresh explicitly discovers the project and preserves the worktree filter", async () => {
const fixture = await renderMove({ directory: clone, home: true })
try {
await fixture.move.open()
await fixture.app.waitFor(() => fixture.reads.worktrees.length === 2)
await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
await fixture.app.waitFor(() => fixture.app.renderer.currentFocusedEditor instanceof InputRenderable)
await fixture.app.mockInput.typeText("linked")
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
fixture.app.mockInput.pressKey("r", { ctrl: true })
await fixture.app.waitFor(() => fixture.reads.worktrees.length === 3)
expect(fixture.reads.refresh).toEqual([{ projectID: "proj_test" }, { projectID: "proj_test" }])
const frame = await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
expect(frame).toContain("linked")
} finally {
fixture.app.renderer.destroy()
}
@@ -178,7 +205,10 @@ test("does not open creation when the selected Home location lookup fails", asyn
expect(fixture.requests).toEqual([])
expect(fixture.moves).toEqual([])
expect(fixture.toast.currentToast).toMatchObject({ message: "Unable to determine current project", variant: "error" })
expect(fixture.toast.currentToast).toMatchObject({
message: "Unable to determine current project",
variant: "error",
})
} finally {
fixture.app.renderer.destroy()
}
@@ -196,7 +226,7 @@ async function renderMove(input: {
const requests: unknown[] = []
const removals: unknown[] = []
const moves: unknown[] = []
const reads = { session: 0, locations: [] as string[], worktrees: [] as string[] }
const reads = { session: 0, locations: [] as string[], worktrees: [] as string[], refresh: [] as unknown[] }
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") {
const directory = url.searchParams.get("location[directory]") ?? launch
@@ -237,10 +267,10 @@ async function renderMove(input: {
}
if (url.pathname === "/api/worktree") {
if (request.method === "GET") {
const directory = url.searchParams.get("location[directory]") ?? launch
reads.worktrees.push(directory)
const projectID = url.searchParams.get("projectID") ?? ""
reads.worktrees.push(projectID)
return json(
directory === launch && input.launchProjectID
projectID === input.launchProjectID
? [{ directory: launch }]
: [{ directory: main }, { directory: clone }, { directory: linked, strategy: "git" }],
)
@@ -258,7 +288,10 @@ async function renderMove(input: {
return new Response(null, { status: 204 })
}
}
if (url.pathname === "/api/worktree/refresh") return new Response(null, { status: 204 })
if (url.pathname === "/api/worktree/refresh") {
reads.refresh.push(await request.json())
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/session/ses_clone/move") {
moves.push(await request.json())
return new Response(null, { status: 204 })
+77 -178
View File
@@ -12052,44 +12052,12 @@
"operationId": "worktree.list",
"parameters": [
{
"name": "location",
"name": "projectID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12105,18 +12073,11 @@
}
},
"400": {
"description": "WorktreeError | InvalidRequestError",
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/WorktreeErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
@@ -12130,56 +12091,25 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Discover worktrees through the requested location's strategies and return its project's inventory.",
"description": "Return the project's saved worktree inventory without loading configuration or running discovery.",
"summary": "List worktrees"
},
"post": {
"tags": ["worktree"],
"operationId": "worktree.create",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"200": {
@@ -12218,9 +12148,19 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
"description": "Load the project's canonical configuration, create a local worktree using its selected strategy, then run the project's setup script.",
"summary": "Create worktree",
"requestBody": {
"content": {
@@ -12236,48 +12176,7 @@
"delete": {
"tags": ["worktree"],
"operationId": "worktree.remove",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -12309,9 +12208,19 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Remove a managed worktree from the requested location's project using its recorded strategy.",
"description": "Remove a saved project worktree using its recorded, already-available strategy. Does not load configuration.",
"summary": "Remove worktree",
"requestBody": {
"content": {
@@ -12329,48 +12238,7 @@
"post": {
"tags": ["worktree"],
"operationId": "worktree.refresh",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -12402,10 +12270,37 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Discover worktrees from the requested location and reconcile the shared project inventory.",
"summary": "Refresh worktrees"
"description": "Load the project's canonical configuration and discover worktrees across known checkout roots using all available strategies.",
"summary": "Refresh worktrees",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/workspace": {
@@ -20103,7 +19998,7 @@
"Worktree.CreateInput": {
"type": "object",
"properties": {
"strategy": {
"projectID": {
"type": "string"
},
"from": {
@@ -20119,6 +20014,7 @@
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
},
"Worktree.Directory": {
@@ -20153,6 +20049,9 @@
"Worktree.RemoveInput": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
},
"directory": {
"type": "string"
},
@@ -20160,7 +20059,7 @@
"type": "boolean"
}
},
"required": ["directory", "force"],
"required": ["projectID", "directory", "force"],
"additionalProperties": false
},
"WorktreeErrorEncoded": {
@@ -20296,7 +20195,7 @@
},
{
"name": "worktree",
"description": "Location-scoped worktree management routes."
"description": "Project-based worktree management routes."
},
{
"name": "workspace",
+77 -178
View File
@@ -12052,44 +12052,12 @@
"operationId": "worktree.list",
"parameters": [
{
"name": "location",
"name": "projectID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12105,18 +12073,11 @@
}
},
"400": {
"description": "WorktreeError | InvalidRequestError",
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/WorktreeErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
@@ -12130,56 +12091,25 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Discover worktrees through the requested location's strategies and return its project's inventory.",
"description": "Return the project's saved worktree inventory without loading configuration or running discovery.",
"summary": "List worktrees"
},
"post": {
"tags": ["worktree"],
"operationId": "worktree.create",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"200": {
@@ -12218,9 +12148,19 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
"description": "Load the project's canonical configuration, create a local worktree using its selected strategy, then run the project's setup script.",
"summary": "Create worktree",
"requestBody": {
"content": {
@@ -12236,48 +12176,7 @@
"delete": {
"tags": ["worktree"],
"operationId": "worktree.remove",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -12309,9 +12208,19 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Remove a managed worktree from the requested location's project using its recorded strategy.",
"description": "Remove a saved project worktree using its recorded, already-available strategy. Does not load configuration.",
"summary": "Remove worktree",
"requestBody": {
"content": {
@@ -12329,48 +12238,7 @@
"post": {
"tags": ["worktree"],
"operationId": "worktree.refresh",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -12402,10 +12270,37 @@
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Discover worktrees from the requested location and reconcile the shared project inventory.",
"summary": "Refresh worktrees"
"description": "Load the project's canonical configuration and discover worktrees across known checkout roots using all available strategies.",
"summary": "Refresh worktrees",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/workspace": {
@@ -20103,7 +19998,7 @@
"Worktree.CreateInput": {
"type": "object",
"properties": {
"strategy": {
"projectID": {
"type": "string"
},
"from": {
@@ -20119,6 +20014,7 @@
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
},
"Worktree.Directory": {
@@ -20153,6 +20049,9 @@
"Worktree.RemoveInput": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
},
"directory": {
"type": "string"
},
@@ -20160,7 +20059,7 @@
"type": "boolean"
}
},
"required": ["directory", "force"],
"required": ["projectID", "directory", "force"],
"additionalProperties": false
},
"WorktreeErrorEncoded": {
@@ -20296,7 +20195,7 @@
},
{
"name": "worktree",
"description": "Location-scoped worktree management routes."
"description": "Project-based worktree management routes."
},
{
"name": "workspace",
@@ -999,34 +999,36 @@ Load the plugin through `plugins` and configure the common destination separatel
#### Operations
The plugin context exposes the same worktree operations as the client. Every operation uses the plugin's current
location unless overridden. `list` discovers worktrees through that location's strategies and returns the full inventory
for the resolved project, including known worktrees from other checkouts of the same project.
The plugin context exposes the same project-based worktree operations as the client. Every operation requires a
`projectID`. `list` reads only saved inventory, including known checkouts of the same project; it does not load
configuration, start plugins, or discover filesystem changes.
```ts
const created = await ctx.worktree.create({ name: "task" })
const inventory = await ctx.worktree.list()
await ctx.worktree.refresh()
await ctx.worktree.remove({ directory: created.directory, force: false })
const projectID = ctx.location.project.id
const created = await ctx.worktree.create({ projectID, name: "task" })
const inventory = await ctx.worktree.list({ projectID })
await ctx.worktree.refresh({ projectID })
await ctx.worktree.remove({ projectID, directory: created.directory, force: false })
```
Create accepts optional explicit `strategy`, destination `directory`, source `from`, and starting `branch` overrides.
All worktree operations derive the project from their location; none takes a `projectID`.
The source defaults to the location's checkout, and a `from` override must belong to that project.
Create loads configuration and plugins from the project's saved `canonical` directory and uses their selected strategy.
It accepts an optional parent `directory`, source `from`, starting `branch`, and `name`.
The source defaults to the canonical checkout, and a `from` override must belong to that project. Choosing a source
does not change the configuration root.
A supplied starting ref must be supported by the selected strategy;
Rift's native snapshot operation, for example, has no ref-selection option.
Calls targeting another location wait for its plugins to activate. Calls in the current plugin's location during setup
see registrations made so far and do not wait for their own activation; prefer lifecycle actions after setup completes.
Create and refresh wait for the canonical checkout's plugins to activate. Calls made during that checkout's plugin
setup see registrations made so far and do not wait for their own activation.
Configuration is derived from the operation's location; no configuration directory is stored in the database.
To remove a worktree through a checkout-local plugin, select a location where that plugin is configured.
The worktree's destination can be elsewhere:
Remove uses the worktree's recorded strategy from the already-loaded canonical registry, or bundled Git. It does not
load configuration or activate plugins. An unavailable owner produces an error, including after a restart before its
plugin has loaded. The worktree's destination can be elsewhere:
```ts
await ctx.worktree.remove({
directory: "/worktrees/task",
location: { directory: "/repos/app" },
projectID,
force: false,
})
```
@@ -1035,6 +1037,15 @@ Missing owners fail removal rather than falling back to Git. A strategy can thro
message: "Uncommitted changes", forceRequired: true })`, importing `Worktree` from `@opencode/plugin`, to request
force confirmation without depending on Core or Git errors.
Refresh loads canonical configuration and asks all available strategies to discover worktrees across known checkout
roots. It combines results, preserves recorded ownership, and prunes missing directories. A failed discovery source
is logged while other sources continue. Existing directories remain saved even if no available strategy reports them.
```ts
await ctx.worktree.refresh({ projectID })
const inventory = await ctx.worktree.list({ projectID })
```
#### Reference
Implementations receive a suggested destination after naming and collision handling. Return the actual directory from
@@ -1069,8 +1080,8 @@ interface WorktreeDomain extends WorktreeApi {
```
Promise callbacks must cooperate with `signal` cancellation. Effect callbacks return Effects and use Effect interruption
instead. Registration and defaults are location-scoped; core configuration adapters feed the directory setting into
Worktree's state without giving the Worktree service a Config dependency.
instead. Registrations remain scoped to the plugin runtime; project operations use the canonical checkout's registry.
Core configuration adapters feed directory settings into that registry.
### Websearch
@@ -42,6 +42,23 @@ options, and `AsyncIterable` streams as `@opencode/client`. It exposes the
full generated client and adds the convenience aliases `sessions` and `events`
for the session and event groups.
## Worktrees
Worktree operations require a `projectID`. Create and refresh load configuration and plugins from the project's saved
canonical checkout. List reads saved inventory only; remove uses the recorded strategy without loading configuration
and fails if that strategy is unavailable.
```ts
const projectID = session.projectID
const worktree = await opencode.worktree.create({ projectID, name: "task" })
await opencode.worktree.refresh({ projectID })
const inventory = await opencode.worktree.list({ projectID })
await opencode.worktree.remove({ projectID, directory: worktree.directory, force: false })
```
Refresh discovers worktrees across known checkout roots using all available strategies. Register a custom strategy
through a [plugin's worktree transform](/build/plugins#worktrees).
## Stream events
```ts
+6 -4
View File
@@ -440,8 +440,8 @@ Set the parent directory for new local worktrees. OpenCode appends the requested
}
```
Relative paths resolve against the project's primary checkout, including when called from a subdirectory or linked
worktree. This applies to global and project configuration alike; absolute paths are used as-is, and `~/` resolves
Relative paths resolve against the project's saved canonical checkout. This applies to global and project
configuration alike; absolute paths are used as-is, and `~/` resolves
against the user's home directory.
For example, this global configuration places new worktrees under each project's own `.lane/trees/` directory:
@@ -455,10 +455,12 @@ For example, this global configuration places new worktrees under each project's
```
Without this setting, creation uses the server's data directory under `worktree/<first-six-project-ID-characters>`.
Configuration applies to the caller's location, not every clone sharing a project ID. Changing it does not move existing worktrees.
Worktree creation and refresh load configuration from the project's saved canonical checkout, including when other
clones share its project ID. Changing it does not move existing worktrees.
Git is the built-in default. A [plugin](/build/plugins#worktrees) that registers a strategy automatically becomes the
default for its location. Strategy-specific options belong to that plugin, not the `worktree` config object.
default in that plugin runtime. Project operations use the canonical checkout's runtime. Strategy-specific options
belong to that plugin, not the `worktree` config object.
### Plugins