feat(core): make worktree APIs project-based (#48867)

This commit is contained in:
James Long
2026-09-14 22:40:16 -04:00
committed by GitHub
parent 98f2a87304
commit 081eef3b80
55 changed files with 1295 additions and 1081 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())
}
@@ -1,12 +1,15 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import type { OpenCodeEvent } from "@opencode/client/promise"
const directory = "C:/Projects/settings-demo"
const sandboxes = Array.from({ length: 12 }, (_, index) => `${directory}/workspace-${index + 1}`)
const events: OpenCodeEvent[] = []
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
test.beforeEach(async ({ page }) => {
events.length = 0
await mockOpenCodeServer(page, {
directory,
project: {
@@ -40,6 +43,7 @@ test.beforeEach(async ({ page }) => {
time: { created: 1700000000000, updated: 1700000000000 },
})),
pageMessages: () => ({ items: [] }),
events: () => events.splice(0),
})
await page.addInitScript((directory) => {
localStorage.setItem(
@@ -52,6 +56,31 @@ test.beforeEach(async ({ page }) => {
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
test("an inventory event updates the open all-project worktree list", async ({ page }) => {
const discovered = `${directory}/discovered-workspace`
const inventory = [{ directory }, ...sandboxes.map((directory) => ({ directory, strategy: "git" }))]
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => route.fulfill({ json: inventory }),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
const listed = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === "/api/worktree" && response.request().method() === "GET",
)
inventory.push({ directory: discovered, strategy: "git" })
events.push({
id: "evt_settings_worktree_updated",
created: Date.now(),
type: "worktree.updated",
data: { projectID: "proj_settings_demo" },
})
expect((await listed).ok()).toBe(true)
await expect(settings.getByText(discovered, { exact: true })).toBeVisible()
})
test("settings has its own route and returns through app history", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
const home = page.getByRole("button", { name: "Home", exact: true })
@@ -223,7 +252,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()
@@ -252,7 +281,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",
@@ -284,8 +313,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(
@@ -46,6 +46,7 @@ for (const interaction of ["hover", "focus"] as const) {
test(`project Worktrees ${interaction} prefetches only its inventory and reuses the request`, async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const calls: string[] = []
const refreshes: string[] = []
const sessions: string[] = []
await page.route(
(url) => url.pathname === "/api/project",
@@ -54,13 +55,14 @@ 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()
},
)
page.on("request", (request) => {
const url = new URL(request.url())
if (url.pathname === "/api/worktree/refresh") refreshes.push(request.postDataJSON().projectID)
if (url.pathname === "/api/session" && url.searchParams.has("directory"))
sessions.push(url.searchParams.get("directory")!)
})
@@ -74,7 +76,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 +92,8 @@ 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])
expect(refreshes).toEqual([project.id])
await expect.poll(() => sessions.toSorted()).toEqual(sandboxes.toSorted())
})
}
@@ -113,7 +116,11 @@ for (const nested of [false, true]) {
await page.reload()
await page.getByTestId("settings-screen").getByRole("tab", { name: "Settings server", exact: true }).click()
}
const calls = { projects: 0, worktrees: [] as string[] }
const calls = { projects: 0, worktrees: [] as string[], refreshes: [] as string[] }
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/worktree/refresh")
calls.refreshes.push(request.postDataJSON().projectID)
})
await page.route(
(url) => url.pathname === "/api/project",
async (route) => {
@@ -124,9 +131,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()
},
)
@@ -141,12 +148,13 @@ for (const nested of [false, true]) {
await fetched
await worktrees.focus()
await expect(worktrees).toHaveAttribute("aria-selected", "false")
expect(calls).toEqual({ projects: 1, worktrees: [] })
expect(calls).toEqual({ projects: 1, worktrees: [], refreshes: [] })
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())
expect(calls.refreshes).toEqual([])
})
}
+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, {
@@ -87,22 +88,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 -1
View File
@@ -297,7 +297,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.list(id).then(() => ctx.sync.worktrees.refresh(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)),
),
),
),
+17 -12
View File
@@ -20,8 +20,7 @@ import { toggleMcp } from "./global-sync/mcp"
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"
import { createWorktreeInventory, withWorktreeInventory, worktreeInventoryViewKey } from "@/workspaces/inventory"
type GlobalStore = {
path: Path
@@ -85,12 +84,19 @@ 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)),
)
for (const key of [
worktreeInventoryViewKey(serverSDK.scope, projectID),
worktreeInventoryViewKey(serverSDK.scope),
]) {
queryClient.setQueryData<Project[]>(key, (projects) =>
projects?.map((project) => (project.id === projectID ? withWorktreeInventory(project, items) : project)),
)
}
},
})
const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"],
@@ -164,8 +170,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 +218,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 +228,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.list(event.data.projectID)
void bootstrap.refetch()
return
}
@@ -2,7 +2,7 @@ import { Menu } from "@opencode/ui/menu"
import { Icon } from "@opencode/ui/icon"
import { getFilename } from "@opencode/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import { createSignal, For, onCleanup, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
@@ -33,17 +33,27 @@ export function SessionWorkspaceMenu(props: {
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
const workspaces = () =>
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
const update = (items: Awaited<ReturnType<typeof serverSDK.api.worktree.list>>) =>
setDirectories(
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
)
onCleanup(
serverSDK.event.listen((event) => {
if (event.type !== "worktree.updated" || event.data.projectID !== props.project.id) return
void serverSDK.api.worktree
.list({ projectID: props.project.id })
.then(update)
.catch(() => undefined)
}),
)
const onOpenChange = (open: boolean) => {
props.onOpenChange?.(open)
if (!open) return
const sdk = serverSDK
void sdk.api.worktree
.list({ location: { directory: props.directory } })
.then((items) =>
setDirectories(
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
),
)
.list({ projectID: props.project.id })
.then(update)
.then(() => sdk.api.worktree.refresh({ projectID: props.project.id }))
.catch(() => undefined)
}
const move = async (selection: "create" | string) => {
@@ -4,6 +4,7 @@ import type { ServerSDK } from "@/runtime/server/client"
import type { ServerConnection } from "@/runtime/server/registry"
import { useServerCtx, type ServerCtx } from "@/runtime/server/runtime"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import { worktreeInventoryViewKey } from "@/workspaces/inventory"
function workspaceProjectsQuery(sdk: ServerSDK) {
return queryOptions({
@@ -13,18 +14,24 @@ function workspaceProjectsQuery(sdk: ServerSDK) {
})
}
export function workspaceInventoryQuery(context: ServerCtx, client: QueryClient, projectID?: string) {
export function workspaceInventoryQuery(
context: ServerCtx,
client: QueryClient,
projectID?: string,
shouldRefresh = projectID !== undefined,
) {
return queryOptions({
queryKey: [context.sdk.scope, "settings-workspace-inventory", projectID ?? null],
queryKey: worktreeInventoryViewKey(context.sdk.scope, projectID),
queryFn: async () =>
Promise.all(
(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.list(project.id)) ?? [
{ directory: project.canonical },
...project.sandboxes.map((directory) => ({ directory })),
]
if (shouldRefresh) void context.sync.worktrees.refresh(project.id)
return normalizeProjectInfo({ ...project, worktrees })
}),
),
@@ -43,7 +50,7 @@ export function useWorkspacesPrefetch(
if (!current || current.sdk.connection.status() !== "connected") return
const project = projectID?.()
if (project) {
void client.prefetchQuery(workspaceInventoryQuery(current, client, project))
void client.prefetchQuery(workspaceInventoryQuery(current, client, project, false))
return
}
// Server-level hover warms metadata without booting every project's Location.
@@ -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,
})
+44 -21
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 refreshed = Promise.withResolvers<void>()
const refreshes: string[] = []
const calls: string[] = []
const updates: Array<[string, WorktreeDirectory[]]> = []
const inventory = createWorktreeInventory({
@@ -14,69 +16,90 @@ 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) => {
refreshes.push(input.projectID)
return refreshed.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, refreshed, refreshes }
}
describe("createWorktreeInventory", () => {
test("loads once per project, shares in-flight work, and publishes the result", async () => {
test("lists a project, shares in-flight work, and publishes each result", async () => {
const gate = Promise.withResolvers<void>()
const setupResult = setup(async (directory) => {
await gate.promise
return [{ directory }, { directory: `${directory}/feature`, strategy: "git" }]
})
const first = setupResult.inventory.load("/repo")
const second = setupResult.inventory.load("/repo/")
const first = setupResult.inventory.list("/repo")
const second = setupResult.inventory.list("/repo")
expect(setupResult.calls).toEqual(["/repo"])
gate.resolve()
expect(await first).toHaveLength(2)
expect(await second).toHaveLength(2)
await setupResult.inventory.load("/repo")
expect(setupResult.calls).toEqual(["/repo"])
await setupResult.inventory.list("/repo")
expect(setupResult.calls).toEqual(["/repo", "/repo"])
expect(setupResult.updates).toEqual([
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
])
expect(setupResult.inventory.cached("/repo/")).toHaveLength(2)
expect(setupResult.inventory.cached("/repo")).toHaveLength(2)
expect(setupResult.refreshes).toEqual([])
setupResult.client.clear()
})
test("refreshes only inventories a view already loaded", async () => {
test("list reads saved inventory without discovery", async () => {
const setupResult = setup(async (directory) => [{ directory }])
await setupResult.inventory.refresh("/never-opened")
expect(setupResult.calls).toEqual([])
await setupResult.inventory.load("/opened")
await setupResult.inventory.refresh("/opened")
expect(setupResult.calls).toEqual(["/opened", "/opened"])
await setupResult.inventory.list("/opened")
expect(setupResult.calls).toEqual(["/opened"])
expect(setupResult.refreshes).toEqual([])
setupResult.client.clear()
})
test("a failed load is not cached and never rejects the caller", async () => {
test("a failed list is not cached and never rejects the caller", async () => {
let fail = true
const setupResult = setup(async (directory) => {
if (fail) throw new Error("Location unavailable")
return [{ directory }]
})
expect(await setupResult.inventory.load("/repo")).toBeUndefined()
expect(await setupResult.inventory.list("/repo")).toBeUndefined()
expect(setupResult.inventory.cached("/repo")).toBeUndefined()
fail = false
expect(await setupResult.inventory.load("/repo")).toEqual([{ directory: "/repo" }])
expect(await setupResult.inventory.list("/repo")).toEqual([{ directory: "/repo" }])
expect(setupResult.calls).toEqual(["/repo", "/repo"])
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("refresh discovers without changing cached inventory until the next list", async () => {
const rows = [{ directory: "/repo" }]
const result = setup(async () => [...rows])
expect(await result.inventory.list("project")).toEqual(rows)
const pending = result.inventory.refresh("project")
rows.push({ directory: "/external" })
expect(result.inventory.cached("project")).toEqual([{ directory: "/repo" }])
result.refreshed.resolve()
expect(await pending).toBeUndefined()
expect(result.calls).toEqual(["project"])
expect(result.refreshes).toEqual(["project"])
expect(await result.inventory.list("project")).toEqual(rows)
result.client.clear()
})
})
describe("withWorktreeInventory", () => {
+23 -20
View File
@@ -3,11 +3,14 @@ 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
}
export function worktreeInventoryViewKey(scope: ServerScope, projectID?: string) {
return [scope, "settings-workspace-inventory", projectID ?? null] as const
}
// Project metadata arrives without worktrees; a loaded inventory supplies the workspace list.
@@ -22,37 +25,37 @@ 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.
// `worktree.updated` and reconnect invalidation drive list reloads; time alone does not re-list.
staleTime: Infinity,
gcTime: Infinity,
retry: false,
})
return {
cached: (directory: string) =>
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, directory)),
load: (directory: string) => input.queryClient.fetchQuery(options(directory)).catch(() => undefined),
// 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)
},
cached: (projectID: string) =>
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, projectID)),
list: (projectID: string) =>
input.queryClient.fetchQuery({ ...options(projectID), staleTime: 0 }).catch(() => undefined),
refresh: (projectID: string) =>
input
.api()
.refresh({ projectID })
.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.list(id).then(() => server.ctx.sync.worktrees.refresh(id))
})
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
+7 -8
View File
@@ -2166,33 +2166,32 @@ export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type WorktreeListInput = { readonly location?: { readonly directory?: 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 } | 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 } | 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 } | 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
@@ -1433,21 +1433,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)),
)
@@ -1455,14 +1454,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
@@ -1952,33 +1952,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,
@@ -1988,22 +1987,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 -17
View File
@@ -6065,44 +6065,41 @@ export type ReferenceListInput = {
export type ReferenceListOutput = { location: LocationPublicRef; data: Array<ReferenceInfo> }
export type WorktreeListInput = {
readonly location?: { readonly location?: { readonly directory?: 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 } | 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
@@ -6113,16 +6110,14 @@ export type WorktreeCreateInput = {
export type WorktreeCreateOutput = WorktreeInfo
export type WorktreeRemoveInput = {
readonly location?: { readonly location?: { readonly directory?: 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 } | undefined }["location"]
}
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeRefreshOutput = void
+25 -21
View File
@@ -332,7 +332,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",
@@ -340,71 +340,75 @@ test("all worktree operations use location-based routes without a project parame
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
if (request.method === "GET") return Response.json([{ directory: "/tmp/project" }])
if (request.method === "POST" && !request.url.endsWith("/refresh"))
if (request.method === "POST" && new URL(request.url).pathname === "/api/worktree")
return Response.json({ directory: "/tmp/worktrees/api" })
if (request.method === "POST") return new Response(null, { status: 204 })
return new Response(null, { status: 204 })
},
})
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)
if (request.method === "GET") return Response.json([{ directory: "/configured/task", strategy: "git" }])
if (request.method === "DELETE" || new URL(request.url).pathname.endsWith("/refresh"))
return new Response(null, { status: 204 })
if (request.method === "DELETE") return new Response(null, { status: 204 })
if (new URL(request.url).pathname.endsWith("/refresh")) return new Response(null, { status: 204 })
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("shell list and remove use the public HTTP contract", 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
@@ -26,8 +26,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"
@@ -74,8 +73,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
@@ -30,6 +30,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))
@@ -507,13 +495,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({
@@ -577,6 +565,7 @@ export const requirements = LayerNode.group([
Vcs.node,
WebSearch.node,
Worktree.node,
WorktreeStrategies.node,
Generate.node,
Permission.node,
PluginHooks.node,
+4 -1
View File
@@ -31,6 +31,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"
@@ -141,6 +142,7 @@ const services = [
Watcher.Service,
WellKnown.Service,
Worktree.Service,
WorktreeStrategies.Service,
] as const
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
@@ -191,11 +193,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,
@@ -243,7 +247,6 @@ const post = [
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
+94 -117
View File
@@ -6,13 +6,14 @@ 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"
@@ -35,12 +38,6 @@ export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Worktree.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshResult = Schema.Struct({
updated: Schema.Array(AbsolutePath),
removed: Schema.Array(AbsolutePath),
}).annotate({ identifier: "Worktree.RefreshResult" })
export type RefreshResult = typeof RefreshResult.Type
export const Info = Worktree.Info
export type Info = typeof Info.Type
@@ -52,7 +49,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 +67,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 +89,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<void, 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,40 +110,29 @@ 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 = {
list: Effect.fnUntraced(function* () {
list: Effect.fnUntraced(function* (projectID: Project.ID) {
const rows = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
@@ -173,7 +142,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
}),
find: Effect.fnUntraced(function* (directory: AbsolutePath) {
find: Effect.fnUntraced(function* (projectID: Project.ID, directory: AbsolutePath) {
const row = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
@@ -182,7 +151,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
}),
create: (input: StoredInput, tx?: Transaction) =>
create: (projectID: Project.ID, input: StoredInput, tx?: Transaction) =>
(tx ?? db)
.insert(WorktreeTable)
.values({
@@ -204,7 +173,7 @@ const layer = Layer.effect(
Effect.orDie,
Effect.map((row) => row !== undefined),
),
remove: (directory: AbsolutePath, tx?: Transaction) =>
remove: (projectID: Project.ID, directory: AbsolutePath, tx?: Transaction) =>
(tx ?? db)
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
@@ -216,26 +185,28 @@ const layer = Layer.effect(
),
}
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* ops.find(projectID, 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 +226,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* ops.create(input.projectID, {
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 +252,53 @@ 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 worktreeDirectory = yield* canonical(fs, input.directory)
const stored = yield* ops.find(worktreeDirectory)
const stored = yield* ops.find(input.projectID, worktreeDirectory)
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), state.get().strategies)
const settings = yield* load(row.worktree, current)
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), settings.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(input.projectID, worktreeDirectory))
}, Effect.scoped)
const refresh = Effect.fn("Worktree.refresh")(function* () {
yield* local
const stored = yield* ops.list()
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 stored = yield* ops.list(input.projectID)
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(
@@ -336,23 +316,20 @@ const layer = Layer.effect(
const changes = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.filter(Array.from(discovered.values()), (item) => ops.create(item, tx)).pipe(
Effect.map((items) => items.map((item) => item.directory)),
),
removed: Effect.filter(removed, (directory) => ops.remove(directory, tx)),
updated: Effect.filter(Array.from(discovered.values()), (item) =>
ops.create(input.projectID, item, tx),
).pipe(Effect.map((items) => items.map((item) => item.directory))),
removed: Effect.filter(removed, (directory) => ops.remove(input.projectID, directory, tx)),
}),
)
.pipe(Effect.orDie)
yield* changed(changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
}, 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* ops.list(input.projectID)
}),
create,
remove,
@@ -361,10 +338,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
@@ -34,6 +34,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"
@@ -98,6 +99,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
Watcher.node,
WebSearch.node,
Worktree.node,
WorktreeStrategies.node,
]),
[
Location.node.replace(tempLocationLayer),
+108 -109
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),
@@ -586,10 +594,10 @@ describe("Worktree", () => {
}),
)
it.live("refresh discovers and prunes an externally managed git worktree", () =>
it.live("refresh finds 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`)
@@ -615,7 +623,7 @@ describe("Worktree", () => {
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
const existing = abs(yield* Effect.promise(() => fs.realpath(unchanged)))
expect(yield* worktree.refresh()).toEqual({ updated: [discovered], removed: [] })
yield* worktree.refresh()
expect(yield* stored(input.projectID)).toEqual(
[
@@ -628,10 +636,7 @@ describe("Worktree", () => {
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
yield* Effect.promise(() => $`git worktree remove --force ${unchanged}`.cwd(input.root.path).quiet())
expect(yield* worktree.refresh()).toEqual({
updated: [],
removed: [discovered, existing].toSorted(),
})
yield* worktree.refresh()
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
}),
)
@@ -641,7 +646,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 +671,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 +679,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,12 +687,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: [],
removed: [],
})
yield* worktree.refresh()
expect(yield* worktree.list()).toEqual([{ directory: input.sourceDirectory, strategy: undefined }])
}),
)
@@ -700,9 +703,9 @@ 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] })
yield* worktree.refresh()
expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
}),
@@ -711,7 +714,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 +734,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 +764,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 +777,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 +795,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 +811,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 +828,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 scans 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 +854,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 +880,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 +891,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 +913,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 +940,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 -138
View File
@@ -11371,34 +11371,12 @@
"operationId": "worktree.list",
"parameters": [
{
"name": "location",
"name": "projectID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -11414,18 +11392,11 @@
}
},
"400": {
"description": "WorktreeError | InvalidRequestError",
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/WorktreeErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
@@ -11439,46 +11410,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"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"200": {
@@ -11517,9 +11467,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": {
@@ -11535,38 +11495,7 @@
"delete": {
"tags": ["worktree"],
"operationId": "worktree.remove",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -11598,9 +11527,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": "Load the project's canonical configuration and remove a saved worktree using its recorded strategy.",
"summary": "Remove worktree",
"requestBody": {
"content": {
@@ -11618,38 +11557,7 @@
"post": {
"tags": ["worktree"],
"operationId": "worktree.refresh",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -11681,10 +11589,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, discover worktrees across known checkout roots using all available strategies, and reconcile saved state.",
"summary": "Refresh worktrees",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/vcs": {
@@ -19053,7 +18988,7 @@
"Worktree.CreateInput": {
"type": "object",
"properties": {
"strategy": {
"projectID": {
"type": "string"
},
"from": {
@@ -19069,6 +19004,7 @@
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
},
"Worktree.Directory": {
@@ -19103,6 +19039,9 @@
"Worktree.RemoveInput": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
},
"directory": {
"type": "string"
},
@@ -19110,7 +19049,7 @@
"type": "boolean"
}
},
"required": ["directory", "force"],
"required": ["projectID", "directory", "force"],
"additionalProperties": false
},
"WorktreeErrorEncoded": {
@@ -19243,7 +19182,7 @@
},
{
"name": "worktree",
"description": "Location-scoped worktree management routes."
"description": "Project-based worktree management routes."
},
{
"name": "vcs",
+2 -2
View File
@@ -51,7 +51,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>
@@ -85,6 +84,7 @@ type ApiGroups<
| typeof ServerGroup
| typeof DebugGroup
| typeof MigrationGroup
| typeof WorktreeGroup
| typeof GenerateGroup
| typeof PersistentPtyGroup
| typeof CredentialGroup
@@ -171,7 +171,7 @@ const makeApiFromGroup = <
.add(PersistentPtyGroup)
.add(ShellGroup.middleware(locationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(WorktreeGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
.add(VcsGroup.middleware(locationMiddleware))
.add(DebugGroup)
.add(MigrationGroup)
+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:
"Load the project's canonical configuration and remove a saved worktree using its recorded strategy.",
}),
),
)
.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, discover worktrees across known checkout roots using all available strategies, and reconcile saved state.",
}),
),
)
.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,
@@ -0,0 +1,13 @@
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "test.worktree-delegate",
async setup(ctx) {
const projectID = ctx.options.projectID ?? ctx.location.project.id
if (typeof projectID !== "string") throw new Error("Missing target project")
await ctx.worktree.create({
projectID,
name: "delegated",
})
},
})
+185 -38
View File
@@ -9,8 +9,117 @@ 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 loads canonical strategies 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 } })
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()).toContainEqual({ directory })
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 +131,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 +145,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 +162,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 +175,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 +191,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 recorded strategy removal",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-plugins-")))
@@ -113,7 +230,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,56 +241,86 @@ 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 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 }),
})
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)
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")
expect(await api.worktree.list({ projectID })).toEqual(rows)
expect(await api.debug.location.list()).toContainEqual({ directory: first })
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(
"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 destination = path.join(tmp.path, "copies")
yield* Effect.promise(async () => {
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({
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") },
],
}),
)
})
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: 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",
})
})
}),
30_000,
+20 -17
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"
@@ -120,19 +120,24 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
pending = creation()
setCreation(undefined)
}
const [worktrees] = createResource(
() => (view().type === "worktrees" ? view() : undefined),
() =>
client.api.worktree
.list({
location: {
directory: data.project.get(projectID()!)!.canonical,
},
})
.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 []
}),
)
const refreshed = new Set<string>()
createEffect(() => {
const id = projectID()
if (!id || worktrees.latest === undefined || refreshed.has(id)) return
refreshed.add(id)
void client.api.worktree.refresh({ projectID: id }).catch(() => undefined)
})
onCleanup(
client.event.on("worktree.updated", (event) => {
if (event.data.projectID === projectID()) void worktreeActions.refetch()
}),
)
const [matched] = createResource(
@@ -441,9 +446,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
setCreating(true)
void client.api.worktree
.create({
location: {
directory: data.project.get(id)!.canonical,
},
projectID: id,
...(value.trim() ? { name: value.trim() } : {}),
})
.then((created) => {
@@ -1,6 +1,6 @@
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import path from "path"
import { DialogSelect, dialogSelectContentWidth, type DialogSelectOption } from "../ui/dialog-select"
import { dialogWidth, useDialog } from "../ui/dialog"
@@ -47,9 +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,
})
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving)
@@ -80,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) {
@@ -95,7 +92,21 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
}
},
)
const directoryData = createMemo(() => directories() ?? props.initialDirectories)
let refreshed = false
createEffect(() => {
if (props.fixture || props.initialRemoving || directories.latest === undefined || refreshed) return
refreshed = true
void refresh().catch(() => undefined)
})
onCleanup(
client.event.on("worktree.updated", (event) => {
if (event.data.projectID === props.projectID) void refetch()
}),
)
function refresh() {
return client.api.worktree.refresh({ projectID: props.projectID })
}
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())
@@ -228,7 +239,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({
@@ -384,7 +395,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
command: "dialog.move_session.refresh",
title: "refresh",
selection: "none",
onTrigger: () => void refetch(),
onTrigger: () => void refresh().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 },
projectID: project.id,
name,
})
const directory = result.directory
+12 -7
View File
@@ -262,7 +262,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("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" }])
},
@@ -376,9 +377,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"))
@@ -406,7 +407,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", 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")
@@ -427,8 +428,12 @@ test.each(["", "search-ui"])("creates a worktree named '%s' and opens it", async
if (url.pathname === "/api/location")
return json({ directory: root, project: { id: projectID, directory: root, canonical: root } })
if (url.pathname !== "/api/worktree") return undefined
expect(url.searchParams.get("location[directory]")).toBe(root)
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 })
},
@@ -471,7 +476,7 @@ test.each(["", "search-ui"])("creates a worktree named '%s' and opens it", async
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(payload).toEqual(name ? { name } : {})
expect(payload).toEqual({ projectID, ...(name ? { name } : {}) })
expect(fixture.route.data).toEqual({ type: "home", location: { directory: created } })
expect(fixture.location.ref).toEqual({ directory: created })
} finally {
+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 }])
expect(fixture.requests).toEqual([
{ payload: { projectID: "proj_test", name: "fresh" }, directory: 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 } })
@@ -70,11 +74,11 @@ test.each([
const frame = await fixture.create()
expect(fixture.reads.worktrees).toEqual([selected.directory])
expect(fixture.reads.worktrees).toEqual(["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` },
{ payload: { projectID: "proj_test", name: "fresh" }, directory: null },
])
expect(fixture.data.location.info(selected)?.project.canonical).toBe(clone)
expect(fixture.moves).toEqual([])
@@ -91,7 +95,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([])
@@ -102,7 +108,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()
@@ -113,7 +119,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 scans 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.refresh.length === 1)
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.refresh.length === 2)
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()
}
@@ -177,7 +204,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()
}
@@ -195,7 +225,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
@@ -235,10 +265,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" }],
)
@@ -255,7 +285,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 })
@@ -873,7 +873,7 @@ describe("V2 mini transport", () => {
)
await Bun.sleep(0)
expect(client.session.message).toHaveBeenCalledWith(
expect(client.session.message.get).toHaveBeenCalledWith(
{ sessionID: "ses_child", messageID: "msg_child_source" },
{ signal: expect.any(AbortSignal) },
)
+77 -138
View File
@@ -11371,34 +11371,12 @@
"operationId": "worktree.list",
"parameters": [
{
"name": "location",
"name": "projectID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -11414,18 +11392,11 @@
}
},
"400": {
"description": "WorktreeError | InvalidRequestError",
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/WorktreeErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
@@ -11439,46 +11410,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"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"200": {
@@ -11517,9 +11467,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": {
@@ -11535,38 +11495,7 @@
"delete": {
"tags": ["worktree"],
"operationId": "worktree.remove",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -11598,9 +11527,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": "Load the project's canonical configuration and remove a saved worktree using its recorded strategy.",
"summary": "Remove worktree",
"requestBody": {
"content": {
@@ -11618,38 +11557,7 @@
"post": {
"tags": ["worktree"],
"operationId": "worktree.refresh",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -11681,10 +11589,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, discover worktrees across known checkout roots using all available strategies, and reconcile saved state.",
"summary": "Refresh worktrees",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/vcs": {
@@ -19053,7 +18988,7 @@
"Worktree.CreateInput": {
"type": "object",
"properties": {
"strategy": {
"projectID": {
"type": "string"
},
"from": {
@@ -19069,6 +19004,7 @@
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
},
"Worktree.Directory": {
@@ -19103,6 +19039,9 @@
"Worktree.RemoveInput": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
},
"directory": {
"type": "string"
},
@@ -19110,7 +19049,7 @@
"type": "boolean"
}
},
"required": ["directory", "force"],
"required": ["projectID", "directory", "force"],
"additionalProperties": false
},
"WorktreeErrorEncoded": {
@@ -19243,7 +19182,7 @@
},
{
"name": "worktree",
"description": "Location-scoped worktree management routes."
"description": "Project-based worktree management routes."
},
{
"name": "vcs",
+77 -138
View File
@@ -11371,34 +11371,12 @@
"operationId": "worktree.list",
"parameters": [
{
"name": "location",
"name": "projectID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -11414,18 +11392,11 @@
}
},
"400": {
"description": "WorktreeError | InvalidRequestError",
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/WorktreeErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
@@ -11439,46 +11410,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"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"200": {
@@ -11517,9 +11467,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": {
@@ -11535,38 +11495,7 @@
"delete": {
"tags": ["worktree"],
"operationId": "worktree.remove",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -11598,9 +11527,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": "Load the project's canonical configuration and remove a saved worktree using its recorded strategy.",
"summary": "Remove worktree",
"requestBody": {
"content": {
@@ -11618,38 +11557,7 @@
"post": {
"tags": ["worktree"],
"operationId": "worktree.refresh",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"parameters": [],
"security": [],
"responses": {
"204": {
@@ -11681,10 +11589,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, discover worktrees across known checkout roots using all available strategies, and reconcile saved state.",
"summary": "Refresh worktrees",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/vcs": {
@@ -19053,7 +18988,7 @@
"Worktree.CreateInput": {
"type": "object",
"properties": {
"strategy": {
"projectID": {
"type": "string"
},
"from": {
@@ -19069,6 +19004,7 @@
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
},
"Worktree.Directory": {
@@ -19103,6 +19039,9 @@
"Worktree.RemoveInput": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
},
"directory": {
"type": "string"
},
@@ -19110,7 +19049,7 @@
"type": "boolean"
}
},
"required": ["directory", "force"],
"required": ["projectID", "directory", "force"],
"additionalProperties": false
},
"WorktreeErrorEncoded": {
@@ -19243,7 +19182,7 @@
},
{
"name": "worktree",
"description": "Location-scoped worktree management routes."
"description": "Project-based worktree management routes."
},
{
"name": "vcs",
@@ -1071,34 +1071,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 loads the canonical configuration and activates its plugins, then uses the worktree's recorded strategy rather
than the currently selected default. An unavailable recorded owner produces an error. The worktree's destination can
be elsewhere:
```ts
await ctx.worktree.remove({
directory: "/worktrees/task",
location: { directory: "/repos/app" },
projectID,
force: false,
})
```
@@ -1107,6 +1109,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 find 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. Observe
`worktree.updated` and call `list` again to read changed inventory.
```ts
await ctx.worktree.refresh({ projectID })
```
#### Reference
Implementations receive a suggested destination after naming and collision handling. Return the actual directory from
@@ -1141,8 +1152,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,22 @@ 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 activates canonical plugins and uses the recorded strategy,
failing if that strategy is unavailable.
```ts
const projectID = session.projectID
const worktree = await opencode.worktree.create({ projectID, name: "task" })
await opencode.worktree.refresh({ 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