From c30285c14880fa0208cb1a845ce71e26f76d1ffb Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 4 Sep 2026 19:07:40 -0400 Subject: [PATCH] feat(worktree): support configurable plugin strategies (#47358) --- bun.lock | 2 + .../new-session-workspace-pending.spec.ts | 20 +- .../e2e/regression/settings-loading.spec.ts | 67 ++- .../e2e/regression/workspace-accent.spec.ts | 19 +- packages/app/e2e/utils/mock-api.ts | 12 +- .../server/global-sync/bootstrap.test.ts | 79 ++-- .../runtime/server/global-sync/bootstrap.ts | 2 +- .../timeline/session-workspace-menu.tsx | 3 +- .../src/settings/workspaces/workspaces.tsx | 4 +- packages/app/src/workspaces/create.test.ts | 2 +- packages/app/src/workspaces/create.ts | 2 +- packages/client/src/effect/api/api.ts | 20 +- .../client/src/effect/generated/client.ts | 24 +- packages/client/src/promise/api.ts | 1 + .../client/src/promise/generated/client.ts | 28 +- .../client/src/promise/generated/types.ts | 35 +- packages/client/test/promise.test.ts | 49 ++- packages/core/src/config/normalize.ts | 1 + packages/core/src/config/plugin/worktree.ts | 35 ++ packages/core/src/instance.ts | 4 +- packages/core/src/plugin/host.ts | 40 ++ packages/core/src/plugin/internal.ts | 5 + packages/core/src/worktree.ts | 297 +++++++------- packages/core/src/worktree/refresh.ts | 28 ++ packages/core/test/plugin/fixture.ts | 2 + packages/core/test/plugin/host.ts | 8 + packages/core/test/plugin/worktree.test.ts | 52 +++ packages/core/test/shared-schema.test.ts | 1 - packages/core/test/worktree.test.ts | 380 +++++++++++++++--- packages/plugin/src/effect/index.ts | 1 + packages/plugin/src/effect/plugin.ts | 2 + packages/plugin/src/effect/worktree.ts | 21 + packages/plugin/src/promise/adapter.ts | 22 + packages/plugin/src/promise/index.ts | 1 + packages/plugin/src/promise/plugin.ts | 2 + packages/plugin/src/promise/worktree.ts | 23 ++ packages/plugin/src/worktree.ts | 19 + packages/protocol/openapi.json | 265 +++++++++--- packages/protocol/src/api.ts | 4 +- packages/protocol/src/groups/worktree.ts | 90 +++-- packages/schema/src/config.ts | 4 + packages/schema/src/config/worktree.ts | 10 + packages/schema/src/worktree.ts | 21 +- packages/schema/test/config.test.ts | 9 + packages/schema/test/contract-hygiene.test.ts | 1 - packages/schema/test/worktree.test.ts | 25 +- packages/server/package.json | 2 + packages/server/src/handlers/worktree.ts | 44 +- packages/server/src/routes.ts | 2 - .../test/fixture/worktree-delegate/index.ts | 13 + .../test/fixture/worktree-plugin/index.ts | 47 +++ packages/server/test/worktree.test.ts | 260 +++++++++--- .../tui/src/component/dialog-move-session.tsx | 24 +- packages/tui/src/component/prompt/move.tsx | 7 +- .../tui/test/cli/tui/prompt-move.test.tsx | 49 ++- packages/tui/test/fixture/tui-client.ts | 4 +- packages/www/openapi.json | 265 +++++++++--- packages/www/public/openapi.json | 265 +++++++++--- .../src/docs/content/build/plugins/index.mdx | 106 +++++ packages/www/src/docs/content/config.mdx | 19 + 60 files changed, 2203 insertions(+), 646 deletions(-) create mode 100644 packages/core/src/config/plugin/worktree.ts create mode 100644 packages/core/src/worktree/refresh.ts create mode 100644 packages/core/test/plugin/worktree.test.ts create mode 100644 packages/plugin/src/effect/worktree.ts create mode 100644 packages/plugin/src/promise/worktree.ts create mode 100644 packages/plugin/src/worktree.ts create mode 100644 packages/schema/src/config/worktree.ts create mode 100644 packages/server/test/fixture/worktree-delegate/index.ts create mode 100644 packages/server/test/fixture/worktree-plugin/index.ts diff --git a/bun.lock b/bun.lock index 50962a201e3..8d044a649ed 100644 --- a/bun.lock +++ b/bun.lock @@ -695,6 +695,8 @@ "effect": "catalog:", }, "devDependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/plugin": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/packages/app/e2e/regression/new-session-workspace-pending.spec.ts b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts index 3559961abed..45eb6028d23 100644 --- a/packages/app/e2e/regression/new-session-workspace-pending.spec.ts +++ b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts @@ -490,20 +490,24 @@ async function openDraft( page.on("request", (request) => { if (request.method() !== "POST") return const path = new URL(request.url()).pathname - if (path === `/api/worktree/${projectID}`) { + if (path === "/api/worktree") { + expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory) calls.push("worktree") worktreeRequests.push(request.postDataJSON()) } if (path === "/api/session") calls.push("session") if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt") }) - await page.route(`**/api/worktree/${projectID}`, async (route) => { - if (route.request().method() !== "POST") return route.fallback() - // Keep the real HTTP response pending until the test has checked the preview. - const response = await worktree.promise - if (response.status === 200) project.sandboxes.push(workspace) - await route.fulfill({ ...response, headers }) - }) + await page.route( + (url) => url.pathname === "/api/worktree", + async (route) => { + if (route.request().method() !== "POST") return route.fallback() + // Keep the real HTTP response pending until the test has checked the preview. + const response = await worktree.promise + if (response.status === 200) project.sandboxes.push(workspace) + await route.fulfill({ ...response, headers }) + }, + ) await page.route("**/api/session", async (route) => { if (route.request().method() !== "POST") return route.fallback() const body: Record = route.request().postDataJSON() diff --git a/packages/app/e2e/regression/settings-loading.spec.ts b/packages/app/e2e/regression/settings-loading.spec.ts index 4749e79b970..d7784ff205e 100644 --- a/packages/app/e2e/regression/settings-loading.spec.ts +++ b/packages/app/e2e/regression/settings-loading.spec.ts @@ -87,16 +87,24 @@ test("recording a new session shortcut stays in settings until recording finishe test("workspaces opens without waiting for inventory or sessions", async ({ page }) => { const inventory = Promise.withResolvers() const sessions = Promise.withResolvers() - await page.route("**/api/worktree/*", async (route) => { - await inventory.promise - await route.fallback() - }) + await page.route( + (url) => url.pathname === "/api/worktree", + async (route) => { + await inventory.promise + await route.fallback() + }, + ) await page.route("**/api/session?*", async (route) => { if (new URL(route.request().url()).searchParams.has("directory")) await sessions.promise await route.fallback() }) const settings = page.getByTestId("settings-screen") - const requested = page.waitForRequest((request) => new URL(request.url()).pathname.startsWith("/api/worktree/")) + const requested = page.waitForRequest( + (request) => + new URL(request.url()).pathname === "/api/worktree" && + new URL(request.url()).searchParams.get("location[directory]") === directory && + request.method() === "GET", + ) await settings.getByRole("tab", { name: "Worktrees", exact: true }).click() await requested await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible() @@ -110,16 +118,57 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible() const refresh = Promise.withResolvers() - await page.route("**/api/worktree/*", async (route) => { - await refresh.promise - await route.fallback() - }) + await page.route( + (url) => url.pathname === "/api/worktree", + async (route) => { + await refresh.promise + await route.fallback() + }, + ) await settings.getByRole("tab", { name: "Preferences", exact: true }).click() await settings.getByRole("tab", { name: "Worktrees", exact: true }).click() await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible() refresh.resolve() }) +test("worktree deletion sends the project location separately from the target", async ({ page }) => { + const removed = new Set() + await page.route( + (url) => url.pathname === "/api/worktree", + async (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + json: [ + { directory }, + ...sandboxes.filter((item) => !removed.has(item)).map((directory) => ({ directory, strategy: "git" })), + ], + }) + } + if (route.request().method() === "DELETE") { + removed.add(route.request().postDataJSON().directory) + return route.fulfill({ status: 204 }) + } + return route.fallback() + }, + ) + const settings = page.getByTestId("settings-screen") + await settings.getByRole("tab", { name: "Worktrees", exact: true }).click() + await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible() + await settings.getByRole("button", { name: 'Delete worktree "workspace-1"?', exact: true }).click() + const confirmation = page.getByRole("dialog", { name: "Delete worktree", exact: true }) + const remove = confirmation.getByRole("button", { name: "Delete worktree", exact: true }) + await expect(remove).toBeEnabled() + const deleting = page.waitForRequest( + (request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "DELETE", + ) + 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 }) + await expect(settings.getByText(sandboxes[0], { exact: true })).toHaveCount(0) + await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible() +}) + test("extensions opens without waiting for MCPs", async ({ page }) => { const mcps = Promise.withResolvers() await page.route("**/api/mcp", async (route) => { diff --git a/packages/app/e2e/regression/workspace-accent.spec.ts b/packages/app/e2e/regression/workspace-accent.spec.ts index 10c2770bdcf..fcdbdb55e6c 100644 --- a/packages/app/e2e/regression/workspace-accent.spec.ts +++ b/packages/app/e2e/regression/workspace-accent.spec.ts @@ -97,7 +97,9 @@ for (const theme of ["light", "dark"] as const) { const refreshed = page.waitForResponse( (response) => - new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET", + new URL(response.url()).pathname === "/api/worktree" && + new URL(response.url()).searchParams.get("location[directory]") === root && + response.request().method() === "GET", ) view.worktrees.push({ directory: workspace, strategy: "git" }) view.events.push({ @@ -199,10 +201,13 @@ async function openSession(page: Page, directory: string, worktrees = [...invent events: () => events.splice(0), }) // Keep authoritative inventory independent of the raw project's empty sandboxes. - await page.route(`**/api/worktree/${projectID}`, (route) => { - if (route.request().method() !== "GET") return route.fallback() - return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } }) - }) + await page.route( + (url) => url.pathname === "/api/worktree", + (route) => { + if (route.request().method() !== "GET") return route.fallback() + return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } }) + }, + ) if (draft) await page.addInitScript( ({ root, server }) => { @@ -222,7 +227,9 @@ async function openSession(page: Page, directory: string, worktrees = [...invent ) const loaded = page.waitForResponse( (response) => - new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET", + new URL(response.url()).pathname === "/api/worktree" && + new URL(response.url()).searchParams.get("location[directory]") === root && + response.request().method() === "GET", ) await page.goto( draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`, diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index 3f0ffd93b85..0b0c7eeaa71 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -71,27 +71,23 @@ const Group = HttpApiGroup.make("mock") .add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json })) .add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json })) .add( - HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", { - params: { projectID: Schema.String }, + HttpApiEndpoint.get("worktreeList", "/api/worktree", { success: Json, }), ) .add( - HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", { - params: { projectID: Schema.String }, + HttpApiEndpoint.post("worktreeCreate", "/api/worktree", { payload: JsonPayload, success: Json, }), ) .add( - HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", { - params: { projectID: Schema.String }, + HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", { success: NoContent, }), ) .add( - HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", { - params: { projectID: Schema.String }, + HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/refresh", { success: NoContent, }), ) diff --git a/packages/app/src/runtime/server/global-sync/bootstrap.test.ts b/packages/app/src/runtime/server/global-sync/bootstrap.test.ts index 034a4568388..511cff488cd 100644 --- a/packages/app/src/runtime/server/global-sync/bootstrap.test.ts +++ b/packages/app/src/runtime/server/global-sync/bootstrap.test.ts @@ -7,9 +7,6 @@ import { ServerScope } from "@/runtime/server/scope" import type { ServerApi } from "@/runtime/server/api" import type { ServerSync } from "@/runtime/server/sync" -type ProjectApi = ServerApi["project"] -type WorktreeApi = ServerApi["worktree"] - test("bootstraps projects through the native store setter and preserves subsequent updates", async () => { const api = OpenCode.make({ baseUrl: "http://opencode.local", @@ -79,26 +76,32 @@ describe("query keys", () => { expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" }) }) - test("loads projects from the current endpoint", async () => { + test("loads each project's inventory through its own location using the real client", async () => { const calls: string[] = [] - const projects = { - list: async () => [ - { id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, - { id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, - ], - } as unknown as ProjectApi - const worktrees = { - list: async ({ projectID }: { projectID: string }) => { - calls.push(projectID) - return [ - { directory: `/${projectID}` }, - { directory: `/${projectID}/clone` }, - { directory: `/${projectID}/copy`, strategy: "git" }, - ] - }, - } as unknown as WorktreeApi + const api = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(new Request(input, init).url) + if (url.pathname === "/api/project") + return Response.json([ + { id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, + { id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, + ]) + const directory = url.searchParams.get("location[directory]") + if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`) + calls.push(directory) + return Response.json([ + { directory }, + { directory: `${directory}/clone` }, + { directory: `${directory}/copy`, strategy: "git" }, + ]) + }, + { preconnect() {} }, + ), + }) - const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees)) + const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree)) expect(result.map((project) => project.id)).toEqual(["a", "b"]) expect(result.map((project) => project.sandboxes)).toEqual([ @@ -109,24 +112,30 @@ describe("query keys", () => { [{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }], [{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }], ]) - expect(calls.toSorted()).toEqual(["a", "b"]) + expect(calls.toSorted()).toEqual(["/a", "/b"]) }) test("keeps projects whose directory inventory cannot load", async () => { - const projects = { - list: async () => [ - { id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, - { id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, - ], - } as unknown as ProjectApi - const worktrees = { - list: async ({ projectID }: { projectID: string }) => { - if (projectID === "b") throw new Error("unavailable") - return [{ directory: "/a/copy", strategy: "git" as const }] - }, - } as unknown as WorktreeApi + const api = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(new Request(input, init).url) + if (url.pathname === "/api/project") + return Response.json([ + { id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, + { id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, + ]) + const directory = url.searchParams.get("location[directory]") + if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`) + if (directory === "/b") return Response.json({ message: "unavailable" }, { status: 503 }) + return Response.json([{ directory: "/a/copy", strategy: "git" }]) + }, + { preconnect() {} }, + ), + }) - const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees)) + const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree)) expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([ { id: "a", sandboxes: ["/a/copy"] }, diff --git a/packages/app/src/runtime/server/global-sync/bootstrap.ts b/packages/app/src/runtime/server/global-sync/bootstrap.ts index c51a811b35b..b1010c17909 100644 --- a/packages/app/src/runtime/server/global-sync/bootstrap.ts +++ b/packages/app/src/runtime/server/global-sync/bootstrap.ts @@ -79,7 +79,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work .filter((project) => !!project?.id) .map(async (project) => { const directories = await worktrees - .list({ projectID: project.id }) + .list({ location: { directory: project.canonical } }) .catch(() => [ { directory: project.canonical }, ...(project.sandboxes ?? []) diff --git a/packages/app/src/session/timeline/session-workspace-menu.tsx b/packages/app/src/session/timeline/session-workspace-menu.tsx index 0ad08de184e..fcdfd78805f 100644 --- a/packages/app/src/session/timeline/session-workspace-menu.tsx +++ b/packages/app/src/session/timeline/session-workspace-menu.tsx @@ -40,8 +40,7 @@ export function SessionWorkspaceMenu(props: { if (!open) return const sdk = serverSDK void sdk.api.worktree - .refresh({ projectID: props.project.id }) - .then(() => sdk.api.worktree.list({ projectID: props.project.id })) + .list({ location: { directory: props.directory } }) .then((items) => setDirectories( items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)), diff --git a/packages/app/src/settings/workspaces/workspaces.tsx b/packages/app/src/settings/workspaces/workspaces.tsx index f7dcb72b7f2..db372816d3c 100644 --- a/packages/app/src/settings/workspaces/workspaces.tsx +++ b/packages/app/src/settings/workspaces/workspaces.tsx @@ -65,7 +65,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop Promise.all( (await serverSDK.api.project.list()).map(async (project) => { const worktrees = await serverSDK.api.worktree - .list({ projectID: project.id }) + .list({ location: { directory: project.canonical } }) .catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))]) return normalizeProjectInfo({ ...project, worktrees }) }), @@ -180,7 +180,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop } const removed = await context.sdk.api.worktree .remove({ - projectID: workspace.project.id, + location: { directory: workspace.project.worktree }, directory: workspace.directory, force, }) diff --git a/packages/app/src/workspaces/create.test.ts b/packages/app/src/workspaces/create.test.ts index 90711b4eba8..7e5c3ad1d32 100644 --- a/packages/app/src/workspaces/create.test.ts +++ b/packages/app/src/workspaces/create.test.ts @@ -65,7 +65,7 @@ describe("worktree creation", () => { branch: "clone-only", }) expect(requests.find((request) => request.method === "POST")?.url).toBe( - "http://localhost:3000/api/worktree/proj_clone", + `http://localhost:3000/api/worktree?location%5Bdirectory%5D=${encodeURIComponent(input.directory)}`, ) expect( requests diff --git a/packages/app/src/workspaces/create.ts b/packages/app/src/workspaces/create.ts index e4e829d6943..cb538dcd0cf 100644 --- a/packages/app/src/workspaces/create.ts +++ b/packages/app/src/workspaces/create.ts @@ -10,7 +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({ - projectID: project.id, + location: { directory: input.directory }, strategy: "git", from: project.canonical, branch: input.branch, diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 93487c33a29..230c8bc2054 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1931,33 +1931,37 @@ export interface ReferenceApi { readonly list: ReferenceListOperation } -export type WorktreeListInput = { readonly projectID: Project.ID } +export type WorktreeListInput = { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined +} export type WorktreeListOutput = Worktree.List -export type WorktreeListOperation = (input: WorktreeListInput) => Effect.Effect +export type WorktreeListOperation = (input?: WorktreeListInput) => Effect.Effect export type WorktreeCreateInput = { - readonly projectID: Project.ID - readonly strategy: Worktree.StrategyID + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly strategy?: Worktree.StrategyID | undefined readonly from?: AbsolutePath | undefined readonly branch?: string | undefined readonly directory?: AbsolutePath | undefined readonly name?: string | undefined } export type WorktreeCreateOutput = Worktree.Info -export type WorktreeCreateOperation = (input: WorktreeCreateInput) => Effect.Effect +export type WorktreeCreateOperation = (input?: WorktreeCreateInput) => Effect.Effect export type WorktreeRemoveInput = { - readonly projectID: Project.ID + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly directory: AbsolutePath readonly force: boolean } export type WorktreeRemoveOutput = void export type WorktreeRemoveOperation = (input: WorktreeRemoveInput) => Effect.Effect -export type WorktreeRefreshInput = { readonly projectID: Project.ID } +export type WorktreeRefreshInput = { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined +} export type WorktreeRefreshOutput = void export type WorktreeRefreshOperation = ( - input: WorktreeRefreshInput, + input?: WorktreeRefreshInput, ) => Effect.Effect export interface WorktreeApi { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index dafd6cda741..a70814782d9 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1451,21 +1451,21 @@ 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()( - raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)), + raw["worktree.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), ) -const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: WorktreeCreateInput) => +const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: WorktreeCreateInput) => preserveEffect()( raw["worktree.create"]({ - params: { projectID: input["projectID"] }, + query: { location: input?.["location"] }, payload: { - strategy: input["strategy"], - from: input["from"], - branch: input["branch"], - directory: input["directory"], - name: input["name"], + strategy: input?.["strategy"], + from: input?.["from"], + branch: input?.["branch"], + directory: input?.["directory"], + name: input?.["name"], }, }).pipe(Effect.mapError(mapClientError)), ) @@ -1473,14 +1473,14 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: Wo const EndpointWorktreeRemove = (raw: RawClient["server.worktree"]) => (input: WorktreeRemoveInput) => preserveEffect()( raw["worktree.remove"]({ - params: { projectID: input["projectID"] }, + query: { location: input["location"] }, payload: { 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()( - raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)), + raw["worktree.refresh"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), ) const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({ diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index 69aaa1a869b..5d9fa0568fb 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -20,6 +20,7 @@ export type WebSearchApi = Client["websearch"] export type SessionApi = Client["session"] export type SkillApi = Client["skill"] export type VcsApi = Client["vcs"] +export type WorktreeApi = Client["worktree"] export interface CatalogApi { readonly provider: ProviderApi diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 33f55761c79..fb55ccc0b63 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1968,28 +1968,30 @@ export function make(options: ClientOptions) { ), }, worktree: { - list: (input: WorktreeListInput, requestOptions?: RequestOptions) => + list: (input?: WorktreeListInput, requestOptions?: RequestOptions) => request( { method: "GET", - path: `/api/worktree/${encodeURIComponent(input.projectID)}`, + path: `/api/worktree`, + query: { location: input?.["location"] }, successStatus: 200, declaredStatuses: [400, 401], empty: false, }, requestOptions, ), - create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) => + create: (input?: WorktreeCreateInput, requestOptions?: RequestOptions) => request( { method: "POST", - path: `/api/worktree/${encodeURIComponent(input.projectID)}`, + path: `/api/worktree`, + query: { location: input?.["location"] }, body: { - strategy: input["strategy"], - from: input["from"], - branch: input["branch"], - directory: input["directory"], - name: input["name"], + strategy: input?.["strategy"], + from: input?.["from"], + branch: input?.["branch"], + directory: input?.["directory"], + name: input?.["name"], }, successStatus: 200, declaredStatuses: [400, 401], @@ -2001,7 +2003,8 @@ export function make(options: ClientOptions) { request( { method: "DELETE", - path: `/api/worktree/${encodeURIComponent(input.projectID)}`, + path: `/api/worktree`, + query: { location: input["location"] }, body: { directory: input["directory"], force: input["force"] }, successStatus: 204, declaredStatuses: [400, 401], @@ -2009,11 +2012,12 @@ export function make(options: ClientOptions) { }, requestOptions, ), - refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) => + refresh: (input?: WorktreeRefreshInput, requestOptions?: RequestOptions) => request( { method: "POST", - path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`, + path: `/api/worktree/refresh`, + query: { location: input?.["location"] }, successStatus: 204, declaredStatuses: [400, 401], empty: true, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index d0bfb043576..b60c805ddf9 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -430,6 +430,8 @@ export type WebSearchProvider = { id: string; name: string } export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } } +export type ConfigWorktree = { directory: string } + export type ProviderRequest = { settings: ProviderSettings headers: { [x: string]: string } @@ -1993,6 +1995,7 @@ export type ConfigEntry = } websearch?: false | { provider: "random" | (string & {}) } plugins?: Array + worktree?: ConfigWorktree warming?: boolean | { prompt?: string; interval?: string; duration?: string } providers?: { [x: string]: { @@ -6077,42 +6080,48 @@ export type ReferenceListOutput = { data: Array } -export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] } +export type WorktreeListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} export type WorktreeListOutput = WorktreeList export type WorktreeCreateInput = { - readonly projectID: { readonly projectID: string }["projectID"] - readonly strategy: { - readonly strategy: string + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly strategy?: { + readonly strategy?: string readonly from?: string readonly branch?: string readonly directory?: string readonly name?: string }["strategy"] readonly from?: { - readonly strategy: string + readonly strategy?: string readonly from?: string readonly branch?: string readonly directory?: string readonly name?: string }["from"] readonly branch?: { - readonly strategy: string + readonly strategy?: string readonly from?: string readonly branch?: string readonly directory?: string readonly name?: string }["branch"] readonly directory?: { - readonly strategy: string + readonly strategy?: string readonly from?: string readonly branch?: string readonly directory?: string readonly name?: string }["directory"] readonly name?: { - readonly strategy: string + readonly strategy?: string readonly from?: string readonly branch?: string readonly directory?: string @@ -6123,14 +6132,20 @@ export type WorktreeCreateInput = { export type WorktreeCreateOutput = WorktreeInfo export type WorktreeRemoveInput = { - readonly projectID: { readonly projectID: string }["projectID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] readonly directory: { readonly directory: string; readonly force: boolean }["directory"] readonly force: { readonly directory: string; readonly force: boolean }["force"] } export type WorktreeRemoveOutput = void -export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] } +export type WorktreeRefreshInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} export type WorktreeRefreshOutput = void diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 989900e83f5..63f2a2328aa 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -336,7 +336,7 @@ test("file.read returns binary content from the public HTTP contract", async () ) }) -test("worktree methods use the global project contract", async () => { +test("all worktree operations use location-based routes without a project parameter", async () => { const requests: Request[] = [] const client = OpenCode.make({ baseUrl: "http://localhost:3000", @@ -350,27 +350,25 @@ test("worktree methods use the global project contract", async () => { }, }) - expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }]) + expect(await client.worktree.list()).toEqual([{ directory: "/tmp/project" }]) expect( await client.worktree.create({ - projectID: "proj_test", strategy: "git", directory: "/tmp/worktrees", name: "api", }), ).toEqual({ directory: "/tmp/worktrees/api" }) await client.worktree.remove({ - projectID: "proj_test", directory: "/tmp/worktrees/api", force: false, }) - await client.worktree.refresh({ projectID: "proj_test" }) + await client.worktree.refresh() expect(requests.map((request) => [request.method, request.url])).toEqual([ - ["GET", "http://localhost:3000/api/worktree/proj_test"], - ["POST", "http://localhost:3000/api/worktree/proj_test"], - ["DELETE", "http://localhost:3000/api/worktree/proj_test"], - ["POST", "http://localhost:3000/api/worktree/proj_test/refresh"], + ["GET", "http://localhost:3000/api/worktree"], + ["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", @@ -380,6 +378,39 @@ test("worktree methods use the global project contract", async () => { expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false }) }) +test("worktree operations send the configuration location separately from their payload", async () => { + const requests: Request[] = [] + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + 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 }) + return Response.json({ directory: "/configured/task" }) + }, + }) + expect(await client.worktree.create({ location: { directory: "/repo/nested" }, 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" }) + await client.worktree.remove({ + location: { directory: "/repo/nested" }, + 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([ + { directory: "/configured/task", strategy: "git" }, + ]) + expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested") +}) + test("workspace.destroy returns the transition result", async () => { let request: Request | undefined const client = OpenCode.make({ diff --git a/packages/core/src/config/normalize.ts b/packages/core/src/config/normalize.ts index a53883cbbd1..b97110c81d0 100644 --- a/packages/core/src/config/normalize.ts +++ b/packages/core/src/config/normalize.ts @@ -209,6 +209,7 @@ export function normalize(input: unknown): Result { media: Info.fields.media, tool_output: Info.fields.tool_output, websearch: Info.fields.websearch, + worktree: Info.fields.worktree, warming: Info.fields.warming, } Object.entries(nativeAtomic).forEach(([key, schema]) => { diff --git a/packages/core/src/config/plugin/worktree.ts b/packages/core/src/config/plugin/worktree.ts new file mode 100644 index 00000000000..106188422fe --- /dev/null +++ b/packages/core/src/config/plugin/worktree.ts @@ -0,0 +1,35 @@ +export * as ConfigWorktreePlugin from "./worktree.js" + +import { define } from "@opencode-ai/plugin/effect/plugin" +import { Effect } from "effect" +import path from "path" +import { Config } from "../../config.js" +import { Global } from "@opencode-ai/util/global" +import { Location } from "../../location.js" +import { AbsolutePath } from "../../schema.js" +import { Worktree } from "../../worktree.js" +import { ConfigEntryObserver } from "./entry-observer.js" + +export const Plugin = define({ + id: "opencode.config.worktree", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const location = yield* Location.Service + const global = yield* Global.Service + const worktrees = yield* Worktree.Service + const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, worktrees.reload()) + yield* worktrees.transform((editor) => { + for (const entry of loaded.entries) { + if (entry.type !== "document" || !entry.info.worktree) continue + const directory = entry.info.worktree.directory + editor.configure({ + directory: AbsolutePath.make( + directory.startsWith("~/") + ? path.join(global.home, directory.slice(2)) + : path.resolve(entry.path ? path.dirname(entry.path) : location.directory, directory), + ), + }) + } + }) + }), +}) diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 14ab8a73eb2..e3ddac1bcf2 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -25,6 +25,7 @@ import { Plugin } from "./plugin.js" import { PluginHooks } from "./plugin/hooks.js" import { InstancePlugins } from "./plugin/instance.js" import { PluginSupervisor } from "./plugin/supervisor.js" +import { WorktreeRefresh } from "./worktree/refresh.js" import { Worktree } from "./worktree.js" import { Pty } from "./pty.js" import { Shell } from "./shell.js" @@ -71,7 +72,8 @@ const nodes = [ PluginHooks.node, InstancePlugins.node, PluginSupervisor.node, - Worktree.refreshNode, + WorktreeRefresh.node, + Worktree.node, FileSystemSearch.node, FileSystem.node, ShellSelect.node, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index f9cf0b76235..d28741c75c9 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -30,6 +30,7 @@ import { Tool } from "../tool.js" import { Workspace } from "../workspace.js" import { Vcs } from "../vcs.js" import { WebSearch } from "../websearch.js" +import { Worktree } from "../worktree.js" import { Generate } from "../generate.js" import { Permission } from "../permission.js" import { PluginHooks } from "./hooks.js" @@ -69,6 +70,7 @@ export const make = Effect.fn("PluginHost.make")(function* ( const sessions = yield* Session.Service const persistentPty = yield* PersistentPty.Service const locations = yield* LocationServiceMap.Service + const worktrees = yield* Worktree.Service const locationInfo = () => new Location.Info({ directory: location.directory, @@ -88,6 +90,24 @@ export const make = Effect.fn("PluginHost.make")(function* ( const response = (effect: Effect.Effect) => effect.pipe(Effect.map((data) => ({ location: locationInfo(), data }))) + const atWorktree = ( + ref: Location.Ref | undefined, + run: (service: Worktree.Interface) => Effect.Effect, + ) => { + 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)) + const listAgents = Effect.fn("PluginHost.listAgents")((ref: Location.Ref) => Effect.gen(function* () { const location = yield* Location.Service @@ -462,6 +482,25 @@ 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, + transform: (callback) => + worktrees.transform((editor) => + callback({ + add: (definition) => + editor.add({ + id: Worktree.StrategyID.make(definition.id), + create: (input) => definition.create(input).pipe(Effect.flatMap(decodeWorktree)), + remove: (input) => definition.remove(input), + list: (directory) => definition.list(directory).pipe(Effect.flatMap(decodeWorktrees)), + }), + }), + ), + }, session: { hook: (name, callback, options) => hooks.register("session", name, callback, options), create: (input) => @@ -510,6 +549,7 @@ export const requirements = LayerNode.group([ Tool.node, Vcs.node, WebSearch.node, + Worktree.node, Generate.node, Permission.node, PluginHooks.node, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index fab8581fd5d..ae460c1a008 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -27,6 +27,8 @@ import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js" import { ConfigSkillPlugin } from "../config/plugin/skill.js" 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 { Bus } from "../bus.js" import { Environment } from "../environment/index.js" import { FileMutation } from "../file-mutation.js" @@ -135,6 +137,7 @@ const services = [ ToolOutput.Service, Watcher.Service, WellKnown.Service, + Worktree.Service, ] as const export type Requirements = Context.Service.Identifier<(typeof services)[number]> @@ -183,6 +186,7 @@ export const requirements = LayerNode.group([ ToolOutput.node, Watcher.node, WellKnown.node, + Worktree.node, ]) export type InternalPlugin = Plugin @@ -232,6 +236,7 @@ const post = [ ConfigSkillPlugin.Plugin, ConfigProviderPlugin.Plugin, ConfigWebSearchPlugin.Plugin, + ConfigWorktreePlugin.Plugin, VariantPlugin.Plugin, ConfigPolicyPlugin.Plugin, ] as const satisfies readonly InternalPlugin[] diff --git a/packages/core/src/worktree.ts b/packages/core/src/worktree.ts index dcdd1ea0db1..c196815604a 100644 --- a/packages/core/src/worktree.ts +++ b/packages/core/src/worktree.ts @@ -1,13 +1,13 @@ export * as Worktree from "./worktree.js" import { Context, Effect, Layer, Schema } from "effect" -import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm" +import { and, asc, desc, eq, isNull, sql } from "drizzle-orm" import path from "path" import { AbsolutePath } from "./schema.js" import { FSUtil } from "@opencode-ai/util/fs-util" -import { Global } from "@opencode-ai/util/global" import { Git } from "./git.js" -import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { Global } from "@opencode-ai/util/global" import { ProjectSchema } from "./project/schema.js" import { Slug } from "./util/slug.js" import { Bus } from "./bus.js" @@ -21,8 +21,10 @@ import type { EffectDrizzleSqlite } from "./database/drizzle.js" import { ProjectTable } from "./project/sql.js" import { AppProcess } from "@opencode-ai/util/process" import { ChildProcess } from "effect/unstable/process" +import { State } from "./state.js" export { DirectoryUnavailableError } from "./worktree/directory.js" +export { OperationError } from "@opencode-ai/schema/worktree" export const StrategyID = Worktree.StrategyID export type StrategyID = typeof StrategyID.Type @@ -33,11 +35,6 @@ export type CreateInput = typeof CreateInput.Type export const RemoveInput = Worktree.RemoveInput export type RemoveInput = typeof RemoveInput.Type -export const RefreshInput = Schema.Struct({ - projectID: ProjectSchema.ID, -}).annotate({ identifier: "Worktree.RefreshInput" }) -export type RefreshInput = typeof RefreshInput.Type - export const RefreshResult = Schema.Struct({ updated: Schema.Array(AbsolutePath), removed: Schema.Array(AbsolutePath), @@ -47,16 +44,10 @@ export type RefreshResult = typeof RefreshResult.Type export const Info = Worktree.Info export type Info = typeof Info.Type -export const ListInput = Worktree.ListInput -export type ListInput = typeof ListInput.Type - export const List = Worktree.List export type List = typeof List.Type -export const ListEntry = Schema.Struct({ - directory: AbsolutePath, - type: Schema.Literals(["root", "worktree"]), -}).annotate({ identifier: "Worktree.ListEntry" }) +export const ListEntry = Worktree.ListEntry export type ListEntry = typeof ListEntry.Type export class SourceDirectoryNotFoundError extends Schema.TaggedError()( @@ -79,9 +70,9 @@ export class StrategyUnavailableError extends Schema.TaggedError()( - "Worktree.DuplicateStrategyError", - { strategy: StrategyID }, +export class UnsupportedLocationError extends Schema.TaggedError()( + "Worktree.UnsupportedLocationError", + { directory: AbsolutePath }, ) {} export type Error = @@ -90,6 +81,8 @@ export type Error = | DirectoryUnavailableError | InvalidDirectoryError | StrategyUnavailableError + | UnsupportedLocationError + | Worktree.OperationError | AppProcess.AppProcessError | Git.WorktreeError @@ -99,68 +92,78 @@ export interface Strategy { sourceDirectory: AbsolutePath directory: AbsolutePath branch?: string - }) => Effect.Effect - readonly remove: (input: { - directory: AbsolutePath - force: boolean - }) => Effect.Effect - readonly list: (directory: AbsolutePath) => Effect.Effect + }) => Effect.Effect + readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect + readonly list: (directory: AbsolutePath) => Effect.Effect } export const Event = Worktree.Event interface StoredInput { - readonly projectID: ProjectSchema.ID readonly directory: AbsolutePath readonly strategy?: string + readonly replace?: boolean } type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase type Transaction = Parameters[0]>[0] -export interface Interface { - readonly register: (strategy: Strategy) => Effect.Effect - readonly list: (projectID: ProjectSchema.ID) => Effect.Effect - readonly create: (input: CreateInput) => Effect.Effect +export interface Editor { + readonly add: (strategy: Strategy) => void + readonly configure: (settings: { readonly directory: AbsolutePath }) => void +} + +export interface Interface extends State.Transformable { + readonly list: () => Effect.Effect + readonly create: (input?: CreateInput) => Effect.Effect readonly remove: (input: RemoveInput) => Effect.Effect - readonly refresh: (input: RefreshInput) => Effect.Effect + readonly refresh: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Worktree") {} -export const refreshAfterBoot = Effect.gen(function* () { - const location = yield* Location.Service - const worktrees = yield* Service - yield* Effect.gen(function* () { - yield* Effect.logInfo("worktree refresh started", { projectID: location.project.id }) - const result = yield* worktrees.refresh({ projectID: location.project.id }) - yield* Effect.logInfo("worktree refresh done", { - projectID: location.project.id, - updated: result.updated, - removed: result.removed, - }) - }).pipe( - Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })), - Effect.forkScoped, - Effect.asVoid, - ) -}) - const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service - const global = yield* Global.Service - const db = (yield* Database.Service).db + const database = yield* Database.Service + 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 changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) { + const local = location.workspaceID + ? Effect.fail(new UnsupportedLocationError({ directory: location.directory })) + : Effect.void + + 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([[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 changed = Effect.fnUntraced(function* (update: boolean) { if (update) yield* bus.publish(Event.Updated, { projectID }) }) const ops = { - list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) { + list: Effect.fnUntraced(function* () { const rows = yield* db .select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy }) .from(WorktreeTable) @@ -170,7 +173,7 @@ const layer = Layer.effect( .pipe(Effect.orDie) return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined })) }), - find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) { + find: Effect.fnUntraced(function* (directory: AbsolutePath) { const row = yield* db .select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy }) .from(WorktreeTable) @@ -179,24 +182,21 @@ const layer = Layer.effect( .pipe(Effect.orDie) return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined }), - primary: Effect.fnUntraced(function* (projectID: ProjectSchema.ID) { - return yield* db - .select({ directory: ProjectTable.worktree }) - .from(ProjectTable) - .where(eq(ProjectTable.id, projectID)) - .get() - .pipe(Effect.orDie) - }), create: (input: StoredInput, tx?: Transaction) => (tx ?? db) .insert(WorktreeTable) - .values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy }) + .values({ + project_id: projectID, + directory: input.directory, + strategy: input.strategy, + }) .onConflictDoUpdate({ target: [WorktreeTable.project_id, WorktreeTable.directory], - set: { strategy: input.strategy ?? null }, - setWhere: input.strategy - ? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy)) - : isNotNull(WorktreeTable.strategy), + set: { + strategy: input.strategy ?? null, + }, + // Discovery may claim an unowned row, but never replace another strategy's ownership. + setWhere: input.replace ? undefined : input.strategy ? isNull(WorktreeTable.strategy) : sql`false`, }) .returning({ directory: WorktreeTable.directory }) .get() @@ -204,7 +204,7 @@ const layer = Layer.effect( Effect.orDie, Effect.map((row) => row !== undefined), ), - remove: (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) => + remove: (directory: AbsolutePath, tx?: Transaction) => (tx ?? db) .delete(WorktreeTable) .where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory))) @@ -216,36 +216,26 @@ const layer = Layer.effect( ), } - const registry = new Map() - - const register = Effect.fn("Worktree.register")(function* (strategy: Strategy) { - if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id }) - registry.set(strategy.id, strategy) - }) - - // Register default strategies - const gitStrategy = yield* WorktreeGit.make - yield* register(gitStrategy).pipe(Effect.orDie) - - const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined, projectID: ProjectSchema.ID) { - const sourceDirectory = input ?? (yield* ops.primary(projectID))?.directory - if (!sourceDirectory) return yield* new SourceDirectoryNotFoundError({ projectID }) + const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined) { + const sourceDirectory = input ?? location.project.directory const resolved = yield* canonical(fs, sourceDirectory) - if ((yield* ops.find(projectID, resolved)) === undefined) + if ((yield* ops.find(resolved)) === undefined) return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved }) return resolved }) - const getStrategy = Effect.fnUntraced(function* (id: StrategyID) { - const found = registry.get(id) + const getStrategy = Effect.fnUntraced(function* (id: StrategyID, strategies: ReadonlyMap) { + const found = strategies.get(id) if (!found) return yield* new StrategyUnavailableError({ strategy: id }) return found }) - const create = Effect.fn("Worktree.create")(function* (input: CreateInput) { - const selected = yield* getStrategy(input.strategy) - const sourceDirectory = yield* source(input.from, input.projectID) - const directory = input.directory ?? path.join(global.data, "worktree", input.projectID.slice(0, 6)) + 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) yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie) const name = input.name ?? Slug.create() let suffix = 1 @@ -256,23 +246,27 @@ const layer = Layer.effect( worktreeDirectory = AbsolutePath.make(path.join(directory, `${name}-${suffix}`)) } - const result = yield* selected.create({ - directory: worktreeDirectory, - sourceDirectory, - branch: input.branch, - }) + const created = yield* selected + .create({ + directory: worktreeDirectory, + sourceDirectory, + branch: input.branch, + }) + .pipe(Effect.mapError((error) => operationError(selected.id, "create", error))) + const result = { directory: yield* canonical(fs, created.directory) } + if (result.directory !== (yield* canonical(fs, worktreeDirectory))) + return yield* new InvalidDirectoryError({ directory: result.directory }) yield* changed( - input.projectID, yield* ops.create({ - projectID: input.projectID, directory: result.directory, - strategy: input.strategy, + strategy: selected.id, + replace: true, }), ) const project = yield* db .select({ commands: ProjectTable.commands }) .from(ProjectTable) - .where(eq(ProjectTable.id, input.projectID)) + .where(eq(ProjectTable.id, projectID)) .get() .pipe(Effect.orDie) const command = project?.commands?.start?.trim() @@ -297,70 +291,71 @@ const layer = Layer.effect( }) const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) { + yield* local const worktreeDirectory = yield* canonical(fs, input.directory) - const stored = yield* ops.find(input.projectID, worktreeDirectory) + const stored = yield* ops.find(worktreeDirectory) if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory }) - const strategy = yield* getStrategy(StrategyID.make(stored.strategy)) - yield* strategy.remove({ - directory: worktreeDirectory, - force: input.force, - }) - yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory)) + const strategy = yield* getStrategy(StrategyID.make(stored.strategy), state.get().strategies) + yield* strategy + .remove({ + directory: worktreeDirectory, + force: input.force, + }) + .pipe(Effect.mapError((error) => operationError(strategy.id, "remove", error))) + yield* changed(yield* ops.remove(worktreeDirectory)) }) - const refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) { - const stored = yield* ops.list(input.projectID) + const refresh = Effect.fn("Worktree.refresh")(function* () { + yield* local + const stored = yield* ops.list() const checked = yield* Effect.forEach( stored, (item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))), { concurrency: "unbounded" }, ) - const sourceDirectories = checked - .filter((item) => item.strategy === undefined && item.exists) - .map((item) => item.directory) - const discovered = yield* Effect.forEach( - sourceDirectories, - (sourceDirectory) => - Effect.forEach(Array.from(registry.values()), (strategy) => - strategy.list(sourceDirectory).pipe( - Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])), - Effect.map((items) => - items.map((item) => ({ - directory: item.directory, - strategy: item.type === "worktree" ? strategy.id : undefined, - })), - ), - ), - ), - { concurrency: "unbounded" }, - ).pipe( - Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()), - ) + const strategies = Array.from(state.get().strategies.values()).toReversed() + const discovered = new Map() + // A location's plugin instances only discover its own checkout, not sibling clones. + if (checked.some((item) => item.directory === location.project.directory && item.exists)) { + for (const strategy of strategies) { + const entries = yield* strategy.list(location.project.directory).pipe( + Effect.mapError((error) => operationError(strategy.id, "list", error)), + Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])), + ) + for (const entry of entries) { + const directory = yield* canonical(fs, entry.directory).pipe( + Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.undefined), + ) + if (!directory || discovered.has(directory)) continue + discovered.set(directory, { + directory, + strategy: entry.type === "worktree" ? strategy.id : undefined, + }) + } + } + } const removed = checked.filter((item) => !item.exists).map((item) => item.directory) const changes = yield* db .transaction((tx) => Effect.all({ - updated: Effect.filter(discovered, (item) => - ops.create( - { - projectID: input.projectID, - directory: item.directory, - strategy: item.strategy, - }, - tx, - ), - ).pipe(Effect.map((items) => items.map((item) => item.directory))), - removed: Effect.filter(removed, (directory) => ops.remove(input.projectID, directory, tx)), + 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)), }), ) .pipe(Effect.orDie) - yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0) + yield* changed(changes.updated.length > 0 || changes.removed.length > 0) return changes }) return Service.of({ - register, - list: ops.list, + transform: state.transform, + reload: state.reload, + list: Effect.fn("Worktree.list")(function* () { + yield* refresh() + return yield* ops.list() + }), create, remove, refresh, @@ -368,14 +363,20 @@ const layer = Layer.effect( }), ) -export const node = makeGlobalNode({ +export const node = makeLocationNode({ service: Service, layer: layer, - deps: [FSUtil.node, Global.node, Git.node, Bus.node, Database.node, AppProcess.node], + deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, Location.node, Global.node], }) -export const refreshNode = makeLocationNode({ - name: "worktree-refresh", - layer: Layer.effectDiscard(refreshAfterBoot), - deps: [node, Location.node], -}) +function operationError(strategy: StrategyID, operation: string, error: unknown) { + if ( + error instanceof Git.WorktreeError || + error instanceof DirectoryUnavailableError || + error instanceof Worktree.OperationError + ) + return error + return new Worktree.OperationError({ + message: `Worktree strategy ${strategy} failed to ${operation}: ${error instanceof globalThis.Error ? error.message : String(error)}`, + }) +} diff --git a/packages/core/src/worktree/refresh.ts b/packages/core/src/worktree/refresh.ts new file mode 100644 index 00000000000..1aa7defbbb2 --- /dev/null +++ b/packages/core/src/worktree/refresh.ts @@ -0,0 +1,28 @@ +export * as WorktreeRefresh from "./refresh.js" + +import { Effect, Layer } from "effect" +import { makeLocationNode } from "@opencode-ai/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], +}) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index f40284eaa8c..9f323bec9ee 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -32,6 +32,7 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Tool } from "@opencode-ai/core/tool" import { Vcs } from "@opencode-ai/core/vcs" import { WebSearch } from "@opencode-ai/core/websearch" +import { Worktree } from "@opencode-ai/core/worktree" import { Effect, Layer } from "effect" import { tempLocationLayer } from "../fixture/location" import { emptyMcpLayer } from "../fixture/mcp" @@ -94,6 +95,7 @@ export const PluginTestLayer = AppNodeBuilder.build( Vcs.node, Watcher.node, WebSearch.node, + Worktree.node, ]), [ Location.node.replace(tempLocationLayer), diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 586b23c06c9..76fe9a62ad6 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -145,6 +145,14 @@ export function host(overrides: Overrides = {}): Plugin.Context { transform: () => Effect.die("unused vcs.transform"), reload: () => Effect.die("unused vcs.reload"), }, + worktree: overrides.worktree ?? { + list: () => Effect.die("unused worktree.list"), + create: () => Effect.die("unused worktree.create"), + remove: () => Effect.die("unused worktree.remove"), + refresh: () => Effect.die("unused worktree.refresh"), + transform: () => Effect.die("unused worktree.transform"), + reload: () => Effect.die("unused worktree.reload"), + }, websearch: overrides.websearch ?? { providers: () => Effect.die("unused websearch.providers"), query: () => Effect.die("unused websearch.query"), diff --git a/packages/core/test/plugin/worktree.test.ts b/packages/core/test/plugin/worktree.test.ts new file mode 100644 index 00000000000..1e9ef6bf307 --- /dev/null +++ b/packages/core/test/plugin/worktree.test.ts @@ -0,0 +1,52 @@ +import { expect } from "bun:test" +import { Cause, Deferred, Effect, Exit, Fiber } from "effect" +import { Plugin } from "@opencode-ai/plugin" +import type { WorktreeDefinition } from "@opencode-ai/plugin/effect/worktree" +import { PluginPromise } from "@opencode-ai/core/plugin/promise" +import { State } from "@opencode-ai/core/state" +import { it } from "../lib/effect" +import { host } from "./host" + +it.live("Promise worktree callbacks receive interruption through their AbortSignal", () => + Effect.gen(function* () { + const started = yield* Deferred.make() + const state = State.create({ + initial: () => new Map(), + editor: (value) => ({ + add: (definition: WorktreeDefinition) => { + value.set(definition.id, definition) + }, + }), + }) + const context = host() + const plugin = PluginPromise.fromPromise( + Plugin.define({ + id: "cancel-worktree", + async setup(ctx) { + await ctx.worktree.transform((editor) => + editor.add({ + id: "cancel", + create: (_input, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }) + Effect.runSync(Deferred.succeed(started, signal)) + }), + remove: async () => {}, + list: async () => [], + }), + ) + }, + }), + ) + yield* plugin.effect(host({ worktree: { ...context.worktree, transform: state.transform, reload: state.reload } })) + const strategy = state.get().get("cancel") + if (!strategy) return yield* Effect.die("Strategy was not registered") + const fiber = yield* strategy.create({ sourceDirectory: "/source", directory: "/target" }).pipe(Effect.forkScoped) + const signal = yield* Deferred.await(started) + expect(signal.aborted).toBe(false) + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber) + expect(signal.aborted).toBe(true) + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) + }), +) diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 0fd3fd3c9dc..50fbea8effe 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -114,7 +114,6 @@ test("Core reuses the canonical shared schemas", async () => { [coreWorktree.CreateInput, Worktree.CreateInput], [coreWorktree.RemoveInput, Worktree.RemoveInput], [coreWorktree.Info, Worktree.Info], - [coreWorktree.ListInput, Worktree.ListInput], [coreWorktree.List, Worktree.List], [coreWorktree.Event, Worktree.Event], [corePty.Info, Pty.Info], diff --git a/packages/core/test/worktree.test.ts b/packages/core/test/worktree.test.ts index dc67e387e09..9a65b33c36f 100644 --- a/packages/core/test/worktree.test.ts +++ b/packages/core/test/worktree.test.ts @@ -3,10 +3,9 @@ import { $ } from "bun" import fs from "fs/promises" import path from "path" import { and, eq, isNull } from "drizzle-orm" -import { Effect, Fiber, Stream } from "effect" +import { Context, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { Global } from "@opencode-ai/util/global" import { AbsolutePath } from "@opencode-ai/core/schema" import { Git } from "@opencode-ai/core/git" import { Database } from "@opencode-ai/core/database/database" @@ -16,20 +15,64 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { Worktree } from "@opencode-ai/core/worktree" import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory" import { WorktreeTable } from "@opencode-ai/core/worktree/sql" +import { WorktreeGit } from "@opencode-ai/core/worktree/git" +import { Location } from "@opencode-ai/core/location" +import { Global } from "@opencode-ai/util/global" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Config } from "@opencode-ai/core/config" +import { ConfigWorktreePlugin } from "@opencode-ai/core/config/plugin/worktree" +import { ConfigNormalize } from "@opencode-ai/core/config/normalize" +import { Document, Event, Info } from "@opencode-ai/schema/config" +import { EventManifest } from "@opencode-ai/schema/event-manifest" +import { Workspace } from "@opencode-ai/schema/workspace" +import { host } from "./plugin/host" import { initRepo } from "./fixture/git" import { tmpdir } from "./fixture/tmpdir" -import { tempGlobalLayer } from "./fixture/global" import { testEffect } from "./lib/effect" -const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node]))) -const projectIt = testEffect( - AppNodeBuilder.build(LayerNode.group([Project.node, Worktree.node, Database.node, Bus.node])), -) -const defaultIt = testEffect( - AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Global.node]), [ - Global.node.replace(tempGlobalLayer), - ]), +class Fixture extends Context.Service>>()("WorktreeFixture") {} + +const infrastructure = AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node])) +const it = testEffect( + Layer.unwrap( + Effect.gen(function* () { + const input = yield* makeFixture() + const database = yield* Database.Service + const bus = yield* Bus.Service + return Layer.mergeAll( + Layer.succeed(Fixture, input), + Config.testLayer(), + worktreeLayer(input.sourceDirectory, input.projectID, database, bus, input.root.path), + ) + }), + ).pipe(Layer.provideMerge(infrastructure)), ) +const projectIt = it + +function worktreeLayer( + directory: AbsolutePath, + projectID: Project.ID, + database: Database.Interface, + bus: Bus.Interface, + 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 }, + }), + ), + ), + ]).pipe(Layer.fresh) +} function abs(input: string) { return AbsolutePath.make(input) @@ -37,7 +80,11 @@ function abs(input: string) { const gitWorktree = Worktree.StrategyID.make("git") -function setup() { +const setup = Effect.fnUntraced(function* () { + return yield* Fixture +}) + +function makeFixture() { return Effect.gen(function* () { const root = yield* Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -114,7 +161,6 @@ describe("Worktree", () => { const unavailable = Worktree.StrategyID.make("acme/missing") const error = yield* worktree .create({ - projectID: input.projectID, strategy: unavailable, from: input.sourceDirectory, directory: abs(`${input.root.path}-missing-strategy`), @@ -138,7 +184,6 @@ describe("Worktree", () => { const error = yield* worktree .create({ - projectID: input.projectID, strategy: gitWorktree, from: input.sourceDirectory, directory: abs(`${input.root.path}-missing-source`), @@ -166,7 +211,6 @@ describe("Worktree", () => { yield* Effect.yieldNow const created = yield* worktree.create({ - projectID: input.projectID, strategy: gitWorktree, directory: parent, name: "worktree", @@ -180,14 +224,14 @@ describe("Worktree", () => { ) expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID }) - yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false }) + yield* worktree.remove({ directory: created.directory, force: false }) expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }]) expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false) }), ) - defaultIt.live("defaults to the TUI worktree directory and suffixes duplicate names", () => + it.live("defaults to the TUI worktree directory and suffixes duplicate names", () => Effect.gen(function* () { const input = yield* setup() const worktree = yield* Worktree.Service @@ -195,13 +239,11 @@ describe("Worktree", () => { const parent = path.join(global.data, "worktree", "worktr") const created = yield* worktree.create({ - projectID: input.projectID, strategy: gitWorktree, from: input.sourceDirectory, name: "task", }) const duplicate = yield* worktree.create({ - projectID: input.projectID, strategy: gitWorktree, from: input.sourceDirectory, name: "task", @@ -210,8 +252,8 @@ describe("Worktree", () => { expect(created.directory).toBe(abs(path.join(parent, "task"))) expect(duplicate.directory).toBe(abs(path.join(parent, "task-2"))) expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, ".git")).exists())).toBe(true) - yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false }) - yield* worktree.remove({ projectID: input.projectID, directory: duplicate.directory, force: false }) + yield* worktree.remove({ directory: created.directory, force: false }) + yield* worktree.remove({ directory: duplicate.directory, force: false }) }), ) @@ -234,7 +276,6 @@ describe("Worktree", () => { .run() .pipe(Effect.orDie) const created = yield* worktree.create({ - projectID: input.projectID, strategy: gitWorktree, directory: parent, name: "worktree", @@ -245,7 +286,7 @@ describe("Worktree", () => { created.directory, created.directory, ]) - yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true }) + yield* worktree.remove({ directory: created.directory, force: true }) }), ) @@ -268,9 +309,12 @@ describe("Worktree", () => { .quiet() }) const projects = yield* Project.Service - const worktrees = yield* Worktree.Service const initial = yield* projects.resolve(main) 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 worktrees = Context.get(context, Worktree.Service) yield* projects.update({ projectID: initial.id, commands: { @@ -280,7 +324,6 @@ describe("Worktree", () => { }) const created = yield* worktrees.create({ - projectID: selected.id, strategy: gitWorktree, from: selected.canonical, directory: abs(path.join(root.path, "worktrees")), @@ -311,7 +354,6 @@ describe("Worktree", () => { }) const created = yield* worktree.create({ - projectID: input.projectID, strategy: gitWorktree, branch: "feature-base", directory: parent, @@ -335,7 +377,6 @@ describe("Worktree", () => { const error = yield* worktree .create({ - projectID: input.projectID, strategy: gitWorktree, branch: "--no-checkout", directory: parent, @@ -356,7 +397,6 @@ describe("Worktree", () => { const error = yield* worktree .create({ - projectID: input.projectID, strategy: gitWorktree, from: abs(path.join(temp, "does-not-exist")), directory: abs(`${input.root.path}-missing-directory`), @@ -382,7 +422,6 @@ describe("Worktree", () => { ]).pipe(Effect.asVoid), ) const source = yield* worktree.create({ - projectID: input.projectID, strategy: gitWorktree, from: input.sourceDirectory, directory: sourceParent, @@ -395,7 +434,6 @@ describe("Worktree", () => { .pipe(Effect.orDie) const created = yield* worktree.create({ - projectID: input.projectID, strategy: gitWorktree, from: source.directory, directory: targetParent, @@ -403,8 +441,8 @@ describe("Worktree", () => { }) expect(created.directory).toBe(abs(path.join(targetParent, "target"))) - yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false }) - yield* worktree.remove({ projectID: input.projectID, directory: source.directory, force: false }) + yield* worktree.remove({ directory: created.directory, force: false }) + yield* worktree.remove({ directory: source.directory, force: false }) }), ) @@ -416,7 +454,6 @@ describe("Worktree", () => { 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({ - projectID: input.projectID, strategy: gitWorktree, from: input.sourceDirectory, directory: parent, @@ -424,9 +461,7 @@ describe("Worktree", () => { }) yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty")) - const error = yield* worktree - .remove({ projectID: input.projectID, directory: created.directory, force: false }) - .pipe(Effect.flip) + const error = yield* worktree.remove({ directory: created.directory, force: false }).pipe(Effect.flip) expect(error).toBeInstanceOf(Git.WorktreeError) if (error instanceof Git.WorktreeError) { @@ -436,7 +471,7 @@ describe("Worktree", () => { expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git" }) expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true) - yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true }) + yield* worktree.remove({ directory: created.directory, force: true }) expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false) }), ) @@ -454,9 +489,7 @@ describe("Worktree", () => { .run() .pipe(Effect.orDie) - const error = yield* worktree - .remove({ projectID: input.projectID, directory: unavailable, force: false }) - .pipe(Effect.flip) + const error = yield* worktree.remove({ directory: unavailable, force: false }).pipe(Effect.flip) expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError) expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" }) @@ -475,7 +508,6 @@ describe("Worktree", () => { yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2"))) const created = yield* worktree.create({ - projectID: input.projectID, strategy: gitWorktree, from: input.sourceDirectory, directory: parent, @@ -490,7 +522,7 @@ describe("Worktree", () => { yield* Effect.promise(() => fs.stat(path.join(parent, "worktree-2")).then((item) => item.isDirectory())), ).toBe(true) - yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false }) + yield* worktree.remove({ directory: created.directory, force: false }) }), ) @@ -511,7 +543,6 @@ describe("Worktree", () => { const error = yield* worktree .create({ - projectID: input.projectID, strategy: gitWorktree, from: input.sourceDirectory, directory: parent, @@ -527,7 +558,6 @@ describe("Worktree", () => { it.live("does not publish an event when refresh finds no directory changes", () => Effect.gen(function* () { - const input = yield* setup() const worktree = yield* Worktree.Service const bus = yield* Bus.Service const event = yield* bus.subscribe(Worktree.Event.Updated).pipe( @@ -537,7 +567,7 @@ describe("Worktree", () => { Effect.flatMap((fiber) => Effect.gen(function* () { yield* Effect.yieldNow - yield* worktree.refresh({ projectID: input.projectID }) + yield* worktree.refresh() return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis")) }), ), @@ -576,7 +606,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({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] }) + expect(yield* worktree.refresh()).toEqual({ updated: [discovered], removed: [] }) expect(yield* stored(input.projectID)).toEqual( [ @@ -589,7 +619,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({ projectID: input.projectID })).toEqual({ + expect(yield* worktree.refresh()).toEqual({ updated: [], removed: [discovered, existing].toSorted(), }) @@ -610,7 +640,7 @@ describe("Worktree", () => { yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true })) yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet()) - yield* worktree.refresh({ projectID: input.projectID }) + yield* worktree.refresh() const discovered = abs(yield* Effect.promise(() => fs.realpath(target))) expect(yield* stored(input.projectID)).toEqual( @@ -629,7 +659,7 @@ describe("Worktree", () => { yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true })) const worktree = yield* Worktree.Service - yield* worktree.refresh({ projectID: input.projectID }) + yield* worktree.refresh() expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }]) }), @@ -637,9 +667,15 @@ describe("Worktree", () => { it.live("refresh with no roots is a no-op", () => Effect.gen(function* () { + const input = yield* setup() + yield* input.db + .delete(WorktreeTable) + .where(eq(WorktreeTable.project_id, input.projectID)) + .run() + .pipe(Effect.orDie) const worktree = yield* Worktree.Service - expect(yield* worktree.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({ + expect(yield* worktree.refresh()).toEqual({ updated: [], removed: [], }) @@ -657,9 +693,251 @@ describe("Worktree", () => { .pipe(Effect.orDie) const worktree = yield* Worktree.Service - expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] }) + expect(yield* worktree.refresh()).toEqual({ updated: [], removed: [missing] }) expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null }) }), ) + + 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 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" }) + expect(created.directory).toBe(abs(path.join(parent, "configured"))) + expect(yield* worktrees.list()).toContainEqual({ + directory: created.directory, + strategy: "git", + }) + yield* registration.dispose + const fallback = yield* worktrees.create({ name: "default" }) + expect(fallback.directory).toBe( + abs(path.join(input.root.path, "worktree", input.projectID.slice(0, 6), "default")), + ) + }), + ) + + 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 git = yield* WorktreeGit.make + const parent = abs(path.join(input.root.path, "strategies")) + const first = yield* worktrees.transform((editor) => + editor.add({ ...git, id: Worktree.StrategyID.make("first") }), + ) + const scope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void)) + const second = yield* worktrees + .transform((editor) => editor.add({ ...git, id: Worktree.StrategyID.make("second") })) + .pipe(Effect.provideService(Scope.Scope, scope)) + yield* worktrees.transform((editor) => editor.configure({ directory: parent })) + const created = yield* worktrees.create({ name: "second" }) + expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "second" }) + yield* Scope.close(scope, Exit.void) + yield* second.dispose + const earlier = yield* worktrees.create({ name: "first" }) + expect(yield* stored(input.projectID)).toContainEqual({ directory: earlier.directory, strategy: "first" }) + yield* first.dispose + const fallback = yield* worktrees.create({ name: "git" }) + expect(yield* stored(input.projectID)).toContainEqual({ directory: fallback.directory, strategy: "git" }) + const error = yield* worktrees.remove({ directory: created.directory, force: false }).pipe(Effect.flip) + expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError) + yield* worktrees.refresh() + expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "second" }) + }), + ) + + 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 git = yield* WorktreeGit.make + yield* worktrees.transform((editor) => + editor.add({ + ...git, + id: Worktree.StrategyID.make("broken"), + create: () => Effect.fail(new Error("backend failed")), + }), + ) + const parent = abs(path.join(input.root.path, "failures")) + 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 projects = yield* Project.Service + const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir())) + yield* Effect.promise(() => initRepo(other.path)) + const resolved = yield* projects.resolve(abs(other.path)) + expect(resolved.id).not.toBe(input.projectID) + const error = yield* worktrees.create({ from: abs(other.path), name: "nope" }).pipe(Effect.flip) + expect(error).toBeInstanceOf(Worktree.SourceDirectoryNotFoundError) + if (error instanceof Worktree.SourceDirectoryNotFoundError) expect(error.projectID).toBe(input.projectID) + }), + ) + + it.live("cannot remove a worktree from another project through the current location", () => + Effect.gen(function* () { + const worktrees = yield* Worktree.Service + const projects = yield* Project.Service + const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir())) + yield* Effect.promise(() => initRepo(other.path)) + const linked = abs(path.join(other.path, "linked")) + yield* Effect.promise(() => $`git worktree add --detach ${linked} HEAD`.cwd(other.path).quiet()) + const resolved = yield* projects.resolve(linked) + const error = yield* worktrees.remove({ directory: linked, force: true }).pipe(Effect.flip) + expect(error).toBeInstanceOf(Worktree.InvalidDirectoryError) + expect(yield* stored(resolved.id)).toContainEqual({ directory: linked, strategy: "git" }) + expect(yield* Effect.promise(() => fs.stat(linked).then((item) => item.isDirectory()))).toBe(true) + }), + ) + + it.live("rejects workspace-qualified locations before running worktree operations", () => + Effect.gen(function* () { + const input = yield* setup() + const database = yield* Database.Service + const bus = yield* Bus.Service + const fs = yield* FSUtil.Service + const context = yield* Layer.build( + worktreeLayer( + input.sourceDirectory, + input.projectID, + database, + bus, + input.root.path, + Workspace.ID.make("wrk_remote"), + ), + ) + 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), + ]) + 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", () => + Effect.gen(function* () { + const input = yield* setup() + const worktrees = yield* Worktree.Service + const git = yield* WorktreeGit.make + const directory = abs(path.join(input.root.path, "discovered")) + yield* Effect.promise(() => fs.mkdir(directory)) + const sources: AbsolutePath[] = [] + yield* worktrees.transform((editor) => + editor.add({ + ...git, + id: Worktree.StrategyID.make("discovered-copy"), + list: (sourceDirectory) => + Effect.sync(() => { + sources.push(sourceDirectory) + return [{ directory, type: "worktree" as const }] + }), + }), + ) + 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()).not.toContainEqual({ directory, strategy: "discovered-copy" }) + expect(sources).toEqual([input.sourceDirectory, input.sourceDirectory]) + }), + ) + + it.live("list surfaces strategy discovery failures", () => + Effect.gen(function* () { + const worktrees = yield* Worktree.Service + const git = yield* WorktreeGit.make + yield* worktrees.transform((editor) => + editor.add({ + ...git, + id: Worktree.StrategyID.make("broken-discovery"), + 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") + }), + ) + + it.live("applies directory config through the adapter and restores defaults after config removal", () => + Effect.gen(function* () { + const input = yield* setup() + const config = yield* Config.Test + const worktrees = yield* Worktree.Service + const bus = yield* Bus.Service + const reloaded = yield* Queue.unbounded() + const documents = [ + new Document({ + type: "document", + path: abs(path.join(input.root.path, "opencode.json")), + info: new Info({ worktree: { directory: "outer" } }), + }), + new Document({ + type: "document", + path: abs(path.join(input.root.path, "nested/opencode.json")), + info: new Info({ worktree: { directory: "copies" } }), + }), + ] + yield* config.setEntries(documents) + const git = yield* WorktreeGit.make + yield* worktrees.transform((editor) => editor.add({ ...git, id: Worktree.StrategyID.make("custom") })) + yield* ConfigWorktreePlugin.Plugin.effect( + host({ event: { subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)) } }), + ).pipe( + Effect.provideService(Worktree.Service, { + ...worktrees, + reload: () => worktrees.reload().pipe(Effect.tap(() => Queue.offer(reloaded, undefined))), + }), + ) + const first = yield* worktrees.create({ name: "one" }) + expect(first.directory).toBe(abs(path.join(input.root.path, "nested/copies/one"))) + expect(yield* stored(input.projectID)).toContainEqual({ directory: first.directory, strategy: "custom" }) + yield* config.setEntries(documents.slice(0, 1)) + yield* bus.publish(Event.Updated, {}) + yield* Queue.take(reloaded) + const second = yield* worktrees.create({ name: "two" }) + expect(second.directory).toBe(abs(path.join(input.root.path, "outer/two"))) + yield* config.setEntries([]) + yield* bus.publish(Event.Updated, {}) + yield* Queue.take(reloaded) + const third = yield* worktrees.create({ name: "three" }) + expect(third.directory).toBe(abs(path.join(input.root.path, "worktree", input.projectID.slice(0, 6), "three"))) + }), + ) + + it.effect("normalization retains worktree directory and rejects invalid configuration", () => + Effect.sync(() => { + expect(ConfigNormalize.normalize({ worktree: { directory: "./copies" } })).toMatchObject({ + type: "normalized", + encoded: { worktree: { directory: "./copies" } }, + diagnostics: [], + }) + for (const worktree of [{ directory: " " }, { directory: 12 }, {}]) { + const result = ConfigNormalize.normalize({ worktree }) + expect(result.diagnostics.length).toBeGreaterThan(0) + if (result.type === "normalized") expect(result.encoded).not.toHaveProperty("worktree") + } + }), + ) }) diff --git a/packages/plugin/src/effect/index.ts b/packages/plugin/src/effect/index.ts index 2ac5a13e1e8..d0bf50fab14 100644 --- a/packages/plugin/src/effect/index.ts +++ b/packages/plugin/src/effect/index.ts @@ -16,3 +16,4 @@ export { Rpc } from "@opencode-ai/schema/rpc" export { Skill } from "@opencode-ai/schema/skill" export { Vcs } from "@opencode-ai/schema/vcs" export { WebSearch } from "@opencode-ai/schema/websearch" +export { Worktree } from "@opencode-ai/schema/worktree" diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index 8cc0de5d671..4500ac13f36 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -20,6 +20,7 @@ import type { StorageDomain } from "./storage.js" import type { ToolDomain } from "./tool.js" import type { VcsDomain } from "./vcs.js" import type { WebSearchDomain } from "./websearch.js" +import type { WorktreeDomain } from "./worktree.js" export interface Context { readonly app: App @@ -47,6 +48,7 @@ export interface Context { readonly tool: ToolDomain readonly vcs: VcsDomain readonly websearch: WebSearchDomain + readonly worktree: WorktreeDomain } export interface Plugin { diff --git a/packages/plugin/src/effect/worktree.ts b/packages/plugin/src/effect/worktree.ts new file mode 100644 index 00000000000..4b3dd8e358b --- /dev/null +++ b/packages/plugin/src/effect/worktree.ts @@ -0,0 +1,21 @@ +import type { WorktreeApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { WorktreeCreateInput, WorktreeEntry, WorktreeRemoveInput, WorktreeResult } from "../worktree.js" +import type { Transform } from "./registration.js" + +export interface WorktreeDefinition { + readonly id: string + readonly create: (input: WorktreeCreateInput) => Effect.Effect + readonly remove: (input: WorktreeRemoveInput) => Effect.Effect + readonly list: (sourceDirectory: string) => Effect.Effect +} + +export interface WorktreeEditor { + /** Registers an implementation and selects it as the default. Later active registrations win. */ + add(definition: WorktreeDefinition): void +} + +export interface WorktreeDomain extends WorktreeApi { + readonly transform: Transform + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index ee0f69c0144..ec5b6dc294d 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -234,6 +234,7 @@ export function fromPromise(plugin: Plugin) { const SkillEndpoints = ClientApi.groups["server.skill"].endpoints const VcsEndpoints = ClientApi.groups["server.vcs"].endpoints const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints + const WorktreeEndpoints = ClientApi.groups["server.worktree"].endpoints const context = yield* Effect.context() const streams = yield* makeStreams() @@ -538,6 +539,27 @@ export function fromPromise(plugin: Plugin) { }), ), }, + worktree: { + list: adaptApiMethod(WorktreeEndpoints["worktree.list"], host.worktree.list), + create: adaptApiMethod(WorktreeEndpoints["worktree.create"], host.worktree.create), + remove: adaptApiMethod(WorktreeEndpoints["worktree.remove"], host.worktree.remove), + refresh: adaptApiMethod(WorktreeEndpoints["worktree.refresh"], host.worktree.refresh), + reload: () => run(host.worktree.reload()), + transform: (callback) => + register( + host.worktree.transform((editor) => + callback({ + add: (definition) => + editor.add({ + id: definition.id, + create: (input) => attempt((signal) => definition.create(input, { signal })), + remove: (input) => attempt((signal) => definition.remove(input, { signal })), + list: (directory) => attempt((signal) => definition.list(directory, { signal })), + }), + }), + ), + ), + }, session: { hook: (name, callback, options) => register( diff --git a/packages/plugin/src/promise/index.ts b/packages/plugin/src/promise/index.ts index 37dbf1e13be..b2e06aaf772 100644 --- a/packages/plugin/src/promise/index.ts +++ b/packages/plugin/src/promise/index.ts @@ -17,3 +17,4 @@ export { Rpc } from "@opencode-ai/schema/rpc" export { Skill } from "@opencode-ai/schema/skill" export { Vcs } from "@opencode-ai/schema/vcs" export { WebSearch } from "@opencode-ai/schema/websearch" +export { Worktree } from "@opencode-ai/schema/worktree" diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index 7f51bdc87cf..423c6779e92 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -20,6 +20,7 @@ import type { StorageDomain } from "./storage.js" import type { ToolDomain } from "./tool.js" import type { VcsDomain } from "./vcs.js" import type { WebSearchDomain } from "./websearch.js" +import type { WorktreeDomain } from "./worktree.js" export interface Context { readonly app: App @@ -47,6 +48,7 @@ export interface Context { readonly tool: ToolDomain readonly vcs: VcsDomain readonly websearch: WebSearchDomain + readonly worktree: WorktreeDomain } export type Cleanup = () => Promise | void diff --git a/packages/plugin/src/promise/worktree.ts b/packages/plugin/src/promise/worktree.ts new file mode 100644 index 00000000000..b71077fc59b --- /dev/null +++ b/packages/plugin/src/promise/worktree.ts @@ -0,0 +1,23 @@ +import type { WorktreeApi } from "@opencode-ai/client/promise/api" +import type { WorktreeCreateInput, WorktreeEntry, WorktreeRemoveInput, WorktreeResult } from "../worktree.js" +import type { Transform } from "./registration.js" + +export interface WorktreeDefinition { + readonly id: string + readonly create: (input: WorktreeCreateInput, context: { readonly signal: AbortSignal }) => Promise + readonly remove: (input: WorktreeRemoveInput, context: { readonly signal: AbortSignal }) => Promise + readonly list: ( + sourceDirectory: string, + context: { readonly signal: AbortSignal }, + ) => Promise +} + +export interface WorktreeEditor { + /** Registers an implementation and selects it as the default. Later active registrations win. */ + add(definition: WorktreeDefinition): void +} + +export interface WorktreeDomain extends WorktreeApi { + readonly transform: Transform + readonly reload: () => Promise +} diff --git a/packages/plugin/src/worktree.ts b/packages/plugin/src/worktree.ts new file mode 100644 index 00000000000..eaf09413c49 --- /dev/null +++ b/packages/plugin/src/worktree.ts @@ -0,0 +1,19 @@ +export interface WorktreeCreateInput { + readonly sourceDirectory: string + readonly directory: string + /** Starting ref, not the name of a new branch. Reject unsupported refs rather than ignoring them. */ + readonly branch?: string +} + +export interface WorktreeRemoveInput { + readonly directory: string + readonly force: boolean +} + +export interface WorktreeResult { + readonly directory: string +} + +export interface WorktreeEntry extends WorktreeResult { + readonly type: "root" | "worktree" +} diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 654638179f7..434b7d8d24a 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -11997,18 +11997,50 @@ "summary": "List references" } }, - "/api/worktree/{projectID}": { + "/api/worktree": { "get": { "tags": ["worktree"], "operationId": "v2.worktree.list", "parameters": [ { - "name": "projectID", - "in": "path", + "name": "location", + "in": "query", "schema": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] }, - "required": true + "required": false, + "style": "deepObject", + "explode": true } ], "security": [], @@ -12024,11 +12056,18 @@ } }, "400": { - "description": "InvalidRequestError", + "description": "WorktreeError | InvalidRequestError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + "anyOf": [ + { + "$ref": "#/components/schemas/WorktreeErrorEncoded" + }, + { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + ] } } } @@ -12044,7 +12083,7 @@ } } }, - "description": "List known local worktrees for a project.", + "description": "Discover worktrees through the requested location's strategies and return its project's inventory.", "summary": "List worktrees" }, "post": { @@ -12052,12 +12091,44 @@ "operationId": "v2.worktree.create", "parameters": [ { - "name": "projectID", - "in": "path", + "name": "location", + "in": "query", "schema": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] }, - "required": true + "required": false, + "style": "deepObject", + "explode": true } ], "security": [], @@ -12100,32 +12171,13 @@ } } }, - "description": "Create a worktree for a project and run its configured setup script.", + "description": "Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.", "summary": "Create worktree", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "strategy": { - "type": "string" - }, - "from": { - "type": "string" - }, - "branch": { - "type": "string" - }, - "directory": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": ["strategy"], - "additionalProperties": false + "$ref": "#/components/schemas/Worktree.CreateInput" } } }, @@ -12137,12 +12189,44 @@ "operationId": "v2.worktree.remove", "parameters": [ { - "name": "projectID", - "in": "path", + "name": "location", + "in": "query", "schema": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] }, - "required": true + "required": false, + "style": "deepObject", + "explode": true } ], "security": [], @@ -12178,23 +12262,13 @@ } } }, - "description": "Remove a managed worktree from a project.", + "description": "Remove a managed worktree from the requested location's project using its recorded strategy.", "summary": "Remove worktree", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "directory": { - "type": "string" - }, - "force": { - "type": "boolean" - } - }, - "required": ["directory", "force"], - "additionalProperties": false + "$ref": "#/components/schemas/Worktree.RemoveInput" } } }, @@ -12202,18 +12276,50 @@ } } }, - "/api/worktree/{projectID}/refresh": { + "/api/worktree/refresh": { "post": { "tags": ["worktree"], "operationId": "v2.worktree.refresh", "parameters": [ { - "name": "projectID", - "in": "path", + "name": "location", + "in": "query", "schema": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] }, - "required": true + "required": false, + "style": "deepObject", + "explode": true } ], "security": [], @@ -12249,7 +12355,7 @@ } } }, - "description": "Reconcile stored worktrees with the project repositories.", + "description": "Discover worktrees from the requested location and reconcile the shared project inventory.", "summary": "Refresh worktrees" } }, @@ -14146,6 +14252,9 @@ ] } }, + "worktree": { + "$ref": "#/components/schemas/Config.Worktree" + }, "warming": { "anyOf": [ { @@ -14463,6 +14572,16 @@ }, "additionalProperties": false }, + "Config.Worktree": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + }, "ConfigWebSearch.InfoEncoded": { "type": "object", "properties": { @@ -19576,6 +19695,27 @@ "additionalProperties": false, "description": "Reports whether this request destroyed an existing workspace." }, + "Worktree.CreateInput": { + "type": "object", + "properties": { + "strategy": { + "type": "string" + }, + "from": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, "Worktree.Directory": { "type": "object", "properties": { @@ -19605,6 +19745,19 @@ "$ref": "#/components/schemas/Worktree.Directory" } }, + "Worktree.RemoveInput": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "force": { + "type": "boolean" + } + }, + "required": ["directory", "force"], + "additionalProperties": false + }, "WorktreeErrorEncoded": { "type": "object", "properties": { @@ -19738,7 +19891,7 @@ }, { "name": "worktree", - "description": "Project worktree management routes." + "description": "Location-scoped worktree management routes." }, { "name": "workspace", diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 358918030f6..4f4c9161ff3 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -54,6 +54,7 @@ type LocationGroups = | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware + | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware @@ -88,7 +89,6 @@ type ApiGroups< | typeof ServerGroup | typeof DebugGroup | typeof MigrationGroup - | typeof WorktreeGroup | typeof WorkspaceGroup | typeof GenerateGroup | typeof PersistentPtyGroup @@ -176,7 +176,7 @@ const makeApiFromGroup = < .add(PersistentPtyGroup) .add(ShellGroup.middleware(locationMiddleware)) .add(ReferenceGroup.middleware(locationMiddleware)) - .add(WorktreeGroup) + .add(WorktreeGroup.middleware(locationMiddleware)) .add(WorkspaceGroup) .add(VcsGroup.middleware(locationMiddleware)) .add(DebugGroup) diff --git a/packages/protocol/src/groups/worktree.ts b/packages/protocol/src/groups/worktree.ts index 3e78079ac97..977b36fc69a 100644 --- a/packages/protocol/src/groups/worktree.ts +++ b/packages/protocol/src/groups/worktree.ts @@ -1,9 +1,9 @@ -import { Project } from "@opencode-ai/schema/project" import { Worktree } from "@opencode-ai/schema/worktree" -import { Schema, Struct } from "effect" +import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location.js" -const root = "/api/worktree/:projectID" +const root = "/api/worktree" export class WorktreeError extends Schema.Error("WorktreeError")( { @@ -16,61 +16,69 @@ export class WorktreeError extends Schema.Error("WorktreeError")( { httpApiStatus: 400 }, ) {} -const CreatePayload = Schema.Struct(Struct.omit(Worktree.CreateInput.fields, ["projectID"])) -const RemovePayload = Schema.Struct(Struct.omit(Worktree.RemoveInput.fields, ["projectID"])) - export const WorktreeGroup = HttpApiGroup.make("server.worktree") .add( HttpApiEndpoint.get("worktree.list", root, { - params: { projectID: Project.ID }, + query: LocationQuery, success: Worktree.List, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.worktree.list", - summary: "List worktrees", - description: "List known local worktrees for a project.", - }), - ), + error: WorktreeError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.worktree.list", + summary: "List worktrees", + description: + "Discover worktrees through the requested location's strategies and return its project's inventory.", + }), + ), ) .add( HttpApiEndpoint.post("worktree.create", root, { - params: { projectID: Project.ID }, - payload: CreatePayload, + query: LocationQuery, + payload: Worktree.CreateInput, success: Worktree.Info, error: WorktreeError, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.worktree.create", - summary: "Create worktree", - description: "Create a worktree for a project and run its configured setup script.", - }), - ), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.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.", + }), + ), ) .add( HttpApiEndpoint.delete("worktree.remove", root, { - params: { projectID: Project.ID }, - payload: RemovePayload, + query: LocationQuery, + payload: Worktree.RemoveInput, success: HttpApiSchema.NoContent, error: WorktreeError, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.worktree.remove", - summary: "Remove worktree", - description: "Remove a managed worktree from a project.", - }), - ), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.worktree.remove", + summary: "Remove worktree", + description: "Remove a managed worktree from the requested location's project using its recorded strategy.", + }), + ), ) .add( HttpApiEndpoint.post("worktree.refresh", `${root}/refresh`, { - params: { projectID: Project.ID }, + query: LocationQuery, success: HttpApiSchema.NoContent, error: WorktreeError, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.worktree.refresh", - summary: "Refresh worktrees", - description: "Reconcile stored worktrees with the project repositories.", - }), - ), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.worktree.refresh", + summary: "Refresh worktrees", + description: "Discover worktrees from the requested location and reconcile the shared project inventory.", + }), + ), ) - .annotateMerge(OpenApi.annotations({ title: "worktree", description: "Project worktree management routes." })) + .annotateMerge(OpenApi.annotations({ title: "worktree", description: "Location-scoped worktree management routes." })) diff --git a/packages/schema/src/config.ts b/packages/schema/src/config.ts index 7ffa170bb7d..e23c2bce6bb 100644 --- a/packages/schema/src/config.ts +++ b/packages/schema/src/config.ts @@ -20,6 +20,7 @@ import { ConfigWebSearch } from "./config/websearch.js" import { ConfigToolOutput } from "./config/tool-output.js" import { ConfigWatcher } from "./config/watcher.js" import { ConfigWarming } from "./config/warming.js" +import { ConfigWorktree } from "./config/worktree.js" export class Info extends Schema.Class("Config.Info")({ $schema: optional(Schema.String).annotate({ @@ -98,6 +99,9 @@ export class Info extends Schema.Class("Config.Info")({ plugins: ConfigPlugin.Plugins.pipe(optional).annotate({ description: "Ordered plugin enablement directives and external package declarations", }), + worktree: ConfigWorktree.Info.pipe(optional).annotate({ + description: "Directory defaults for local worktree creation", + }), warming: ConfigWarming.Warming.pipe(optional).annotate({ description: "Keep recently active sessions warm with transient model requests (default: false)", }), diff --git a/packages/schema/src/config/worktree.ts b/packages/schema/src/config/worktree.ts new file mode 100644 index 00000000000..399f4ff1b1a --- /dev/null +++ b/packages/schema/src/config/worktree.ts @@ -0,0 +1,10 @@ +export * as ConfigWorktree from "./worktree.js" + +import { Schema } from "effect" + +export const Info = Schema.Struct({ + directory: Schema.Trim.pipe(Schema.check(Schema.isNonEmpty())).annotate({ + description: "Parent directory for new worktrees, relative to the declaring config file when not absolute", + }), +}).annotate({ identifier: "Config.Worktree" }) +export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/worktree.ts b/packages/schema/src/worktree.ts index 5bd35cd5176..33a6566eb25 100644 --- a/packages/schema/src/worktree.ts +++ b/packages/schema/src/worktree.ts @@ -2,7 +2,6 @@ export * as Worktree from "./worktree.js" import { Schema } from "effect" import { durable, ephemeral, inventory } from "./event.js" -import { ProjectID } from "./project-id.js" import { AbsolutePath, optional } from "./schema.js" import { Project } from "./project.js" @@ -10,20 +9,18 @@ export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Sc export type StrategyID = typeof StrategyID.Type export const CreateInput = Schema.Struct({ - projectID: ProjectID, - strategy: StrategyID, + strategy: optional(StrategyID), from: optional(AbsolutePath), branch: optional(Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()))), directory: optional(AbsolutePath).annotate({ description: - "Parent directory for the new worktree. Defaults to the server's data directory under worktree/.", + "Parent directory for the new worktree. Uses the location's configuration, then defaults to the server's data directory under worktree/.", }), name: optional(Schema.String), }).annotate({ identifier: "Worktree.CreateInput" }) export interface CreateInput extends Schema.Schema.Type {} export const RemoveInput = Schema.Struct({ - projectID: ProjectID, directory: AbsolutePath, force: Schema.Boolean, }).annotate({ identifier: "Worktree.RemoveInput" }) @@ -40,10 +37,16 @@ export const Directory = Schema.Struct({ }).annotate({ identifier: "Worktree.Directory" }) export interface Directory extends Schema.Schema.Type {} -export const ListInput = Schema.Struct({ - projectID: ProjectID, -}).annotate({ identifier: "Worktree.ListInput" }) -export interface ListInput extends Schema.Schema.Type {} +export const ListEntry = Schema.Struct({ + directory: AbsolutePath, + type: Schema.Literals(["root", "worktree"]), +}).annotate({ identifier: "Worktree.ListEntry" }) +export interface ListEntry extends Schema.Schema.Type {} + +export class OperationError extends Schema.TaggedError()("Worktree.OperationError", { + message: Schema.String, + forceRequired: optional(Schema.Boolean), +}) {} export const List = Schema.Array(Directory).annotate({ identifier: "Worktree.List" }) export type List = typeof List.Type diff --git a/packages/schema/test/config.test.ts b/packages/schema/test/config.test.ts index 1bf24bb577b..60852ef5a3d 100644 --- a/packages/schema/test/config.test.ts +++ b/packages/schema/test/config.test.ts @@ -10,6 +10,15 @@ import { AbsolutePath } from "../src/schema.js" import { WebSearch } from "../src/websearch.js" describe("Config.Entry", () => { + test("accepts directory-only worktree config and omits it when absent", () => { + const decode = Schema.decodeUnknownSync(Config.Info) + const input = { worktree: { directory: "../worktrees" } } + expect(Schema.encodeSync(Config.Info)(decode(input))).toEqual(input) + expect(Schema.encodeSync(Config.Info)(new Config.Info({ worktree: undefined }))).not.toHaveProperty("worktree") + expect(() => decode({ worktree: {} })).toThrow() + expect(() => decode({ worktree: { directory: " " } })).toThrow() + expect(() => decode({ worktree: { directory: false } })).toThrow() + }) test("round-trips canonical provider IDs without changing config keys", () => { const input = { providers: { "console-anthropic": { canonical: "anthropic" } } } const decoded = Schema.decodeUnknownSync(Config.Info)(input) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index 24def3583bb..7e5546b8ce4 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -184,7 +184,6 @@ describe("contract hygiene", () => { Model.Variant, Project.Current, Worktree.Directory, - Worktree.ListInput, Worktree.List, Project.Icon, Project.Commands, diff --git a/packages/schema/test/worktree.test.ts b/packages/schema/test/worktree.test.ts index 950370f5818..83ada90ff39 100644 --- a/packages/schema/test/worktree.test.ts +++ b/packages/schema/test/worktree.test.ts @@ -5,20 +5,39 @@ import { Worktree } from "../src/worktree.js" describe("Worktree.CreateInput", () => { test("allows the server to choose the destination", () => { const input = Schema.decodeUnknownSync(Worktree.CreateInput)({ - projectID: "project", strategy: "git", }) expect(input.directory).toBeUndefined() expect(Schema.encodeSync(Worktree.CreateInput)({ ...input, directory: undefined })).toEqual({ - projectID: "project", strategy: "git", }) }) test("preserves an explicit destination", () => { - const input = { projectID: "project", strategy: "git", directory: "/custom/worktrees" } + const input = { strategy: "git", 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("inventory contains only the directory and its owning strategy", () => { + const value = Schema.decodeUnknownSync(Worktree.Directory)({ directory: "/repo/task", strategy: "git" }) + expect(Schema.encodeSync(Worktree.Directory)(value)).toEqual({ directory: "/repo/task", strategy: "git" }) +}) + +test("strategy failures can request force confirmation without Core or Git dependencies", () => { + const value = new Worktree.OperationError({ message: "Dirty worktree", forceRequired: true }) + expect(value.forceRequired).toBe(true) + expect( + Schema.encodeSync(Worktree.OperationError)(new Worktree.OperationError({ message: "Failed" })), + ).not.toHaveProperty("forceRequired") +}) diff --git a/packages/server/package.json b/packages/server/package.json index ec2f046909f..637d75f1b4e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -36,6 +36,8 @@ "effect": "catalog:" }, "devDependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/plugin": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:" diff --git a/packages/server/src/handlers/worktree.ts b/packages/server/src/handlers/worktree.ts index 364bd97ed5d..8eaebc80d3b 100644 --- a/packages/server/src/handlers/worktree.ts +++ b/packages/server/src/handlers/worktree.ts @@ -1,32 +1,32 @@ import { Git } from "@opencode-ai/core/git" import { Worktree } from "@opencode-ai/core/worktree" +import { Plugin } from "@opencode-ai/core/plugin" import { WorktreeError } from "@opencode-ai/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) => - Effect.gen(function* () { - const worktrees = yield* Worktree.Service - - return handlers - .handle("worktree.list", (ctx) => worktrees.list(ctx.params.projectID)) - .handle("worktree.create", (ctx) => - badRequest(worktrees.create({ ...ctx.payload, projectID: ctx.params.projectID })), - ) - .handle("worktree.remove", (ctx) => - badRequest(worktrees.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe( - Effect.as(HttpApiSchema.NoContent.make()), - ), - ) - .handle("worktree.refresh", (ctx) => - badRequest(worktrees.refresh({ projectID: ctx.params.projectID })).pipe( - Effect.as(HttpApiSchema.NoContent.make()), - ), - ) - }), + 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(action: (service: Worktree.Interface) => Effect.Effect) { + return Effect.gen(function* () { + const plugins = yield* Plugin.Service + const worktrees = yield* Worktree.Service + yield* plugins.awaitActivation + return yield* action(worktrees) + }).pipe(badRequest) +} + function badRequest(effect: Effect.Effect) { return effect.pipe( Effect.mapError( @@ -35,7 +35,10 @@ function badRequest(effect: Effect.Effect) { name: "WorktreeError", data: { message: message(error), - forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined, + forceRequired: + error instanceof Git.WorktreeError || error instanceof Worktree.OperationError + ? error.forceRequired + : undefined, }, }), ), @@ -51,5 +54,6 @@ 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 } diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index f301d394257..5598557d971 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -30,7 +30,6 @@ import { PluginUpdate } from "@opencode-ai/core/plugin/update" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { WellKnown } from "@opencode-ai/core/wellknown" import { Workspace } from "@opencode-ai/core/workspace" -import { Worktree } from "@opencode-ai/core/worktree" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { HttpRouter } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -56,7 +55,6 @@ const applicationServiceNodes = [ httpClient, Job.node, Project.node, - Worktree.node, Session.node, Instance.node, SessionTransfer.node, diff --git a/packages/server/test/fixture/worktree-delegate/index.ts b/packages/server/test/fixture/worktree-delegate/index.ts new file mode 100644 index 00000000000..d56342b8ce9 --- /dev/null +++ b/packages/server/test/fixture/worktree-delegate/index.ts @@ -0,0 +1,13 @@ +import { Plugin } from "@opencode-ai/plugin" + +export default Plugin.define({ + id: "test.worktree-delegate", + async setup(ctx) { + const directory = ctx.options.directory + if (typeof directory !== "string") throw new Error("Missing target location") + await ctx.worktree.create({ + location: { directory }, + name: "delegated", + }) + }, +}) diff --git a/packages/server/test/fixture/worktree-plugin/index.ts b/packages/server/test/fixture/worktree-plugin/index.ts new file mode 100644 index 00000000000..b49b89a8c89 --- /dev/null +++ b/packages/server/test/fixture/worktree-plugin/index.ts @@ -0,0 +1,47 @@ +import { Plugin, Worktree } from "@opencode-ai/plugin" + +export default Plugin.define({ + id: "test.worktree", + async setup(ctx) { + const id = typeof ctx.options.strategy === "string" ? ctx.options.strategy : "test-copy" + await ctx.worktree.transform((editor) => + editor.add({ + id, + async create(input, { signal }) { + await git( + input.sourceDirectory, + ["worktree", "add", "--detach", "--", input.directory, input.branch ?? "HEAD"], + signal, + ) + await ctx.storage.set(`tree:${input.directory}`, input.sourceDirectory) + return { directory: input.directory } + }, + async remove(input, { signal }) { + const source = await ctx.storage.get(`tree:${input.directory}`) + if (typeof source !== "string") throw new Worktree.OperationError({ message: "Worktree source not found" }) + // Windows cannot remove the working directory of the Git process itself. + await git( + source, + ["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory], + signal, + !input.force, + ) + await ctx.storage.remove(`tree:${input.directory}`) + }, + async list(sourceDirectory, { signal }) { + signal.throwIfAborted() + const rows = await ctx.storage.scan({ prefix: "tree:" }) + return rows.entries + .filter((row) => row.value === sourceDirectory) + .map((row) => ({ directory: row.key.slice(5), type: "worktree" as const })) + }, + }), + ) + }, +}) + +async function git(directory: string, args: string[], signal: AbortSignal, forceRequired = false) { + const child = Bun.spawn(["git", "-C", directory, ...args], { stdout: "pipe", stderr: "pipe", signal }) + const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]) + if (code !== 0) throw new Worktree.OperationError({ message: stderr, forceRequired }) +} diff --git a/packages/server/test/worktree.test.ts b/packages/server/test/worktree.test.ts index 7a1701e5a95..9244183a369 100644 --- a/packages/server/test/worktree.test.ts +++ b/packages/server/test/worktree.test.ts @@ -6,57 +6,223 @@ import { Effect } from "effect" import { tmpdir } from "../../core/test/fixture/tmpdir" import { it } from "../../core/test/lib/effect" import { startServer } from "./fixture/server" +import { OpenCode } from "@opencode-ai/client" +import { initRepo } from "../../core/test/fixture/git" -it.live("lists, creates, and removes worktrees by project ID", () => - Effect.gen(function* () { - const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-endpoint-"))) - const project = path.join(tmp.path, "project") - const destination = path.join(tmp.path, "worktrees") - yield* Effect.promise(() => fs.mkdir(project, { recursive: true })) - yield* Effect.promise(() => $`git init`.cwd(project).quiet()) - yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet()) - 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 location = new URL("/api/location", server.base) - location.searchParams.set("location[directory]", project) - const resolved = yield* Effect.promise(() => - fetch(location, { headers: server.headers }).then((response) => response.json()), - ) - if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string") - throw new Error("Expected resolved project") - const url = new URL(`/api/worktree/${resolved.project.id}`, server.base) +it.live( + "lists, creates, and removes worktrees through the same location", + () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-endpoint-"))) + const project = path.join(tmp.path, "project") + const destination = path.join(tmp.path, "worktrees") + yield* Effect.promise(() => fs.mkdir(project, { recursive: true })) + yield* Effect.promise(() => $`git init`.cwd(project).quiet()) + yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet()) + 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 url = new URL("/api/worktree", server.base) + url.searchParams.set("location[directory]", project) - const initial = yield* Effect.promise(() => - fetch(url, { headers: server.headers }).then((response) => response.json()), - ) - expect(initial).toEqual([{ directory: project }]) + const initial = yield* Effect.promise(() => + fetch(url, { headers: server.headers }).then((response) => response.json()), + ) + expect(initial).toEqual([{ directory: project }]) - const created = yield* Effect.promise(() => - fetch(url, { - method: "POST", - headers: { ...server.headers, "content-type": "application/json" }, - body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }), - }).then((response) => response.json()), - ) - expect(created).toEqual({ directory: path.join(destination, "api") }) + const created = yield* Effect.promise(() => + fetch(url, { + method: "POST", + headers: { ...server.headers, "content-type": "application/json" }, + body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }), + }).then((response) => response.json()), + ) + expect(created).toEqual({ directory: path.join(destination, "api") }) - const listed = yield* Effect.promise(() => - fetch(url, { headers: server.headers }).then((response) => response.json()), - ) - expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" }) + const listed = yield* Effect.promise(() => + fetch(url, { headers: server.headers }).then((response) => response.json()), + ) + expect(listed).toContainEqual({ + directory: path.join(destination, "api"), + strategy: "git", + }) - const removed = yield* Effect.promise(() => - fetch(url, { - method: "DELETE", - headers: { ...server.headers, "content-type": "application/json" }, - body: JSON.stringify({ directory: path.join(destination, "api"), force: false }), - }), - ) - expect(removed.status).toBe(204) - }), + const removed = yield* Effect.promise(() => + fetch(url, { + method: "DELETE", + headers: { ...server.headers, "content-type": "application/json" }, + body: JSON.stringify({ directory: path.join(destination, "api"), force: false }), + }), + ) + expect(removed.status).toBe(204) + }), + 30_000, ) -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} +it.live( + "derives the project and creation defaults when the SDK omits its input", + () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-default-location-"))) + const project = path.join(tmp.path, "project") + const config = path.join(tmp.path, "config") + const destination = path.join(tmp.path, "copies") + yield* Effect.promise(async () => { + await fs.mkdir(project) + await initRepo(project) + await fs.mkdir(config) + await Bun.write(path.join(config, "opencode.json"), JSON.stringify({ worktree: { directory: destination } })) + }) + const server = yield* startServer(config) + const api = OpenCode.make({ + baseUrl: server.base, + headers: { ...server.headers, "x-opencode-directory": encodeURIComponent(project) }, + }) + yield* Effect.promise(async () => { + const created = await api.worktree.create() + expect(path.dirname(created.directory)).toBe(destination) + await api.worktree.refresh() + expect(await api.worktree.list()).toContainEqual({ + directory: created.directory, + strategy: "git", + }) + await api.worktree.remove({ directory: created.directory, force: false }) + expect(await api.worktree.list()).toEqual([{ directory: project }]) + }) + }), + 30_000, +) + +it.live( + "uses checkout-local plugins and configuration for clones sharing a project", + () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-plugins-"))) + const first = path.join(tmp.path, "first") + const second = path.join(tmp.path, "second") + const nested = path.join(first, "nested") + const config = path.join(tmp.path, "config") + const destination = path.join(tmp.path, "worktrees") + yield* Effect.promise(async () => { + await fs.mkdir(first) + await initRepo(first) + await $`git remote add origin git@github.com:example/worktree-fixture.git`.cwd(first).quiet() + await $`git clone --no-hardlinks ${first} ${second}`.quiet() + await $`git remote set-url origin https://github.com/example/worktree-fixture.git`.cwd(second).quiet() + await fs.mkdir(nested) + 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"), + JSON.stringify({ + plugins: [ + { package: path.join(import.meta.dir, "fixture/worktree-plugin"), options: { strategy: "test-copy" } }, + ], + }), + ) + }) + 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" }) + 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(otherRows).toContainEqual({ directory: custom.directory, strategy: "test-copy" }) + const rows = await api.worktree.list({ location: { directory: nested } }) + expect(rows).toContainEqual({ + directory: custom.directory, + strategy: "test-copy", + }) + expect(rows).toContainEqual({ directory: builtin.directory, strategy: "git" }) + + await Bun.write(path.join(custom.directory, "dirty.txt"), "keep me") + 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 }), + }) + expect(failure.status).toBe(400) + expect(await failure.json()).toMatchObject({ data: { forceRequired: true } }) + expect(await Bun.file(path.join(custom.directory, "dirty.txt")).text()).toBe("keep me") + + await api.worktree.remove({ + location: { directory: nested }, + directory: custom.directory, + force: true, + }) + await api.worktree.remove({ + location: { directory: second }, + directory: builtin.directory, + force: false, + }) + expect((await api.worktree.list({ location: { directory: nested } })).filter((row) => row.strategy)).toEqual([]) + }) + }), + 30_000, +) + +it.live( + "plugin calls await a different location's strategy and directory configuration", + () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-delegate-"))) + const source = path.join(tmp.path, "source") + const target = path.join(tmp.path, "target") + const destination = path.join(tmp.path, "copies") + yield* Effect.promise(async () => { + for (const directory of [source, target]) { + await fs.mkdir(directory) + await initRepo(directory) + await $`git remote add origin git@github.com:example/delegate-fixture.git`.cwd(directory).quiet() + } + await Bun.write( + path.join(source, "opencode.json"), + JSON.stringify({ + plugins: [ + { package: path.join(import.meta.dir, "fixture/worktree-delegate"), options: { directory: target } }, + ], + }), + ) + await Bun.write( + path.join(target, "opencode.json"), + JSON.stringify({ + worktree: { directory: destination }, + plugins: [ + { package: path.join(import.meta.dir, "fixture/worktree-plugin"), options: { strategy: "target-copy" } }, + ], + }), + ) + }) + const server = yield* startServer(path.join(tmp.path, "config")) + const api = OpenCode.make({ baseUrl: server.base, headers: server.headers }) + yield* Effect.promise(async () => { + await api.location.get({ location: { directory: source } }) + const url = new URL("/api/plugin/await-activation", server.base) + url.searchParams.set("location[directory]", source) + expect((await fetch(url, { method: "POST", headers: server.headers })).status).toBe(204) + expect(await api.worktree.list({ location: { directory: target } })).toContainEqual({ + directory: path.join(destination, "delegated"), + strategy: "target-copy", + }) + }) + }), + 30_000, +) diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 0b3c8f6f03c..2d1022a14b2 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -27,6 +27,7 @@ type ProjectDirectory = WorktreeListOutput[number] type DialogMoveSessionProps = { projectID: string + location?: { directory: string; workspaceID?: string } current?: MoveSessionSelection onSelect: (selection: MoveSessionSelection) => void onCurrentChange?: (selection: MoveSessionSelection) => void @@ -45,7 +46,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const toast = useToast() const paths = useTuiPaths() const shortcuts = Keymap.useShortcuts() - const location = createMemo(() => sessionData.location.info()) + const location = createMemo(() => sessionData.location.info(props.location)) + const worktreeLocation = () => ({ + directory: props.location?.directory ?? location()?.directory ?? paths.cwd, + workspace: props.location?.workspaceID ?? location()?.workspaceID, + }) const [working, setWorking] = createSignal(Boolean(props.initialRemoving)) const [toDelete, setToDelete] = createSignal() const [removing, setRemoving] = createSignal(props.initialRemoving) @@ -76,11 +81,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { }) const [directories, { refetch }] = createResource( - () => (props.fixture || props.initialRemoving ? undefined : props.projectID), - async (projectID, info): Promise | undefined> => { + () => (props.fixture || props.initialRemoving ? undefined : worktreeLocation()), + async (location, info): Promise | undefined> => { try { - await client.api.worktree.refresh({ projectID }) - const directories = await client.api.worktree.list({ projectID }) + const directories = await client.api.worktree.list({ location }) setLoadError(undefined) return directories } catch (error) { @@ -223,10 +227,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { setToDelete(undefined) setRemoving(selected.directory) setWorking(true) + const request = { + directory: selected.directory, + location: worktreeLocation(), + } const error = await client.api.worktree .remove({ - projectID: props.projectID, - directory: selected.directory, + ...request, force: false, }) .then( @@ -251,8 +258,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { reopen(selected.directory) const forcedError = await client.api.worktree .remove({ - projectID: props.projectID, - directory: selected.directory, + ...request, force: true, }) .then( diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index c11cab19421..228747ab3b1 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -1,5 +1,4 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import path from "path" import { useTuiPaths } from "../../context/runtime" import { errorMessage } from "../../util/error" import { useDialog } from "../../ui/dialog" @@ -38,10 +37,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({ - projectID: project.id, - strategy: "git", - from: project.canonical, - directory: path.join(paths.worktree, project.id.slice(0, 6)), + location: { directory: location.directory, workspace: location.workspaceID }, name, }) const directory = result.directory @@ -74,6 +70,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess dialog.replace(() => ( { +])("passes the current location and uses server worktree defaults for $name", async (input) => { const fixture = await renderMove(input) try { await fixture.data.project.sync() @@ -46,9 +45,7 @@ test.each([ await fixture.create() - expect(fixture.requests).toEqual([ - { strategy: "git", from: clone, directory: path.join("/tmp/opencode", "proj_t"), name: "fresh" }, - ]) + expect(fixture.requests).toEqual([{ payload: { name: "fresh" }, directory: input.directory, workspace: null }]) expect(fixture.data.location.info({ directory: created })?.project.canonical).toBe(clone) expect(fixture.reads.locations.filter((directory) => directory === input.directory)).toHaveLength(1) expect(fixture.reads.session).toBe(input.home ? 0 : 1) @@ -73,11 +70,11 @@ test.each([ const frame = await fixture.create() - expect(fixture.reads.worktrees).toEqual(["proj_test"]) + expect(fixture.reads.worktrees).toEqual([selected.directory]) expect(frame).toContain(clone) expect(frame.indexOf(clone)).toBeLessThan(frame.indexOf(main)) expect(fixture.requests).toEqual([ - { strategy: "git", from: clone, directory: path.join("/tmp/opencode", "proj_t"), name: "fresh" }, + { payload: { name: "fresh" }, directory: `${clone}/packages/tui`, workspace: input.workspaceID ?? null }, ]) expect(fixture.data.location.info(selected)?.project.canonical).toBe(clone) expect(fixture.moves).toEqual([]) @@ -86,6 +83,23 @@ test.each([ } }) +test("removal uses the current configuration location, not the destination directory", async () => { + const fixture = await renderMove({ directory: clone, home: true }) + try { + await fixture.move.open() + await fixture.app.waitForFrame((frame) => frame.includes("Move session") && frame.includes(linked)) + await fixture.app.mockInput.typeText("linked") + await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone)) + fixture.app.mockInput.pressKey("d", { ctrl: true }) + 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 }]) + } finally { + fixture.app.renderer.destroy() + } +}) + test.each([ { name: "session", unavailable: "session" as const }, { name: "location", unavailable: "location" as const }, @@ -115,6 +129,7 @@ async function renderMove(input: { }) { const launch = input.launch ?? (input.home ? input.directory : main) const requests: unknown[] = [] + const removals: unknown[] = [] const moves: unknown[] = [] const reads = { session: 0, locations: [] as string[], worktrees: [] as string[] } const calls = createFetch(async (url, request) => { @@ -156,21 +171,30 @@ async function renderMove(input: { }, }) } - if (url.pathname === "/api/worktree/proj_test" || url.pathname === "/api/worktree/proj_launch") { + if (url.pathname === "/api/worktree") { if (request.method === "GET") { - reads.worktrees.push(url.pathname.slice("/api/worktree/".length)) + const directory = url.searchParams.get("location[directory]") ?? launch + reads.worktrees.push(directory) return json( - url.pathname === "/api/worktree/proj_launch" + directory === launch && input.launchProjectID ? [{ directory: launch }] : [{ directory: main }, { directory: clone }, { directory: linked, strategy: "git" }], ) } if (request.method === "POST") { - requests.push(await request.json()) + requests.push({ + payload: await request.json(), + directory: url.searchParams.get("location[directory]"), + workspace: url.searchParams.get("location[workspace]"), + }) return json({ directory: created }) } + if (request.method === "DELETE") { + removals.push({ payload: await request.json(), directory: url.searchParams.get("location[directory]") }) + return new Response(null, { status: 204 }) + } } - if (url.pathname === "/api/worktree/proj_launch/refresh") return new Response(null, { status: 204 }) + if (url.pathname === "/api/worktree/refresh") 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 }) @@ -229,6 +253,7 @@ async function renderMove(input: { toast, location, requests, + removals, moves, reads, async create() { diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index 8eef52a8ed3..b0a116e5857 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -112,12 +112,12 @@ export function createFetch(override?: FetchHandler, events?: ReturnType { + editor.add(strategy) + }) + }, +}) +``` + +Load the plugin through `plugins` and configure the common destination separately: + +```jsonc title="opencode.jsonc" +{ + "worktree": { "directory": "../worktrees" }, + "plugins": [{ "package": "./plugins/worktrees", "options": {} }], +} +``` + +- Adding a strategy selects it automatically. The last active registration wins; no strategy-selection config is needed. +- Disposing its registration or unloading the plugin restores the previous implementation, ultimately the bundled Git strategy. +- `reload()` replays transforms after captured inputs change; it does not rerun plugin setup. +- Creation failures do not retry through Git. Existing worktrees retain their recorded owner even when the default changes. +- Strategies manage local directories. Remote workspace provisioning is not part of this interface. + +#### 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. + +```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 }) +``` + +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. +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. + +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: + +```ts +await ctx.worktree.remove({ + directory: "/worktrees/task", + location: { directory: "/repos/app" }, + force: false, +}) +``` + +Missing owners fail removal rather than falling back to Git. A strategy can throw `new Worktree.OperationError({ +message: "Uncommitted changes", forceRequired: true })`, importing `Worktree` from `@opencode-ai/plugin`, to request +force confirmation without depending on Core or Git errors. + +#### Reference + +Implementations receive the final destination after naming and collision handling. Return that directory from `create`; +`list` must report only directories the strategy owns, plus any repository roots. Core owns inventory and startup commands. + +```ts +interface WorktreeDefinition { + readonly id: string + create( + input: { sourceDirectory: string; directory: string; branch?: string }, + context: { signal: AbortSignal }, + ): Promise<{ directory: string }> + remove(input: { directory: string; force: boolean }, context: { signal: AbortSignal }): Promise + list( + sourceDirectory: string, + context: { signal: AbortSignal }, + ): Promise +} + +interface WorktreeEditor { + add(definition: WorktreeDefinition): void +} + +interface WorktreeDomain extends WorktreeApi { + transform(callback: (editor: WorktreeEditor) => void): Promise + reload(): Promise +} +``` + +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. + ### Websearch List websearch providers or run a query through the selected provider. diff --git a/packages/www/src/docs/content/config.mdx b/packages/www/src/docs/content/config.mdx index d1d7bc19842..17874583693 100644 --- a/packages/www/src/docs/content/config.mdx +++ b/packages/www/src/docs/content/config.mdx @@ -419,6 +419,25 @@ context. See the [references guide](/references) for shorthand, visibility, and path resolution. +### Worktrees + +Set the parent directory for new local worktrees. OpenCode appends the requested or generated worktree name. + +```jsonc +{ + "worktree": { + "directory": "../worktrees", + }, +} +``` + +Relative paths resolve against the config file that declares them; `~/` resolves against the user's home directory. +Without this setting, creation uses the server's data directory under `worktree/`. +Configuration applies to the caller's location, not every clone sharing a 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. + ### Plugins Load plugins from packages or local plugin directories. Use the object form when a plugin