mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(worktree): support configurable plugin strategies (#47358)
This commit is contained in:
@@ -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:",
|
||||
|
||||
@@ -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<string, unknown> = route.request().postDataJSON()
|
||||
|
||||
@@ -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<void>()
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
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<void>()
|
||||
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<string>()
|
||||
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<void>()
|
||||
await page.route("**/api/mcp", async (route) => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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 ?? [])
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1931,33 +1931,37 @@ export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
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<E = never> = (input: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
|
||||
export type WorktreeListOperation<E = never> = (input?: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
|
||||
|
||||
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<E = never> = (input: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
|
||||
export type WorktreeCreateOperation<E = never> = (input?: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
|
||||
|
||||
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<E = never> = (input: WorktreeRemoveInput) => Effect.Effect<WorktreeRemoveOutput, E>
|
||||
|
||||
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<E = never> = (
|
||||
input: WorktreeRefreshInput,
|
||||
input?: WorktreeRefreshInput,
|
||||
) => Effect.Effect<WorktreeRefreshOutput, E>
|
||||
|
||||
export interface WorktreeApi<E = never> {
|
||||
|
||||
@@ -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<WorktreeListOutput>()(
|
||||
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<WorktreeCreateOutput>()(
|
||||
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<WorktreeRemoveOutput>()(
|
||||
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<WorktreeRefreshOutput>()(
|
||||
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"]) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1968,28 +1968,30 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
worktree: {
|
||||
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
list: (input?: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeListOutput>(
|
||||
{
|
||||
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<WorktreeCreateOutput>(
|
||||
{
|
||||
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<WorktreeRemoveOutput>(
|
||||
{
|
||||
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<WorktreeRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`,
|
||||
path: `/api/worktree/refresh`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -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<string | { package: string; options?: { [x: string]: JsonValue } }>
|
||||
worktree?: ConfigWorktree
|
||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
@@ -6077,42 +6080,48 @@ export type ReferenceListOutput = {
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
|
||||
const atWorktree = <A, E>(
|
||||
ref: Location.Ref | undefined,
|
||||
run: (service: Worktree.Interface) => Effect.Effect<A, E>,
|
||||
) => {
|
||||
if (ref?.workspaceID) return Effect.fail(new Worktree.UnsupportedLocationError({ directory: ref.directory }))
|
||||
if (!ref || isCurrentLocation(ref)) return run(worktrees)
|
||||
return Effect.gen(function* () {
|
||||
// Defer this import: Plugin's construction depends on this host. Same-location setup calls never wait on themselves.
|
||||
const { Plugin } = yield* Effect.promise(() => import("../plugin.js"))
|
||||
const plugins = yield* Plugin.Service
|
||||
const target = yield* Worktree.Service
|
||||
yield* plugins.awaitActivation
|
||||
return yield* run(target)
|
||||
}).pipe(Effect.provide(locations.get(ref)))
|
||||
}
|
||||
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
|
||||
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<Requirements | Scope.Scope>
|
||||
@@ -232,6 +236,7 @@ const post = [
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
+149
-148
@@ -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<SourceDirectoryNotFoundError>()(
|
||||
@@ -79,9 +70,9 @@ export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavail
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export class DuplicateStrategyError extends Schema.TaggedError<DuplicateStrategyError>()(
|
||||
"Worktree.DuplicateStrategyError",
|
||||
{ strategy: StrategyID },
|
||||
export class UnsupportedLocationError extends Schema.TaggedError<UnsupportedLocationError>()(
|
||||
"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<Info, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (input: {
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
}) => Effect.Effect<Info, unknown>
|
||||
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly ListEntry[], unknown>
|
||||
}
|
||||
|
||||
export const Event = Worktree.Event
|
||||
|
||||
interface StoredInput {
|
||||
readonly projectID: ProjectSchema.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly strategy?: string
|
||||
readonly replace?: boolean
|
||||
}
|
||||
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
|
||||
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<List>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info, Error>
|
||||
export interface Editor {
|
||||
readonly add: (strategy: Strategy) => void
|
||||
readonly configure: (settings: { readonly directory: AbsolutePath }) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
readonly list: () => Effect.Effect<List, Error>
|
||||
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
|
||||
readonly refresh: () => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<StrategyID, Strategy>([[gitStrategy.id, gitStrategy]]),
|
||||
selected: gitStrategy.id,
|
||||
}),
|
||||
editor: (value): Editor => ({
|
||||
configure: (settings) => {
|
||||
value.directory = settings.directory
|
||||
},
|
||||
add: (strategy) => {
|
||||
value.strategies.delete(strategy.id)
|
||||
value.strategies.set(strategy.id, strategy)
|
||||
value.selected = strategy.id
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const 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<StrategyID, Strategy>()
|
||||
|
||||
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<StrategyID, Strategy>) {
|
||||
const found = strategies.get(id)
|
||||
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
|
||||
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<AbsolutePath, StoredInput>()
|
||||
// A location's plugin instances only discover its own checkout, not sibling clones.
|
||||
if (checked.some((item) => item.directory === location.project.directory && item.exists)) {
|
||||
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)}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<AbortSignal>()
|
||||
const state = State.create({
|
||||
initial: () => new Map<string, WorktreeDefinition>(),
|
||||
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<never>((_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)
|
||||
}),
|
||||
)
|
||||
@@ -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],
|
||||
|
||||
@@ -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<Fixture, Effect.Success<ReturnType<typeof makeFixture>>>()("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<void>()
|
||||
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")
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<R = Scope.Scope> {
|
||||
|
||||
@@ -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<WorktreeResult, unknown>
|
||||
readonly remove: (input: WorktreeRemoveInput) => Effect.Effect<void, unknown>
|
||||
readonly list: (sourceDirectory: string) => Effect.Effect<readonly WorktreeEntry[], unknown>
|
||||
}
|
||||
|
||||
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<unknown> {
|
||||
readonly transform: Transform<WorktreeEditor>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -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<Scope.Scope>()
|
||||
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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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> | void
|
||||
|
||||
@@ -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<WorktreeResult>
|
||||
readonly remove: (input: WorktreeRemoveInput, context: { readonly signal: AbortSignal }) => Promise<void>
|
||||
readonly list: (
|
||||
sourceDirectory: string,
|
||||
context: { readonly signal: AbortSignal },
|
||||
) => Promise<readonly WorktreeEntry[]>
|
||||
}
|
||||
|
||||
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<WorktreeEditor>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
+209
-56
@@ -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",
|
||||
|
||||
@@ -54,6 +54,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof PtyGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof WorktreeGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>("WorktreeError")(
|
||||
{
|
||||
@@ -16,61 +16,69 @@ export class WorktreeError extends Schema.Error<WorktreeError>("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." }))
|
||||
|
||||
@@ -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<Info>("Config.Info")({
|
||||
$schema: optional(Schema.String).annotate({
|
||||
@@ -98,6 +99,9 @@ export class Info extends Schema.Class<Info>("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)",
|
||||
}),
|
||||
|
||||
@@ -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<typeof Info> {}
|
||||
@@ -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/<first six project ID characters>.",
|
||||
"Parent directory for the new worktree. Uses the location's configuration, then defaults to the server's data directory under worktree/<first six project ID characters>.",
|
||||
}),
|
||||
name: optional(Schema.String),
|
||||
}).annotate({ identifier: "Worktree.CreateInput" })
|
||||
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
|
||||
|
||||
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<typeof Directory> {}
|
||||
|
||||
export const ListInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
}).annotate({ identifier: "Worktree.ListInput" })
|
||||
export interface ListInput extends Schema.Schema.Type<typeof ListInput> {}
|
||||
export const ListEntry = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
type: Schema.Literals(["root", "worktree"]),
|
||||
}).annotate({ identifier: "Worktree.ListEntry" })
|
||||
export interface ListEntry extends Schema.Schema.Type<typeof ListEntry> {}
|
||||
|
||||
export class OperationError extends Schema.TaggedError<OperationError>()("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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -184,7 +184,6 @@ describe("contract hygiene", () => {
|
||||
Model.Variant,
|
||||
Project.Current,
|
||||
Worktree.Directory,
|
||||
Worktree.ListInput,
|
||||
Worktree.List,
|
||||
Project.Icon,
|
||||
Project.Commands,
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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<A>(action: (service: Worktree.Interface) => Effect.Effect<A, Worktree.Error>) {
|
||||
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<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
|
||||
return effect.pipe(
|
||||
Effect.mapError(
|
||||
@@ -35,7 +35,10 @@ function badRequest<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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<string>()
|
||||
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<ReadonlyArray<ProjectDirectory> | undefined> => {
|
||||
() => (props.fixture || props.initialRemoving ? undefined : worktreeLocation()),
|
||||
async (location, info): Promise<ReadonlyArray<ProjectDirectory> | 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(
|
||||
|
||||
@@ -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(() => (
|
||||
<DialogMoveSession
|
||||
projectID={projectID}
|
||||
location={session?.location ?? homeLocation()}
|
||||
current={
|
||||
destination() ??
|
||||
(session
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { InputRenderable } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { usePromptMove } from "../../../src/component/prompt/move"
|
||||
@@ -29,7 +28,7 @@ test.each([
|
||||
{ name: "an uncached session in a linked worktree", directory: linked, worktree: linked },
|
||||
{ name: "a session in a linked worktree subdirectory", directory: `${linked}/packages/tui`, worktree: linked },
|
||||
{ name: "the home/default location", directory: `${clone}/packages/tui`, home: true },
|
||||
])("creates from the clone's main worktree for $name", async (input) => {
|
||||
])("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() {
|
||||
|
||||
@@ -112,12 +112,12 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
})
|
||||
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
|
||||
if (url.pathname === "/api/project") return json([])
|
||||
if (url.pathname === "/api/worktree/proj_test") {
|
||||
if (url.pathname === "/api/worktree") {
|
||||
if (request.method === "GET") return json([{ directory: worktree }])
|
||||
if (request.method === "POST") return json({ directory: `${worktree}/created` })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/worktree/proj_test/refresh") return new Response(null, { status: 204 })
|
||||
if (url.pathname === "/api/worktree/refresh") return new Response(null, { status: 204 })
|
||||
if (url.pathname === "/api/shell")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
|
||||
+209
-56
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -950,6 +950,112 @@ interface VcsEditor {
|
||||
}
|
||||
```
|
||||
|
||||
### Worktrees
|
||||
|
||||
Register a local worktree strategy using the normal plugin lifecycle. The implementation module in this example owns
|
||||
option validation and the backend's `create`, `remove`, and `list` operations.
|
||||
|
||||
```ts title="plugins/worktrees/index.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
import { makeStrategy } from "./strategy"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "company.worktrees",
|
||||
async setup(ctx) {
|
||||
const strategy = makeStrategy(ctx.options)
|
||||
await ctx.worktree.transform((editor) => {
|
||||
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<void>
|
||||
list(
|
||||
sourceDirectory: string,
|
||||
context: { signal: AbortSignal },
|
||||
): Promise<readonly { directory: string; type: "root" | "worktree" }[]>
|
||||
}
|
||||
|
||||
interface WorktreeEditor {
|
||||
add(definition: WorktreeDefinition): void
|
||||
}
|
||||
|
||||
interface WorktreeDomain extends WorktreeApi {
|
||||
transform(callback: (editor: WorktreeEditor) => void): Promise<Registration>
|
||||
reload(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -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/<first-six-project-ID-characters>`.
|
||||
Configuration applies to the caller's location, not every clone sharing a project ID. Changing it does not move existing worktrees.
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user