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