Compare commits

...

3 Commits

Author SHA1 Message Date
James Long b79e75e01a refactor(tui): migrate worktree workflows 2026-08-12 21:57:30 +00:00
James Long eb75637742 refactor(server): expose global worktree api 2026-08-12 21:57:21 +00:00
James Long 2bba2915db refactor(core): consolidate worktree domain 2026-08-12 21:57:11 +00:00
66 changed files with 5863 additions and 2522 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ import { Permission } from "@opencode-ai/schema/permission"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Project } from "@opencode-ai/schema/project"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
import { Worktree } from "@opencode-ai/schema/worktree"
import { AgentAttachment, FileAttachment, Prompt, PromptMention } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Provider } from "@opencode-ai/schema/provider"
@@ -60,7 +60,7 @@ const effectTypeReferences = [
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
...namespaceTypes("Worktree", "@opencode-ai/schema/worktree", Worktree),
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
+21 -29
View File
@@ -34,7 +34,7 @@ import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import type { Pty } from "@opencode-ai/schema/pty"
import type { Question } from "@opencode-ai/schema/question"
import type { Reference } from "@opencode-ai/schema/reference"
import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
import type { Worktree } from "@opencode-ai/schema/worktree"
import type { Vcs } from "@opencode-ai/schema/vcs"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { WebSearch } from "@opencode-ai/schema/websearch"
@@ -1230,17 +1230,9 @@ export type Endpoint13_1Input = {
export type Endpoint13_1Output = Project.Current
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
export type Endpoint13_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint13_2Output = Project.Directories
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly current: ProjectCurrentOperation<E>
readonly directories: ProjectDirectoriesOperation<E>
}
export type Endpoint14_0Input = {
@@ -1549,36 +1541,36 @@ export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type Endpoint24_0Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy: ProjectCopy.StrategyID
readonly directory: AbsolutePath
readonly name?: string | undefined
}
export type Endpoint24_0Output = ProjectCopy.Copy
export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint24_0Input = { readonly projectID: Project.ID }
export type Endpoint24_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint24_1Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy: Worktree.StrategyID
readonly directory: AbsolutePath
readonly force: boolean
readonly name?: string | undefined
}
export type Endpoint24_1Output = void
export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint24_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint24_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly directory: AbsolutePath
readonly force: boolean
}
export type Endpoint24_2Output = void
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export interface ProjectCopyApi<E = never> {
readonly create: ProjectCopyCreateOperation<E>
readonly remove: ProjectCopyRemoveOperation<E>
readonly refresh: ProjectCopyRefreshOperation<E>
export type Endpoint24_3Input = { readonly projectID: Project.ID }
export type Endpoint24_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
export interface WorktreeApi<E = never> {
readonly list: WorktreeListOperation<E>
readonly create: WorktreeCreateOperation<E>
readonly remove: WorktreeRemoveOperation<E>
readonly refresh: WorktreeRefreshOperation<E>
}
export type Endpoint25_0Input = {
@@ -1691,7 +1683,7 @@ export interface AppApi<E = never> {
readonly shell: ShellApi<E>
readonly question: QuestionApi<E>
readonly reference: ReferenceApi<E>
readonly projectCopy: ProjectCopyApi<E>
readonly worktree: WorktreeApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
+22 -33
View File
@@ -139,8 +139,6 @@ import type {
Endpoint13_0Output,
Endpoint13_1Input,
Endpoint13_1Output,
Endpoint13_2Input,
Endpoint13_2Output,
Endpoint14_0Input,
Endpoint14_0Output,
Endpoint14_1Input,
@@ -216,6 +214,8 @@ import type {
Endpoint24_1Output,
Endpoint24_2Input,
Endpoint24_2Output,
Endpoint24_3Input,
Endpoint24_3Output,
Endpoint25_0Input,
Endpoint25_0Output,
Endpoint25_1Input,
@@ -876,19 +876,7 @@ const Endpoint13_1 = (raw: RawClient["server.project"]) => (input?: Endpoint13_1
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint13_2 = (raw: RawClient["server.project"]) => (input: Endpoint13_2Input) =>
preserveEffect<Endpoint13_2Output>()(
raw["project.directories"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup13 = (raw: RawClient["server.project"]) => ({
list: Endpoint13_0(raw),
current: Endpoint13_1(raw),
directories: Endpoint13_2(raw),
})
const adaptGroup13 = (raw: RawClient["server.project"]) => ({ list: Endpoint13_0(raw), current: Endpoint13_1(raw) })
const Endpoint14_0 = (raw: RawClient["server.form"]) => (input?: Endpoint14_0Input) =>
preserveEffect<Endpoint14_0Output>()(
@@ -1212,36 +1200,37 @@ const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
const Endpoint24_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_0Input) =>
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
raw["projectCopy.create"]({
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["projectCopy.remove"]({
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["worktree.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
preserveEffect<Endpoint24_3Output>()(
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint24_0(raw),
remove: Endpoint24_1(raw),
refresh: Endpoint24_2(raw),
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint24_0(raw),
create: Endpoint24_1(raw),
remove: Endpoint24_2(raw),
refresh: Endpoint24_3(raw),
})
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
@@ -1334,7 +1323,7 @@ const adaptClient = (raw: RawClient) => ({
shell: adaptGroup21(raw["server.shell"]),
question: adaptGroup22(raw["server.question"]),
reference: adaptGroup23(raw["server.reference"]),
projectCopy: adaptGroup24(raw["server.projectCopy"]),
worktree: adaptGroup24(raw["server.worktree"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
migration: adaptGroup27(raw["server.migration"]),
+1 -1
View File
@@ -33,7 +33,7 @@ export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
+29 -33
View File
@@ -133,8 +133,6 @@ import type {
ProjectListOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectDirectoriesInput,
ProjectDirectoriesOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
@@ -206,12 +204,14 @@ import type {
QuestionRejectOutput,
ReferenceListInput,
ReferenceListOutput,
ProjectCopyCreateInput,
ProjectCopyCreateOutput,
ProjectCopyRemoveInput,
ProjectCopyRemoveOutput,
ProjectCopyRefreshInput,
ProjectCopyRefreshOutput,
WorktreeListInput,
WorktreeListOutput,
WorktreeCreateInput,
WorktreeCreateOutput,
WorktreeRemoveInput,
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
@@ -1255,18 +1255,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
directories: (input: ProjectDirectoriesInput, requestOptions?: RequestOptions) =>
request<ProjectDirectoriesOutput>(
{
method: "GET",
path: `/api/project/${encodeURIComponent(input.projectID)}/directories`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
form: {
request: {
@@ -1736,13 +1724,23 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
projectCopy: {
create: (input: ProjectCopyCreateInput, requestOptions?: RequestOptions) =>
request<ProjectCopyCreateOutput>(
worktree: {
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
request<WorktreeListOutput>(
{
method: "GET",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
request<WorktreeCreateOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
successStatus: 200,
declaredStatuses: [400, 401],
@@ -1750,12 +1748,11 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
remove: (input: ProjectCopyRemoveInput, requestOptions?: RequestOptions) =>
request<ProjectCopyRemoveOutput>(
remove: (input: WorktreeRemoveInput, requestOptions?: RequestOptions) =>
request<WorktreeRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -1763,12 +1760,11 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
refresh: (input: ProjectCopyRefreshInput, requestOptions?: RequestOptions) =>
request<ProjectCopyRefreshOutput>(
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
request<WorktreeRefreshOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input["location"] },
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree/refresh`,
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+25 -41
View File
@@ -291,8 +291,6 @@ export type ProjectTime = { created: number; updated: number; initialized?: numb
export type ProjectCurrent = { id: string; directory: string; canonical: string }
export type ProjectDirectory = { directory: string; strategy?: string }
export type FormMetadata = { [x: string]: JsonValue }
export type FormValue = string | number | boolean | Array<string>
@@ -370,7 +368,9 @@ export type ReferenceGitSource = {
hidden?: boolean
}
export type ProjectCopyCopy = { directory: string }
export type WorktreeDirectory = { directory: string; strategy?: string }
export type WorktreeInfo = { directory: string }
export type VcsBranch = { current?: string; default?: string }
@@ -828,20 +828,20 @@ export type PluginUpdated = {
data: {}
}
export type ProjectDirectoriesUpdated = {
export type WorktreeUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.directories.updated"
type: "worktree.updated"
location?: LocationRef
data: { projectID: string }
}
export type ProjectDirectoryResolved = {
export type WorktreeResolved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.directory.resolved"
type: "worktree.resolved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { projectID: string; directory: string; previous: string }
@@ -1369,8 +1369,6 @@ export type Project = {
sandboxes: Array<string>
}
export type ProjectDirectories = Array<ProjectDirectory>
export type FormAnswer = { [x: string]: FormValue }
export type PermissionRequest = {
@@ -1520,6 +1518,8 @@ export type SessionStatus2 = {
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
export type WorktreeList = Array<WorktreeDirectory>
export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule>
@@ -2102,8 +2102,8 @@ export type V2Event =
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectDirectoriesUpdated
| ProjectDirectoryResolved
| WorktreeUpdated
| WorktreeResolved
| CommandUpdated
| ConfigUpdated
| SkillUpdated
@@ -2304,12 +2304,12 @@ export type QuestionNotFoundError = {
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
export type ProjectCopyError = {
readonly name: "ProjectCopyError"
export type WorktreeError = {
readonly name: "WorktreeError"
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
}
export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
export type HealthGetOutput = ServiceHealth
@@ -4348,15 +4348,6 @@ export type ProjectCurrentInput = {
export type ProjectCurrentOutput = ProjectCurrent
export type ProjectDirectoriesInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectDirectoriesOutput = ProjectDirectories
export type FormRequestListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -5642,37 +5633,30 @@ export type ReferenceListOutput = {
data: Array<ReferenceInfo>
}
export type ProjectCopyCreateInput = {
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeListOutput = WorktreeList
export type WorktreeCreateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
}
export type ProjectCopyCreateOutput = ProjectCopyCopy
export type WorktreeCreateOutput = WorktreeInfo
export type ProjectCopyRemoveInput = {
export type WorktreeRemoveInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly directory: string; readonly force: boolean }["force"]
}
export type ProjectCopyRemoveOutput = void
export type WorktreeRemoveOutput = void
export type ProjectCopyRefreshInput = {
readonly projectID: { readonly projectID: string }["projectID"]
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 ProjectCopyRefreshOutput = void
export type WorktreeRefreshOutput = void
export type VcsGetInput = {
readonly location?: {
+33 -18
View File
@@ -29,7 +29,7 @@ test("exposes every standard HTTP API group", () => {
"shell",
"question",
"reference",
"projectCopy",
"worktree",
"vcs",
"debug",
"migration",
@@ -49,7 +49,8 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
expect(Object.keys(client.project)).toEqual(["list", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
test("config.get returns ordered config entries for a location", async () => {
@@ -251,30 +252,44 @@ test("file.read returns binary content from the public HTTP contract", async ()
)
})
test("project methods use the public HTTP contract", async () => {
const requests: string[] = []
test("worktree methods use the global project contract", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
requests.push(url)
if (url.includes("/directories")) return Response.json([])
return Response.json({ id: "proj_test", directory: "/tmp/project" })
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
if (request.method === "GET") return Response.json([{ directory: "/tmp/project" }])
if (request.method === "POST" && !request.url.endsWith("/refresh"))
return Response.json({ directory: "/tmp/worktrees/api" })
return new Response(null, { status: 204 })
},
})
const current = await client.project.current({ location: { workspace: "wrk_test" } })
const directories = await client.project.directories({
projectID: current.id,
location: { directory: current.directory },
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
expect(
await client.worktree.create({
projectID: "proj_test",
strategy: "git",
directory: "/tmp/worktrees",
name: "api",
}),
).toEqual({ directory: "/tmp/worktrees/api" })
await client.worktree.remove({
projectID: "proj_test",
directory: "/tmp/worktrees/api",
force: false,
})
await client.worktree.refresh({ projectID: "proj_test" })
expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" })
expect(directories).toEqual([])
expect(requests).toEqual([
"http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test",
"http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject",
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["DELETE", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree/refresh"],
])
expect(await requests[1]?.json()).toEqual({ strategy: "git", directory: "/tmp/worktrees", name: "api" })
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
})
test("shell list and remove use the public HTTP contract", async () => {
+191 -41
View File
@@ -1,8 +1,10 @@
{
"version": "7",
"dialect": "sqlite",
"id": "5c1aa56b-c3ee-4283-9a84-c0bf626dc604",
"prevIds": ["00924d88-1842-4d71-ac74-5682ddc47e1c"],
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
"prevIds": [
"5c1aa56b-c3ee-4283-9a84-c0bf626dc604"
],
"ddl": [
{
"name": "account_state",
@@ -76,6 +78,10 @@
"name": "workspace",
"entityType": "tables"
},
{
"name": "worktree",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
@@ -1437,9 +1443,53 @@
"table": "workspace"
},
{
"columns": ["active_account_id"],
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "worktree"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "worktree"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "strategy",
"entityType": "columns",
"table": "worktree"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "worktree"
},
{
"columns": [
"active_account_id"
],
"tableTo": "account",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@@ -1448,9 +1498,13 @@
"table": "account_state"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"tableTo": "event_sequence",
"columnsTo": ["aggregate_id"],
"columnsTo": [
"aggregate_id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1459,9 +1513,13 @@
"table": "event"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1470,9 +1528,13 @@
"table": "permission"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1481,9 +1543,13 @@
"table": "project_directory"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1492,9 +1558,13 @@
"table": "instruction_entry"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1503,9 +1573,13 @@
"table": "instruction_state"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1514,9 +1588,13 @@
"table": "session_inbox"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1525,9 +1603,13 @@
"table": "session_message"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1536,9 +1618,13 @@
"table": "session_pending"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1547,126 +1633,190 @@
"table": "session_v2"
},
{
"columns": ["email", "url"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_worktree_project_id_project_id_fk",
"entityType": "fks",
"table": "worktree"
},
{
"columns": [
"email",
"url"
],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": ["project_id", "directory"],
"columns": [
"project_id",
"directory"
],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": ["session_id", "key"],
"columns": [
"session_id",
"key"
],
"nameExplicit": false,
"name": "instruction_entry_pk",
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": ["id"],
"columns": [
"project_id",
"directory"
],
"nameExplicit": false,
"name": "worktree_pk",
"entityType": "pks",
"table": "worktree"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": ["key"],
"columns": [
"key"
],
"nameExplicit": false,
"name": "kv_pk",
"table": "kv",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["hash"],
"columns": [
"hash"
],
"nameExplicit": false,
"name": "instruction_blob_pk",
"table": "instruction_blob",
"entityType": "pks"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "instruction_state_pk",
"table": "instruction_state",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_inbox_pk",
"table": "session_inbox",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_pending_pk",
"table": "session_pending",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_v2_pk",
"table": "session_v2",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
@@ -1962,4 +2112,4 @@
}
],
"renames": []
}
}
+2
View File
@@ -42,6 +42,7 @@ import m39 from "./migration/20260805200742_import_legacy_credentials.js"
import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
export const migrations = [
m00,
@@ -87,4 +88,5 @@ export const migrations = [
m40,
m41,
m42,
m43,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,35 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260812213948_worktree",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`worktree\` (
\`project_id\` text NOT NULL,
\`directory\` text NOT NULL,
\`strategy\` text,
\`time_created\` integer NOT NULL,
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
INSERT INTO \`worktree\` (\`project_id\`, \`directory\`, \`strategy\`, \`time_created\`)
SELECT
\`project_id\`,
\`directory\`,
CASE
WHEN \`strategy\` = 'git_worktree' THEN 'git'
WHEN \`strategy\` IS NOT NULL THEN \`strategy\`
WHEN \`type\` = 'git_worktree' THEN 'git'
END,
\`time_created\`
FROM \`project_directory\`;
`)
})
},
}
export default migration
+10
View File
@@ -225,6 +225,16 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`last_used_at\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`worktree\` (
\`project_id\` text NOT NULL,
\`directory\` text NOT NULL,
\`strategy\` text,
\`time_created\` integer NOT NULL,
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
+2 -3
View File
@@ -25,7 +25,7 @@ import { MCP } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { ProjectCopy } from "./project/copy.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Question } from "./question.js"
import { Shell } from "./shell.js"
@@ -66,8 +66,7 @@ const locationServiceNodes = [
AISDK.node,
Plugin.node,
PluginSupervisor.node,
ProjectCopy.node,
ProjectCopy.refreshNode,
Worktree.refreshNode,
FileSystemSearch.node,
FileSystem.node,
Pty.node,
+26 -25
View File
@@ -2,20 +2,20 @@ export * as Project from "./project.js"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { asc, desc } from "drizzle-orm"
import { and, asc, desc, eq } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { Event } from "@opencode-ai/schema/project-directories"
import { Worktree } from "@opencode-ai/schema/worktree"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Hash } from "@opencode-ai/util/hash"
import { ProjectDirectories } from "./project/directories.js"
import { ProjectSchema } from "./project/schema.js"
import { ProjectTable, upsertProject } from "./project/sql.js"
import { WorktreeTable } from "./worktree/sql.js"
export const ID = ProjectSchema.ID
export type ID = ProjectSchema.ID
@@ -26,18 +26,9 @@ export type Vcs = ProjectSchema.Vcs
export const Current = ProjectSchema.Current
export type Current = ProjectSchema.Current
export const Directory = ProjectSchema.Directory
export type Directory = ProjectSchema.Directory
export const Info = ProjectSchema.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const DirectoriesInput = ProjectSchema.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = ProjectSchema.Directories
export type Directories = typeof Directories.Type
export interface Resolved {
readonly previous?: ID
readonly id: ID
@@ -56,7 +47,6 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
}
@@ -95,18 +85,19 @@ const layer = Layer.effect(
const proc = yield* AppProcess.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const projectDirectories = yield* ProjectDirectories.Service
const announcing = new Set<string>()
const persist = Effect.fnUntraced(function* (project: Resolved) {
yield* upsertProject(db, project).pipe(Effect.orDie)
if (!project.vcs) return project
const directories: ProjectDirectories.CreateInput[] = [{ projectID: project.id, directory: project.canonical }]
const directories: Array<{ projectID: ID; directory: AbsolutePath; strategy?: string }> = [
{ projectID: project.id, directory: project.canonical },
]
if (project.directory !== project.canonical)
directories.push({
projectID: project.id,
directory: project.directory,
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
strategy: project.vcs.type === "git" ? "git" : undefined,
})
// A missing directory row means this directory's resolution is a new durable
// fact (copy.ts registers copy directories directly; those never strand
@@ -119,11 +110,25 @@ const layer = Layer.effect(
if (announcing.has(key)) continue
announcing.add(key)
yield* Effect.gen(function* () {
if (yield* projectDirectories.get({ projectID: item.projectID, directory: item.directory })) return
const stored = yield* db
.select({ directory: WorktreeTable.directory })
.from(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, item.projectID), eq(WorktreeTable.directory, item.directory)))
.get()
.pipe(Effect.orDie)
if (stored) return
yield* bus.publish(
Event.Resolved,
Worktree.Event.Resolved,
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
{ commit: () => Effect.asVoid(projectDirectories.create(item)) },
{
commit: () =>
db
.insert(WorktreeTable)
.values({ project_id: item.projectID, directory: item.directory, strategy: item.strategy })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie, Effect.asVoid),
},
)
}).pipe(Effect.ensuring(Effect.sync(() => announcing.delete(key))))
}
@@ -140,10 +145,6 @@ const layer = Layer.effect(
return rows.map(fromRow)
})
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
return yield* projectDirectories.list(input.projectID)
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@@ -257,12 +258,12 @@ const layer = Layer.effect(
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
return Service.of({ list, directories, resolve })
return Service.of({ list, resolve })
}),
)
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
})
@@ -1,35 +0,0 @@
import { Effect } from "effect"
import { AbsolutePath } from "../schema.js"
import { Git } from "../git.js"
import { DirectoryUnavailableError, StrategyID, type ListEntry, type Strategy } from "./copy.js"
export function makeGitWorktreeStrategy(input: {
git: Git.Interface
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
}) {
return {
id: StrategyID.make("git_worktree"),
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
const repository = yield* input.git.repo.discover(options.sourceDirectory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory })
yield* input.git.worktree.create({ repository, directory: options.directory })
return { directory: yield* input.canonical(options.directory) }
}),
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
const found = yield* input.git.repo.discover(options.directory)
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force })
}),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
const found = yield* input.git.repo.discover(directory)
if (!found) return yield* new DirectoryUnavailableError({ directory })
const entries = yield* input.git.worktree.list(found)
return yield* Effect.forEach(entries, (entry) =>
input.canonical(entry.directory).pipe(
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const),
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
}),
} satisfies Strategy
}
-272
View File
@@ -1,272 +0,0 @@
export * as ProjectCopy from "./copy.js"
import { Context, Effect, Layer, Schema } from "effect"
import path from "path"
import { AbsolutePath } from "../schema.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Project } from "../project.js"
import { ProjectDirectories } from "./directories.js"
import { makeGitWorktreeStrategy } from "./copy-strategies.js"
import { Slug } from "../util/slug.js"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Location } from "../location.js"
import { Event } from "@opencode-ai/schema/project-directories"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
export const StrategyID = ProjectCopy.StrategyID
export type StrategyID = typeof StrategyID.Type
export const CreateInput = ProjectCopy.CreateInput
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = ProjectCopy.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
projectID: Project.ID,
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
export type RefreshInput = typeof RefreshInput.Type
export const RefreshResult = Schema.Struct({
updated: Schema.Array(AbsolutePath),
removed: Schema.Array(AbsolutePath),
}).annotate({ identifier: "ProjectCopy.RefreshResult" })
export type RefreshResult = typeof RefreshResult.Type
export const Copy = ProjectCopy.Copy
export type Copy = typeof Copy.Type
export const ListEntry = Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["root", "copy"]),
}).annotate({ identifier: "ProjectCopy.ListEntry" })
export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
"ProjectCopy.SourceDirectoryNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
"ProjectCopy.DestinationExistsError",
{ directory: AbsolutePath },
) {}
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
"ProjectCopy.DirectoryUnavailableError",
{ directory: AbsolutePath },
) {}
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
"ProjectCopy.InvalidDirectoryError",
{ directory: AbsolutePath },
) {}
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
"ProjectCopy.StrategyUnavailableError",
{ strategy: StrategyID },
) {}
export type Error =
| SourceDirectoryNotFoundError
| DestinationExistsError
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| Git.WorktreeError
export interface Strategy {
readonly id: StrategyID
readonly create: (input: {
sourceDirectory: AbsolutePath
directory: AbsolutePath
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (input: {
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
}
export { Event }
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
export const refreshAfterBoot = Effect.gen(function* () {
const location = yield* Location.Service
const copies = yield* Service
yield* Effect.gen(function* () {
yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
const result = yield* copies.refresh({ projectID: location.project.id })
yield* Effect.logInfo("project copy refresh done", {
projectID: location.project.id,
updated: result.updated,
removed: result.removed,
})
}).pipe(
Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })),
Effect.forkScoped,
Effect.asVoid,
)
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const directories = yield* ProjectDirectories.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
})
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
const resolved = AbsolutePath.make(yield* fs.resolve(input))
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
return resolved
})
const strategy = makeGitWorktreeStrategy({ git, canonical })
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
const sourceDirectory = yield* canonical(input)
if ((yield* directories.get({ projectID, directory: sourceDirectory })) === undefined)
return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
return sourceDirectory
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
if (id !== strategy.id) return yield* new StrategyUnavailableError({ strategy: id })
return strategy
})
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
const selected = yield* getStrategy(input.strategy)
const sourceDirectory = yield* source(input.sourceDirectory, input.projectID)
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
let copyDirectory = AbsolutePath.make(path.join(input.directory, name))
while (yield* fs.existsSafe(copyDirectory)) {
suffix++
if (suffix > 10) return yield* new DestinationExistsError({ directory: copyDirectory })
copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
}
const result = yield* selected.create({
directory: copyDirectory,
sourceDirectory,
})
yield* changed(
input.projectID,
yield* directories.create({
projectID: input.projectID,
directory: result.directory,
strategy: input.strategy,
behavior: "replace",
}),
)
return result
})
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
const copyDirectory = yield* canonical(input.directory)
const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
yield* strategy.remove({
directory: copyDirectory,
force: input.force,
})
yield* changed(
input.projectID,
yield* directories.remove({ projectID: input.projectID, directory: copyDirectory }),
)
})
const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
const stored = yield* directories.list(input.projectID)
const checked = yield* Effect.forEach(
stored,
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
{ concurrency: "unbounded" },
)
const sourceDirectories = checked
.filter((item) => item.strategy === undefined && item.exists)
.map((item) => item.directory)
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "copy" ? strategy.id : undefined,
})),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((sets) => new Map(sets.flat().map((item) => [item.directory, item] as const)).values().toArray()),
)
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const result = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.forEach(discovered, (item) =>
directories.create(
{
projectID: input.projectID,
directory: item.directory,
strategy: item.strategy,
behavior: "replace",
},
tx,
),
),
removed: Effect.forEach(removed, (directory) =>
directories.remove({ projectID: input.projectID, directory }, tx),
),
}),
)
.pipe(Effect.orDie)
const changes = {
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
removed: removed.filter((_, index) => result.removed[index]),
}
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
return Service.of({
create,
remove,
refresh,
})
}),
)
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, ProjectDirectories.node, Bus.node, Database.node],
})
export const refreshNode = makeLocationNode({
name: "project-copy-refresh",
layer: Layer.effectDiscard(refreshAfterBoot),
deps: [node, Location.node],
})
-128
View File
@@ -1,128 +0,0 @@
export * as ProjectDirectories from "./directories.js"
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath } from "../schema.js"
import { ProjectSchema } from "./schema.js"
import { ProjectDirectoryTable } from "./sql.js"
import type { EffectDrizzleSqlite } from "../database/drizzle.js"
import type { Project } from "../project.js"
export type Directory = Project.Directory
export const CreateInput = Schema.Struct({
projectID: ProjectSchema.ID,
directory: AbsolutePath,
strategy: Schema.optional(Schema.String),
behavior: Schema.Literals(["ignore", "replace"]).pipe(Schema.optional),
})
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Schema.Struct({
projectID: ProjectSchema.ID,
directory: AbsolutePath,
})
export type RemoveInput = typeof RemoveInput.Type
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export const ListInput = ProjectSchema.DirectoriesInput
export type ListInput = typeof ListInput.Type
export const ListOutput = ProjectSchema.Directories
export type ListOutput = typeof ListOutput.Type
export interface Interface {
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<ReadonlyArray<Directory>>
readonly get: (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) => Effect.Effect<Directory | undefined>
readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectDirectories") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const create = Effect.fn("ProjectDirectories.create")(function* (input: CreateInput, tx?: Transaction) {
const insert = (tx ?? db)
.insert(ProjectDirectoryTable)
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
const query =
input.behavior === "replace"
? insert.onConflictDoUpdate({
target: [ProjectDirectoryTable.project_id, ProjectDirectoryTable.directory],
set: { strategy: input.strategy ?? null },
setWhere: input.strategy
? or(isNull(ProjectDirectoryTable.strategy), ne(ProjectDirectoryTable.strategy, input.strategy))
: isNotNull(ProjectDirectoryTable.strategy),
})
: insert.onConflictDoNothing()
return (
(yield* query.returning({ directory: ProjectDirectoryTable.directory }).get().pipe(Effect.orDie)) !== undefined
)
})
const remove = Effect.fn("ProjectDirectories.remove")(function* (input: RemoveInput, tx?: Transaction) {
return (
(yield* (tx ?? db)
.delete(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
eq(ProjectDirectoryTable.directory, input.directory),
),
)
.returning({ directory: ProjectDirectoryTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
})
const list = Effect.fn("ProjectDirectories.list")(function* (projectID: ProjectSchema.ID) {
const rows = yield* db
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, projectID))
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
})
const get = Effect.fn("ProjectDirectories.get")(function* (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) {
const row = yield* db
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
.from(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
eq(ProjectDirectoryTable.directory, input.directory),
),
)
.get()
.pipe(Effect.orDie)
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
})
return Service.of({
list,
get,
create,
remove,
})
}),
)
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
-9
View File
@@ -10,18 +10,9 @@ export type ID = typeof ID.Type
export const Current = Project.Current
export type Current = typeof Current.Type
export const Directory = Project.Directory
export type Directory = typeof Directory.Type
export const Info = Project.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const DirectoriesInput = Project.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = Project.Directories
export type Directories = typeof Directories.Type
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
+1
View File
@@ -23,6 +23,7 @@ export const ProjectTable = sqliteTable("project", {
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})
/** @deprecated Use WorktreeTable from worktree/sql instead. */
export const ProjectDirectoryTable = sqliteTable(
"project_directory",
{
+2 -2
View File
@@ -18,7 +18,7 @@ import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
import { Slug } from "../util/slug.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Money } from "@opencode-ai/schema/money"
import { Event } from "@opencode-ai/schema/project-directories"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js"
@@ -442,7 +442,7 @@ const layer = Layer.effectDiscard(
// Sessions whose ownership came from the directory's previous resolution
// follow its new identity. Location, transcript, instructions, and recency
// are untouched: the session did not move, its directory got identified.
yield* bus.project(Event.Resolved, (event) =>
yield* bus.project(Worktree.Event.Resolved, (event) =>
Effect.gen(function* () {
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
if (stale.length === 0) return
+349
View File
@@ -0,0 +1,349 @@
export * as Worktree from "./worktree.js"
import { Context, Effect, Layer, Schema } from "effect"
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { ProjectSchema } from "./project/schema.js"
import { Slug } from "./util/slug.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { Location } from "./location.js"
import { Worktree } from "@opencode-ai/schema/worktree"
import { WorktreeTable } from "./worktree/sql.js"
import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
import { WorktreeGit } from "./worktree/git.js"
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
export { DirectoryUnavailableError } from "./worktree/directory.js"
export const StrategyID = Worktree.StrategyID
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Worktree.CreateInput
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Worktree.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
projectID: ProjectSchema.ID,
}).annotate({ identifier: "Worktree.RefreshInput" })
export type RefreshInput = typeof RefreshInput.Type
export const RefreshResult = Schema.Struct({
updated: Schema.Array(AbsolutePath),
removed: Schema.Array(AbsolutePath),
}).annotate({ identifier: "Worktree.RefreshResult" })
export type RefreshResult = typeof RefreshResult.Type
export const Info = Worktree.Info
export type Info = typeof Info.Type
export const ListInput = Worktree.ListInput
export type ListInput = typeof ListInput.Type
export const List = Worktree.List
export type List = typeof List.Type
export const ListEntry = Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["root", "worktree"]),
}).annotate({ identifier: "Worktree.ListEntry" })
export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
"Worktree.SourceDirectoryNotFoundError",
{ projectID: ProjectSchema.ID },
) {}
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
"Worktree.DestinationExistsError",
{ directory: AbsolutePath },
) {}
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
"Worktree.InvalidDirectoryError",
{ directory: AbsolutePath },
) {}
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
"Worktree.StrategyUnavailableError",
{ strategy: StrategyID },
) {}
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
"Worktree.DuplicateStrategyError",
{ strategy: StrategyID },
) {}
export type Error =
| SourceDirectoryNotFoundError
| DestinationExistsError
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| Git.WorktreeError
export interface Strategy {
readonly id: StrategyID
readonly create: (input: {
sourceDirectory: AbsolutePath
directory: AbsolutePath
}) => Effect.Effect<Info, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (input: {
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
}
export const Event = Worktree.Event
interface StoredInput {
readonly projectID: ProjectSchema.ID
readonly directory: AbsolutePath
readonly strategy?: string
}
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export interface Interface {
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<List>
readonly create: (input: CreateInput) => Effect.Effect<Info, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
export const refreshAfterBoot = Effect.gen(function* () {
const location = yield* Location.Service
const worktrees = yield* Service
yield* Effect.gen(function* () {
yield* Effect.logInfo("worktree refresh started", { projectID: location.project.id })
const result = yield* worktrees.refresh({ projectID: location.project.id })
yield* Effect.logInfo("worktree refresh done", {
projectID: location.project.id,
updated: result.updated,
removed: result.removed,
})
}).pipe(
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
Effect.forkScoped,
Effect.asVoid,
)
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
})
const ops = {
list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) {
const rows = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(eq(WorktreeTable.project_id, projectID))
.orderBy(desc(WorktreeTable.time_created), asc(WorktreeTable.directory))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
}),
find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) {
const row = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
.get()
.pipe(Effect.orDie)
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
}),
create: Effect.fnUntraced(function* (input: StoredInput, tx?: Transaction) {
return (
(yield* (tx ?? db)
.insert(WorktreeTable)
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
.onConflictDoUpdate({
target: [WorktreeTable.project_id, WorktreeTable.directory],
set: { strategy: input.strategy ?? null },
setWhere: input.strategy
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
: isNotNull(WorktreeTable.strategy),
})
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
}),
remove: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) {
return (
(yield* (tx ?? db)
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
}),
}
const registry = new Map<StrategyID, Strategy>()
const register = Effect.fn("Worktree.register")(function* (strategy: Strategy) {
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
registry.set(strategy.id, strategy)
})
// Register default strategies
const gitStrategy = yield* WorktreeGit.make
yield* register(gitStrategy).pipe(Effect.orDie)
const strategies = () => Array.from(registry.values())
const source = Effect.fnUntraced(function* (projectID: ProjectSchema.ID) {
const checked = yield* Effect.forEach(
yield* ops.list(projectID),
(item) =>
canonical(fs, item.directory).pipe(
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
{ concurrency: "unbounded" },
)
const found = checked.find((directory) => directory !== undefined)
if (!found) return yield* new SourceDirectoryNotFoundError({ projectID })
return found
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
const found = registry.get(id)
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
return found
})
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
const selected = yield* getStrategy(input.strategy)
const sourceDirectory = yield* source(input.projectID)
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
while (yield* fs.existsSafe(worktreeDirectory)) {
suffix++
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
}
const result = yield* selected.create({
directory: worktreeDirectory,
sourceDirectory,
})
yield* changed(
input.projectID,
yield* ops.create({
projectID: input.projectID,
directory: result.directory,
strategy: input.strategy,
}),
)
return result
})
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
const worktreeDirectory = yield* canonical(fs, input.directory)
const stored = yield* ops.find(input.projectID, worktreeDirectory)
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
directory: worktreeDirectory,
force: input.force,
})
yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory))
})
const refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) {
const stored = yield* ops.list(input.projectID)
const checked = yield* Effect.forEach(
stored,
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
{ concurrency: "unbounded" },
)
const sourceDirectories = checked
.filter((item) => item.strategy === undefined && item.exists)
.map((item) => item.directory)
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
Effect.forEach(strategies(), (strategy) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "worktree" ? strategy.id : undefined,
})),
),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
)
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const result = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.forEach(discovered, (item) =>
ops.create(
{
projectID: input.projectID,
directory: item.directory,
strategy: item.strategy,
},
tx,
),
),
removed: Effect.forEach(removed, (directory) => ops.remove(input.projectID, directory, tx)),
}),
)
.pipe(Effect.orDie)
const changes = {
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
removed: removed.filter((_, index) => result.removed[index]),
}
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
return Service.of({
register,
list: ops.list,
create,
remove,
refresh,
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node],
})
export const refreshNode = makeLocationNode({
name: "worktree-refresh",
layer: Layer.effectDiscard(refreshAfterBoot),
deps: [node, Location.node],
})
+16
View File
@@ -0,0 +1,16 @@
export * as WorktreeDirectory from "./directory.js"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AbsolutePath } from "../schema.js"
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
"Worktree.DirectoryUnavailableError",
{ directory: AbsolutePath },
) {}
export const canonical = Effect.fnUntraced(function* (fs: FSUtil.Interface, input: AbsolutePath) {
const resolved = AbsolutePath.make(yield* fs.resolve(input))
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
return resolved
})
+39
View File
@@ -0,0 +1,39 @@
export * as WorktreeGit from "./git.js"
import { Effect } from "effect"
import { Worktree } from "@opencode-ai/schema/worktree"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git.js"
import { canonical, DirectoryUnavailableError } from "./directory.js"
import type { ListEntry, Strategy } from "../worktree.js"
export const make = Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
return {
id: Worktree.StrategyID.make("git"),
create: Effect.fn("Worktree.Git.create")(function* (input) {
const repository = yield* git.repo.discover(input.sourceDirectory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.sourceDirectory })
yield* git.worktree.create({ repository, directory: input.directory })
return { directory: yield* canonical(fs, input.directory) }
}),
remove: Effect.fn("Worktree.Git.remove")(function* (input) {
const repository = yield* git.repo.discover(input.directory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.directory })
yield* git.worktree.remove({ repository, directory: input.directory, force: input.force })
}),
list: Effect.fn("Worktree.Git.list")(function* (directory) {
const repository = yield* git.repo.discover(directory)
if (!repository) return yield* new DirectoryUnavailableError({ directory })
const entries = yield* git.worktree.list(repository)
return yield* Effect.forEach(entries, (entry) =>
canonical(fs, entry.directory).pipe(
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "worktree" }) as const),
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
}),
} satisfies Strategy
})
+20
View File
@@ -0,0 +1,20 @@
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { absoluteColumn } from "../database/path.js"
import { ProjectSchema } from "../project/schema.js"
import { ProjectTable } from "../project/sql.js"
export const WorktreeTable = sqliteTable(
"worktree",
{
project_id: text()
.$type<ProjectSchema.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
directory: absoluteColumn().notNull(),
strategy: text(),
time_created: integer()
.notNull()
.$default(() => Date.now()),
},
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
)
@@ -12,6 +12,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -127,6 +128,31 @@ describe("DatabaseMigration", () => {
)
})
test("copies project directories into worktrees without removing the old table", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
yield* db.run(
sql`CREATE TABLE project_directory (project_id text NOT NULL, directory text NOT NULL, type text, strategy text, time_created integer NOT NULL, PRIMARY KEY (project_id, directory))`,
)
yield* db.run(
sql`INSERT INTO project_directory (project_id, directory, type, strategy, time_created) VALUES ('project', '/root', 'main', NULL, 1), ('project', '/legacy', 'git_worktree', NULL, 2), ('project', '/strategy', NULL, 'git_worktree', 3), ('project', '/custom', NULL, 'acme/snapshot', 4)`,
)
yield* DatabaseMigration.applyOnly(db, [worktreeMigration])
expect(yield* db.all(sql`SELECT directory, strategy FROM worktree ORDER BY directory`)).toEqual([
{ directory: "/custom", strategy: "acme/snapshot" },
{ directory: "/legacy", strategy: "git" },
{ directory: "/root", strategy: null },
{ directory: "/strategy", strategy: "git" },
])
expect(yield* db.get(sql`SELECT count(*) AS count FROM project_directory`)).toEqual({ count: 4 })
}),
)
})
test("imports legacy JSON credentials without changing the source file or existing credentials", async () => {
await using tmp = await tmpdir()
const source = path.join(tmp.path, "auth.json")
@@ -78,7 +78,6 @@ describe("node build", () => {
acquisitions++
return Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
}),
-1
View File
@@ -13,7 +13,6 @@ const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
@@ -1,69 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectDirectories.node])))
const projectID = Project.ID.make("project-directories")
const directory = AbsolutePath.make("/tmp/project-directories")
function setup() {
return Database.Service.use(({ db }) =>
db
.insert(ProjectTable)
.values({ id: projectID, worktree: directory, sandboxes: [], time_created: 1, time_updated: 1 })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie),
)
}
describe("ProjectDirectories", () => {
it.effect("decodes directory schemas", () =>
Effect.sync(() => {
expect(Schema.decodeUnknownSync(ProjectDirectories.ListInput)({ projectID })).toEqual({ projectID })
expect(Schema.decodeUnknownSync(ProjectDirectories.ListOutput)([{ directory }])).toEqual([{ directory }])
}),
)
it.effect("creates once and ignores conflicts", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* ProjectDirectories.Service
expect(yield* service.create({ projectID, directory })).toBe(true)
expect(yield* service.create({ projectID, directory, strategy: "git_worktree" })).toBe(false)
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: undefined }])
}),
)
it.effect("returns an empty list for missing projects", () =>
Effect.gen(function* () {
const service = yield* ProjectDirectories.Service
expect(yield* service.list(Project.ID.make("missing-project"))).toEqual([])
}),
)
it.effect("replaces the strategy when requested", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* ProjectDirectories.Service
yield* service.create({ projectID, directory, strategy: "old/strategy" })
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(false)
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(true)
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(false)
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: "new/strategy" }])
}),
)
})
+11 -2
View File
@@ -2,11 +2,13 @@ import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Database } from "@opencode-ai/core/database/database"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/util/hash"
import { tmpdir } from "./fixture/tmpdir"
@@ -365,8 +367,15 @@ describe("Project.resolve", () => {
time: { created: 1, initialized: 2 },
})
expect(
(yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
).toEqual([{ directory: yield* real(tmp.path) }, { directory: yield* real(worktree), strategy: "git_worktree" }])
(yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(eq(WorktreeTable.project_id, id))
.all()
.pipe(Effect.orDie))
.map((item) => ({ directory: item.directory, strategy: item.strategy ?? undefined }))
.toSorted((a, b) => a.directory.localeCompare(b.directory)),
).toEqual([{ directory: yield* real(tmp.path), strategy: undefined }, { directory: yield* real(worktree), strategy: "git" }])
}),
)
})
@@ -34,7 +34,6 @@ const projects = Layer.succeed(
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
}),
)
let requests: LLMRequest[] = []
+1 -2
View File
@@ -36,7 +36,6 @@ const projects = Layer.succeed(
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
}),
)
const it = testEffect(
@@ -144,7 +143,7 @@ describe("Session.create", () => {
.where(eq(EventTable.aggregate_id, project.id))
.all()
.pipe(Effect.orDie)
expect(announced.map((event) => event.type)).toEqual(["project.directory.resolved.1"])
expect(announced.map((event) => event.type)).toEqual(["worktree.resolved.1"])
}),
),
)
@@ -55,7 +55,6 @@ const projects = Layer.succeed(
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
}),
)
const permission = Layer.succeed(
-1
View File
@@ -22,7 +22,6 @@ const projects = Layer.succeed(
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
}),
)
const it = testEffect(
+2 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { Event } from "@opencode-ai/schema/project-directories"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -22,7 +22,6 @@ const projects = Layer.succeed(
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
}),
)
const it = testEffect(
@@ -99,7 +98,7 @@ describe("Session.move", () => {
projectID: Project.ID.global,
})
// The former directory becomes a project after the session left it.
yield* bus.publish(Event.Resolved, {
yield* bus.publish(Worktree.Event.Resolved, {
projectID: Project.ID.make("adopting"),
directory: previous,
previous: Project.ID.global,
@@ -18,7 +18,6 @@ const projects = Layer.succeed(
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
}),
)
const it = testEffect(
+9 -7
View File
@@ -6,7 +6,7 @@ import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Project } from "@opencode-ai/schema/project"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { Worktree } from "@opencode-ai/schema/worktree"
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
import { Prompt } from "@opencode-ai/schema/prompt"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
@@ -39,7 +39,7 @@ test("Core reuses the canonical shared schemas", async () => {
coreModel,
corePermission,
corePermissionV1,
coreProjectCopy,
coreWorktree,
corePty,
coreProject,
coreProvider,
@@ -60,7 +60,7 @@ test("Core reuses the canonical shared schemas", async () => {
import("@opencode-ai/core/model"),
import("@opencode-ai/core/permission"),
import("@opencode-ai/core/v1/permission"),
import("@opencode-ai/core/project/copy"),
import("@opencode-ai/core/worktree"),
import("@opencode-ai/core/pty"),
import("@opencode-ai/core/project/schema"),
import("@opencode-ai/core/provider"),
@@ -112,14 +112,16 @@ test("Core reuses the canonical shared schemas", async () => {
[corePermission.Rule, Permission.Rule],
[corePermission.Ruleset, Permission.Ruleset],
[corePermissionV1.Event, PermissionV1.Event],
[coreProjectCopy.Event, ProjectDirectories.Event],
[coreWorktree.CreateInput, Worktree.CreateInput],
[coreWorktree.RemoveInput, Worktree.RemoveInput],
[coreWorktree.Info, Worktree.Info],
[coreWorktree.ListInput, Worktree.ListInput],
[coreWorktree.List, Worktree.List],
[coreWorktree.Event, Worktree.Event],
[corePty.Info, Pty.Info],
[corePty.Event, Pty.Event],
[coreProject.ID, Project.ID],
[coreProject.Current, Project.Current],
[coreProject.Directory, Project.Directory],
[coreProject.DirectoriesInput, Project.DirectoriesInput],
[coreProject.Directories, Project.Directories],
[coreReference.LocalSource, Reference.LocalSource],
[coreReference.GitSource, Reference.GitSource],
[coreReference.Source, Reference.Source],
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { and, eq, isNull } from "drizzle-orm"
import { Effect, Fiber, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -11,21 +11,19 @@ import { Git } from "@opencode-ai/core/git"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Worktree } from "@opencode-ai/core/worktree"
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, Bus.node, ProjectDirectories.node])),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node])))
function abs(input: string) {
return AbsolutePath.make(input)
}
const gitWorktree = ProjectCopy.StrategyID.make("git_worktree")
const gitWorktree = Worktree.StrategyID.make("git")
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
@@ -44,7 +42,7 @@ function setup() {
)
yield* Effect.promise(() => initRepo(root.path))
const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const projectID = Project.ID.make("copy-project")
const projectID = Project.ID.make("worktree-project")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
@@ -52,7 +50,7 @@ function setup() {
.run()
.pipe(Effect.orDie)
yield* db
.insert(ProjectDirectoryTable)
.insert(WorktreeTable)
.values({ project_id: projectID, directory: sourceDirectory })
.run()
.pipe(Effect.orDie)
@@ -63,9 +61,9 @@ function setup() {
function stored(projectID: Project.ID) {
return Database.Service.use(({ db }) =>
db
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, projectID))
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(eq(WorktreeTable.project_id, projectID))
.all()
.pipe(
Effect.orDie,
@@ -74,92 +72,151 @@ function stored(projectID: Project.ID) {
)
}
describe("ProjectCopy", () => {
describe("Worktree", () => {
it.effect("accepts arbitrary non-empty strategy ids", () =>
Effect.sync(() => {
expect(String(ProjectCopy.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
expect(() => ProjectCopy.StrategyID.make(" acme/snapshot ")).toThrow()
expect(() => ProjectCopy.StrategyID.make(" ")).toThrow()
expect(String(Worktree.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
expect(() => Worktree.StrategyID.make(" acme/snapshot ")).toThrow()
expect(() => Worktree.StrategyID.make(" ")).toThrow()
}),
)
it.effect("reports unavailable strategy ids", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const unavailable = ProjectCopy.StrategyID.make("acme/missing")
const error = yield* copy
const worktree = yield* Worktree.Service
const unavailable = Worktree.StrategyID.make("acme/missing")
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: unavailable,
sourceDirectory: input.sourceDirectory,
directory: abs(`${input.root.path}-missing-strategy`),
name: "copy",
name: "worktree",
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
if (error instanceof ProjectCopy.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
if (error instanceof Worktree.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
}),
)
it.live("requires a tracked project worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
yield* input.db
.delete(WorktreeTable)
.where(eq(WorktreeTable.project_id, input.projectID))
.run()
.pipe(Effect.orDie)
const worktree = yield* Worktree.Service
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
directory: abs(`${input.root.path}-missing-source`),
name: "worktree",
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.SourceDirectoryNotFoundError)
if (error instanceof Worktree.SourceDirectoryNotFoundError) expect(error.projectID).toBe(input.projectID)
}),
)
it.live("creates and removes a git worktree directory", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const worktree = yield* Worktree.Service
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) + "-copy-created"))
const target = abs(path.join(parent, "copy"))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-created"))
const target = abs(path.join(parent, "worktree"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
const fiber = yield* bus
.subscribe(ProjectCopy.Event.Updated)
.subscribe(Worktree.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const created = yield* copy.create({
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "copy",
name: "worktree",
})
expect(created.directory).toBe(target)
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, strategy: null },
{ directory: created.directory, strategy: "git_worktree" },
{ directory: created.directory, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
}),
)
it.live("creates from another managed worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
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"))
yield* Effect.addFinalizer(() =>
Effect.all([
Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })).pipe(Effect.ignore),
Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })).pipe(Effect.ignore),
]).pipe(Effect.asVoid),
)
const source = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
directory: sourceParent,
name: "source",
})
yield* input.db
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, input.projectID), isNull(WorktreeTable.strategy)))
.run()
.pipe(Effect.orDie)
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
directory: targetParent,
name: "target",
})
expect(created.directory).toBe(abs(path.join(targetParent, "target")))
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* worktree.remove({ projectID: input.projectID, directory: source.directory, force: false })
}),
)
it.live("requires force to remove a dirty git worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-dirty"))
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 })).pipe(Effect.ignore),
)
const created = yield* copy.create({
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "copy",
name: "worktree",
})
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
const error = yield* copy
const error = yield* worktree
.remove({ projectID: input.projectID, directory: created.directory, force: false })
.pipe(Effect.flip)
@@ -168,115 +225,113 @@ describe("ProjectCopy", () => {
expect(error.operation).toBe("remove")
expect(error.forceRequired).toBe(true)
}
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" })
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git" })
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
}),
)
it.live("preserves copies whose stored strategy is unavailable", () =>
it.live("preserves worktrees whose stored strategy is unavailable", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const unavailable = abs(`${input.root.path}-copy-unavailable`)
const worktree = yield* Worktree.Service
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 })))
yield* input.db
.insert(ProjectDirectoryTable)
.insert(WorktreeTable)
.values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" })
.run()
.pipe(Effect.orDie)
const error = yield* copy
const error = yield* worktree
.remove({ projectID: input.projectID, directory: unavailable, force: false })
.pipe(Effect.flip)
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
}),
)
it.live("adds a numeric suffix when a copy directory already exists", () =>
it.live("adds a numeric suffix when a worktree directory already exists", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-suffix"))
const target = abs(path.join(parent, "copy-3"))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-suffix"))
const target = abs(path.join(parent, "worktree-3"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2")))
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
const created = yield* copy.create({
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "copy",
name: "worktree",
})
expect(created.directory).toBe(target)
expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy")).then((item) => item.isDirectory()))).toBe(
true,
)
expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy-2")).then((item) => item.isDirectory()))).toBe(
true,
)
expect(
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree")).then((item) => item.isDirectory())),
).toBe(true)
expect(
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree-2")).then((item) => item.isDirectory())),
).toBe(true)
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
}),
)
it.live("fails after ten copy directory conflicts", () =>
it.live("fails after ten worktree directory conflicts", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-conflicts"))
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 })).pipe(Effect.ignore),
)
yield* Effect.promise(() =>
Promise.all(
Array.from({ length: 10 }, (_, index) =>
fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }),
fs.mkdir(path.join(parent, index === 0 ? "worktree" : `worktree-${index + 1}`), { recursive: true }),
),
),
)
const error = yield* copy
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "copy",
name: "worktree",
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
if (error instanceof ProjectCopy.DestinationExistsError)
expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
expect(error).toBeInstanceOf(Worktree.DestinationExistsError)
if (error instanceof Worktree.DestinationExistsError)
expect(error.directory).toBe(abs(path.join(parent, "worktree-10")))
}),
)
it.live("does not publish an event when refresh finds no directory changes", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const worktree = yield* Worktree.Service
const bus = yield* Bus.Service
const event = yield* bus.subscribe(ProjectCopy.Event.Updated).pipe(
const event = yield* bus.subscribe(Worktree.Event.Updated).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* copy.refresh({ projectID: input.projectID })
yield* worktree.refresh({ projectID: input.projectID })
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
}),
),
@@ -289,36 +344,36 @@ describe("ProjectCopy", () => {
it.live("refresh discovers and prunes an externally managed git worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const worktree = yield* Worktree.Service
const bus = yield* Bus.Service
const target = abs(`${input.root.path}-copy-external`)
const target = abs(`${input.root.path}-worktree-external`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* input.db
.insert(ProjectDirectoryTable)
.insert(WorktreeTable)
.values({ project_id: input.projectID, directory: target })
.run()
.pipe(Effect.orDie)
const fiber = yield* bus
.subscribe(ProjectCopy.Event.Updated)
.subscribe(Worktree.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, strategy: null },
{ directory: discovered, strategy: "git_worktree" },
{ directory: discovered, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
}),
)
@@ -326,9 +381,9 @@ describe("ProjectCopy", () => {
it.live("refresh ignores stale git worktree registrations", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const stale = abs(`${input.root.path}-copy-stale`)
const target = abs(`${input.root.path}-copy-after-stale`)
const worktree = yield* Worktree.Service
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 })).pipe(Effect.ignore),
)
@@ -336,13 +391,13 @@ describe("ProjectCopy", () => {
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* copy.refresh({ projectID: input.projectID })
yield* worktree.refresh({ projectID: input.projectID })
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, strategy: null },
{ directory: discovered, strategy: "git_worktree" },
{ directory: discovered, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
}),
@@ -352,9 +407,9 @@ describe("ProjectCopy", () => {
Effect.gen(function* () {
const input = yield* setup()
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
const copy = yield* ProjectCopy.Service
const worktree = yield* Worktree.Service
yield* copy.refresh({ projectID: input.projectID })
yield* worktree.refresh({ projectID: input.projectID })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
}),
@@ -362,9 +417,9 @@ describe("ProjectCopy", () => {
it.live("refresh with no roots is a no-op", () =>
Effect.gen(function* () {
const copy = yield* ProjectCopy.Service
const worktree = yield* Worktree.Service
expect(yield* copy.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
expect(yield* worktree.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
updated: [],
removed: [],
})
@@ -376,13 +431,13 @@ describe("ProjectCopy", () => {
const input = yield* setup()
const missing = abs(`${input.root.path}-missing-checkout`)
yield* input.db
.insert(ProjectDirectoryTable)
.insert(WorktreeTable)
.values({ project_id: input.projectID, directory: missing })
.run()
.pipe(Effect.orDie)
const copy = yield* ProjectCopy.Service
const worktree = yield* Worktree.Service
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
}),
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -29,7 +29,7 @@ import { WebSearchGroup } from "./groups/websearch.js"
import { McpGroup } from "./groups/mcp.js"
import { CredentialGroup } from "./groups/credential.js"
import { ProjectGroup } from "./groups/project.js"
import { ProjectCopyGroup } from "./groups/project-copy.js"
import { WorktreeGroup } from "./groups/worktree.js"
import { VcsGroup } from "./groups/vcs.js"
import { MigrationGroup } from "./groups/migration.js"
import { ConfigGroup } from "./groups/config.js"
@@ -52,7 +52,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 ProjectCopyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
@@ -89,6 +88,7 @@ type ApiGroups<
| typeof ServerGroup
| typeof DebugGroup
| typeof MigrationGroup
| typeof WorktreeGroup
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
| SessionGroups<SessionLocationId, SessionLocationService>
@@ -172,7 +172,7 @@ const makeApiFromGroup = <
.add(ShellGroup.middleware(locationMiddleware))
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(ProjectCopyGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
.add(VcsGroup.middleware(locationMiddleware))
.add(DebugGroup)
.add(MigrationGroup)
+1 -1
View File
@@ -60,7 +60,7 @@ export const groupNames = {
"server.question": "question",
"server.reference": "reference",
"server.project": "project",
"server.projectCopy": "projectCopy",
"server.worktree": "worktree",
"server.vcs": "vcs",
"server.config": "config",
} as const
@@ -1,56 +0,0 @@
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
import { Project } from "@opencode-ai/schema/project"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const root = "/api/experimental/project/:projectID/copy"
export class ProjectCopyError extends Schema.ErrorClass<ProjectCopyError>("ProjectCopyError")(
{
name: Schema.Literal("ProjectCopyError"),
data: Schema.Struct({
message: Schema.String,
forceRequired: Schema.optional(Schema.Boolean),
}),
},
{ httpApiStatus: 400 },
) {}
const CreatePayload = Schema.Struct(Struct.omit(ProjectCopy.CreateInput.fields, ["projectID", "sourceDirectory"]))
const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, ["projectID"]))
export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
.add(
HttpApiEndpoint.post("projectCopy.create", root, {
params: { projectID: Project.ID },
query: LocationQuery,
payload: CreatePayload,
success: ProjectCopy.Copy,
error: ProjectCopyError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.create" })),
)
.add(
HttpApiEndpoint.delete("projectCopy.remove", root, {
params: { projectID: Project.ID },
query: LocationQuery,
payload: RemovePayload,
success: HttpApiSchema.NoContent,
error: ProjectCopyError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.remove" })),
)
.add(
HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, {
params: { projectID: Project.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: ProjectCopyError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })),
)
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
+1 -16
View File
@@ -31,24 +31,9 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
}),
),
)
.add(
HttpApiEndpoint.get("project.directories", `${root}/:projectID/directories`, {
params: { projectID: Project.ID },
query: LocationQuery,
success: Project.Directories,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.project.directories",
summary: "List project directories",
description: "List known local absolute directories for a project.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "project",
description: "Location-scoped project routes.",
description: "Project routes.",
}),
)
+76
View File
@@ -0,0 +1,76 @@
import { Project } from "@opencode-ai/schema/project"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
const root = "/api/experimental/project/:projectID/worktree"
export class WorktreeError extends Schema.ErrorClass<WorktreeError>("WorktreeError")(
{
name: Schema.Literal("WorktreeError"),
data: Schema.Struct({
message: Schema.String,
forceRequired: Schema.optional(Schema.Boolean),
}),
},
{ httpApiStatus: 400 },
) {}
const CreatePayload = Schema.Struct(Struct.omit(Worktree.CreateInput.fields, ["projectID"]))
const RemovePayload = Schema.Struct(Struct.omit(Worktree.RemoveInput.fields, ["projectID"]))
export const WorktreeGroup = HttpApiGroup.make("server.worktree")
.add(
HttpApiEndpoint.get("worktree.list", root, {
params: { projectID: Project.ID },
success: Worktree.List,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.list",
summary: "List worktrees",
description: "List known local worktrees for a project.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.create", root, {
params: { projectID: Project.ID },
payload: CreatePayload,
success: Worktree.Info,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.create",
summary: "Create worktree",
description: "Create a worktree for a project.",
}),
),
)
.add(
HttpApiEndpoint.delete("worktree.remove", root, {
params: { projectID: Project.ID },
payload: RemovePayload,
success: HttpApiSchema.NoContent,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.remove",
summary: "Remove worktree",
description: "Remove a managed worktree from a project.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.refresh", `${root}/refresh`, {
params: { projectID: Project.ID },
success: HttpApiSchema.NoContent,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.refresh",
summary: "Refresh worktrees",
description: "Reconcile stored worktrees with the project repositories.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Project worktree management routes." }))
@@ -1,7 +1,7 @@
export * as DurableEventManifest from "./durable-event-manifest.js"
import { Event } from "./event.js"
import { ProjectDirectories } from "./project-directories.js"
import { Worktree } from "./worktree.js"
import { SessionEvent } from "./session-event.js"
export const SessionDurable = {
@@ -9,4 +9,4 @@ export const SessionDurable = {
schema: SessionEvent.Durable,
} as const
export const Durable = Event.durableMap([...SessionEvent.DurableDefinitions, ProjectDirectories.Event.Resolved])
export const Durable = Event.durableMap([...SessionEvent.DurableDefinitions, Worktree.Event.Resolved])
+2 -2
View File
@@ -19,7 +19,7 @@ import { ModelsDev } from "./models-dev.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { Project } from "./project.js"
import { ProjectDirectories } from "./project-directories.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Question } from "./question.js"
import { Reference } from "./reference.js"
@@ -50,7 +50,7 @@ const featureDefinitions = Event.inventory(
...Reference.Event.Definitions,
...Permission.Event.Definitions,
...Plugin.Event.Definitions,
...ProjectDirectories.Event.Definitions,
...Worktree.Event.Definitions,
...Command.Event.Definitions,
...Config.Event.Definitions,
...Skill.Event.Definitions,
+1 -1
View File
@@ -16,7 +16,7 @@ export { Money } from "./money.js"
export { Permission } from "./permission.js"
export { PermissionSaved } from "./permission-saved.js"
export { Project } from "./project.js"
export { ProjectCopy } from "./project-copy.js"
export { Worktree } from "./worktree.js"
export { Provider } from "./provider.js"
export { Reference } from "./reference.js"
export { WebSearch } from "./websearch.js"
-30
View File
@@ -1,30 +0,0 @@
export * as ProjectCopy from "./project-copy.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { ProjectID } from "./project-id.js"
import { AbsolutePath } from "./schema.js"
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Schema.Struct({
projectID: ProjectID,
strategy: StrategyID,
sourceDirectory: AbsolutePath,
directory: AbsolutePath,
name: optional(Schema.String),
}).annotate({ identifier: "ProjectCopy.CreateInput" })
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
export const RemoveInput = Schema.Struct({
projectID: ProjectID,
directory: AbsolutePath,
force: Schema.Boolean,
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
export interface RemoveInput extends Schema.Schema.Type<typeof RemoveInput> {}
export const Copy = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.Copy" })
export interface Copy extends Schema.Schema.Type<typeof Copy> {}
@@ -1,53 +0,0 @@
export * as ProjectDirectories from "./project-directories.js"
import { durable, ephemeral, inventory } from "./event.js"
import { AbsolutePath } from "./schema.js"
import { Project } from "./project.js"
const Updated = ephemeral({
type: "project.directories.updated",
schema: { projectID: Project.ID },
})
/**
* A directory's resolution changed: it now resolves to `projectID` where it
* previously resolved to `previous` (`global` when the directory had no
* stable identity yet, e.g. before `git init`). Sessions whose ownership
* came from the previous resolution follow the new identity by projection.
*/
const Resolved = durable({
type: "project.directory.resolved",
durable: { aggregate: "projectID", version: 1 },
schema: {
projectID: Project.ID,
directory: AbsolutePath,
previous: Project.ID,
},
})
export const Event = { Updated, Resolved, Definitions: inventory(Updated, Resolved) }
/**
* Client-side mirror of the server's `project.directory.resolved` session fold.
* Returns the ownership update for a cached session, or undefined when the
* session does not follow the resolution. Plain strings: callers hold
* generated client types, and the server projection remains authoritative.
*/
export function adopt(
session: { readonly projectID: string; readonly directory: string },
event: { readonly projectID: string; readonly directory: string; readonly previous: string },
) {
if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return
if (session.projectID === event.projectID) return
const inside =
session.directory === event.directory ||
session.directory.startsWith(event.directory + "/") ||
session.directory.startsWith(event.directory + "\\")
if (!inside) return
return {
projectID: event.projectID,
subpath:
session.directory === event.directory
? undefined
: session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"),
}
}
-11
View File
@@ -15,17 +15,6 @@ export const Current = Schema.Struct({
canonical: AbsolutePath,
}).annotate({ identifier: "Project.Current" })
export interface Current extends Schema.Schema.Type<typeof Current> {}
export const Directory = Schema.Struct({
directory: AbsolutePath,
strategy: optional(Schema.String),
}).annotate({ identifier: "Project.Directory" })
export interface Directory extends Schema.Schema.Type<typeof Directory> {}
export const DirectoriesInput = Schema.Struct({
projectID: ID,
}).annotate({ identifier: "Project.DirectoriesInput" })
export interface DirectoriesInput extends Schema.Schema.Type<typeof DirectoriesInput> {}
export const Directories = Schema.Array(Directory).annotate({ identifier: "Project.Directories" })
export type Directories = typeof Directories.Type
export const Icon = Schema.Struct({
url: optional(Schema.String),
override: optional(Schema.String),
+81
View File
@@ -0,0 +1,81 @@
export * as Worktree from "./worktree.js"
import { Schema } from "effect"
import { durable, ephemeral, inventory } from "./event.js"
import { ProjectID } from "./project-id.js"
import { AbsolutePath, optional } from "./schema.js"
import { Project } from "./project.js"
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("Worktree.StrategyID"))
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Schema.Struct({
projectID: ProjectID,
strategy: StrategyID,
directory: AbsolutePath,
name: optional(Schema.String),
}).annotate({ identifier: "Worktree.CreateInput" })
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
export const RemoveInput = Schema.Struct({
projectID: ProjectID,
directory: AbsolutePath,
force: Schema.Boolean,
}).annotate({ identifier: "Worktree.RemoveInput" })
export interface RemoveInput extends Schema.Schema.Type<typeof RemoveInput> {}
export const Info = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "Worktree.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Directory = Schema.Struct({
directory: AbsolutePath,
strategy: optional(Schema.String),
}).annotate({ identifier: "Worktree.Directory" })
export interface Directory extends Schema.Schema.Type<typeof Directory> {}
export const ListInput = Schema.Struct({
projectID: ProjectID,
}).annotate({ identifier: "Worktree.ListInput" })
export interface ListInput extends Schema.Schema.Type<typeof ListInput> {}
export const List = Schema.Array(Directory).annotate({ identifier: "Worktree.List" })
export type List = typeof List.Type
const Updated = ephemeral({
type: "worktree.updated",
schema: { projectID: Project.ID },
})
const Resolved = durable({
type: "worktree.resolved",
durable: { aggregate: "projectID", version: 1 },
schema: {
projectID: Project.ID,
directory: AbsolutePath,
previous: Project.ID,
},
})
export const Event = { Updated, Resolved, Definitions: inventory(Updated, Resolved) }
export function adopt(
session: { readonly projectID: string; readonly directory: string },
event: { readonly projectID: string; readonly directory: string; readonly previous: string },
) {
if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return
if (session.projectID === event.projectID) return
const inside =
session.directory === event.directory ||
session.directory.startsWith(event.directory + "/") ||
session.directory.startsWith(event.directory + "\\")
if (!inside) return
return {
projectID: event.projectID,
subpath:
session.directory === event.directory
? undefined
: session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"),
}
}
@@ -17,6 +17,7 @@ import { Money } from "../src/money.js"
import { Skill } from "../src/skill.js"
import { Shell } from "../src/shell.js"
import { Vcs } from "../src/vcs.js"
import { Worktree } from "../src/worktree.js"
import { PersistedRevert } from "../src/session-revert.js"
import { AbsolutePath, optional } from "../src/schema.js"
@@ -157,9 +158,9 @@ describe("contract hygiene", () => {
Model.Cost,
Model.Variant,
Project.Current,
Project.Directory,
Project.DirectoriesInput,
Project.Directories,
Worktree.Directory,
Worktree.ListInput,
Worktree.List,
Project.Icon,
Project.Commands,
Project.Time,
+1 -1
View File
@@ -114,7 +114,7 @@ describe("public event manifest", () => {
"session.revert.staged.1",
"session.revert.cleared.1",
"session.revert.committed.1",
"project.directory.resolved.1",
"worktree.resolved.1",
].toSorted(),
)
expect(SessionEvent.DurableDefinitions).toEqual([
+1 -1
View File
@@ -14,7 +14,7 @@ export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Prompt } from "@opencode-ai/schema/prompt"
export { PromptInput } from "@opencode-ai/schema/prompt-input"
export { Provider } from "@opencode-ai/schema/provider"
@@ -13,6 +13,7 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Api } from "@opencode-ai/server/api"
import { ClientApi, groupNames, promiseOmitEndpoints } from "@opencode-ai/protocol/client"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
@@ -22,6 +23,7 @@ const CoreAgent = await import("@opencode-ai/core/agent")
const CoreModel = await import("@opencode-ai/core/model")
const CoreProject = await import("@opencode-ai/core/project")
const CoreSession = await import("@opencode-ai/core/session")
const CoreWorktree = await import("@opencode-ai/core/worktree")
test("re-exports canonical contracts directly from Schema", () => {
expect(SDK.Agent).toBe(Agent)
@@ -29,6 +31,7 @@ test("re-exports canonical contracts directly from Schema", () => {
expect(SDK.Model).toBe(Model)
expect(SDK.WebSearch).toBe(WebSearch)
expect(SDK.Session).toBe(Session)
expect(SDK.Worktree).toBe(Worktree)
expect(SDK.Workspace).toBe(Workspace)
expect(Object.keys(SDK).sort()).toEqual([
"AbsolutePath",
@@ -45,7 +48,6 @@ test("re-exports canonical contracts directly from Schema", () => {
"Permission",
"PermissionSaved",
"Project",
"ProjectCopy",
"Prompt",
"PromptInput",
"Provider",
@@ -60,6 +62,7 @@ test("re-exports canonical contracts directly from Schema", () => {
"Tool",
"WebSearch",
"Workspace",
"Worktree",
])
})
@@ -69,8 +72,9 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
expect(CoreModel.Ref).toBe(Model.Ref)
expect(CoreSession.Info).toBe(Session.Info)
expect(CoreProject.Current).toBe(Project.Current)
expect(CoreProject.Directory).toBe(Project.Directory)
expect(CoreProject.Directories).toBe(Project.Directories)
expect(CoreWorktree.DirectoryUnavailableError).toBeDefined()
expect(CoreWorktree.List).toBe(Worktree.List)
expect(CoreWorktree.Info).toBe(Worktree.Info)
expect(CoreSessionInbox.Item).toBe(SessionInbox.Item)
expect(CoreSessionInbox.User).toBe(SessionInbox.User)
expect(CoreSessionInbox.Synthetic).toBe(SessionInbox.Synthetic)
+2 -2
View File
@@ -25,7 +25,7 @@ import { WebSearchHandler } from "./handlers/websearch"
import { McpHandler } from "./handlers/mcp"
import { CredentialHandler } from "./handlers/credential"
import { ProjectHandler } from "./handlers/project"
import { ProjectCopyHandler } from "./handlers/project-copy"
import { WorktreeHandler } from "./handlers/worktree"
import { VcsHandler } from "./handlers/vcs"
import { EventFeed } from "./event-feed"
import { MigrationHandler } from "./handlers/migration"
@@ -59,7 +59,7 @@ export const handlers = Layer.mergeAll(
ShellHandler,
QuestionHandler,
ReferenceHandler,
ProjectCopyHandler,
WorktreeHandler,
VcsHandler,
ConfigHandler,
)
@@ -1,68 +0,0 @@
import { Location } from "@opencode-ai/core/location"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { Git } from "@opencode-ai/core/git"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ProjectCopyError } from "@opencode-ai/protocol/groups/project-copy"
export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) =>
Effect.succeed(
handlers
.handle("projectCopy.create", (ctx) =>
Effect.gen(function* () {
const copies = yield* ProjectCopy.Service
const location = yield* Location.Service
return yield* badRequest(
copies.create({
...ctx.payload,
projectID: ctx.params.projectID,
sourceDirectory: location.project.directory,
}),
)
}),
)
.handle("projectCopy.remove", (ctx) =>
ProjectCopy.Service.use((copies) =>
badRequest(copies.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
),
)
.handle("projectCopy.refresh", (ctx) =>
ProjectCopy.Service.use((copies) =>
badRequest(copies.refresh({ projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
),
),
),
)
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
return effect.pipe(
Effect.mapError(
(error) =>
new ProjectCopyError({
name: "ProjectCopyError",
data: {
message: message(error),
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
},
}),
),
)
}
function message(error: ProjectCopy.Error) {
if (error instanceof ProjectCopy.SourceDirectoryNotFoundError)
return `Project copy source not found: ${error.directory}`
if (error instanceof ProjectCopy.DestinationExistsError)
return `Project copy destination already exists: ${error.directory}`
if (error instanceof ProjectCopy.DirectoryUnavailableError)
return `Project copy directory unavailable: ${error.directory}`
if (error instanceof ProjectCopy.InvalidDirectoryError) return `Invalid project copy directory: ${error.directory}`
if (error instanceof ProjectCopy.StrategyUnavailableError)
return `Project copy strategy unavailable: ${error.strategy}`
return error.message
}
-3
View File
@@ -15,8 +15,5 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl
canonical: location.project.canonical,
}),
),
)
.handle("project.directories", (ctx) =>
Project.Service.use((project) => project.directories({ projectID: ctx.params.projectID })),
),
)
+53
View File
@@ -0,0 +1,53 @@
import { Git } from "@opencode-ai/core/git"
import { Worktree } from "@opencode-ai/core/worktree"
import { WorktreeError } from "@opencode-ai/protocol/groups/worktree"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
export const WorktreeHandler = HttpApiBuilder.group(Api, "server.worktree", (handlers) =>
Effect.gen(function* () {
const worktrees = yield* Worktree.Service
return handlers
.handle("worktree.list", (ctx) => worktrees.list(ctx.params.projectID))
.handle("worktree.create", (ctx) =>
badRequest(worktrees.create({ ...ctx.payload, projectID: ctx.params.projectID })),
)
.handle("worktree.remove", (ctx) =>
badRequest(worktrees.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
)
.handle("worktree.refresh", (ctx) =>
badRequest(worktrees.refresh({ projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
)
}),
)
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.forceRequired : undefined,
},
}),
),
)
}
function message(error: Worktree.Error) {
if (error instanceof Worktree.SourceDirectoryNotFoundError)
return `Worktree source not found for project: ${error.projectID}`
if (error instanceof Worktree.DestinationExistsError) return `Worktree destination already exists: ${error.directory}`
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}`
return error.message
}
+2
View File
@@ -28,6 +28,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Workspace } from "@opencode-ai/core/workspace"
import { Worktree } from "@opencode-ai/core/worktree"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -52,6 +53,7 @@ const applicationServiceNodes = [
httpClient,
Job.node,
Project.node,
Worktree.node,
Session.node,
SessionTransfer.node,
PluginRuntime.providerNode,
+71
View File
@@ -0,0 +1,71 @@
import fs from "node:fs/promises"
import path from "node:path"
import { $ } from "bun"
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
it.live("lists, creates, and removes worktrees by project ID", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-worktree-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: path.join(tmp.path, "config") },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const headers = { authorization: `Basic ${btoa("opencode:secret")}` }
const location = new URL("/api/location", base)
location.searchParams.set("location[directory]", project)
const resolved = yield* Effect.promise(() => fetch(location, { headers }).then((response) => response.json()))
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
throw new Error("Expected resolved project")
const url = new URL(`/api/experimental/project/${resolved.project.id}/worktree`, base)
const initial = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(initial).toEqual([{ directory: project }])
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const listed = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
)
expect(removed.status).toBe(204)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -16,14 +16,14 @@ import { isRecord } from "../util/record"
import { useToast } from "../ui/toast"
import { Spinner } from "./spinner"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
import type { ProjectDirectoriesOutput } from "@opencode-ai/client"
import type { WorktreeListOutput } from "@opencode-ai/client"
import { useRoute } from "../context/route"
import { DialogProjectCopyName } from "./dialog-project-copy-name"
import { DialogWorktreeName } from "./dialog-worktree-name"
export type MoveSessionSelection =
| { type: "directory"; directory: string; subdirectory: boolean }
| { type: "new"; name: string }
type ProjectDirectory = ProjectDirectoriesOutput[number]
type ProjectDirectory = WorktreeListOutput[number]
type DialogMoveSessionProps = {
projectID: string
@@ -78,15 +78,8 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
() => (props.initialRemoving ? undefined : props.projectID),
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
try {
const requestLocation = { directory: location()?.directory || paths.cwd }
await client.api.projectCopy.refresh({
projectID,
location: requestLocation,
})
const directories = await client.api.project.directories({
projectID,
location: requestLocation,
})
await client.api.worktree.refresh({ projectID })
const directories = await client.api.worktree.list({ projectID })
setLoadError(undefined)
return directories
} catch (error) {
@@ -234,10 +227,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
setToDelete(undefined)
setRemoving(selected.directory)
setWorking(true)
const error = await client.api.projectCopy
const error = await client.api.worktree
.remove({
projectID: props.projectID,
location: { directory: location()?.directory || paths.cwd },
directory: selected.directory,
force: false,
})
@@ -253,18 +245,17 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
.status({ location: { directory: selected.directory } })
.catch(() => undefined)
const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
title: "Delete working copy?",
message: "This working copy has file changes. Do you want to delete it anyway?",
title: "Delete worktree?",
message: "This worktree has file changes. Do you want to delete it anyway?",
})
if (choice !== "yes") {
reopen()
return
}
reopen(selected.directory)
const forcedError = await client.api.projectCopy
const forcedError = await client.api.worktree
.remove({
projectID: props.projectID,
location: { directory: location()?.directory || paths.cwd },
directory: selected.directory,
force: true,
})
@@ -275,7 +266,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (forcedError) {
toast.show({
variant: "error",
title: "Failed to delete project copy",
title: "Failed to delete worktree",
message: errorMessage(forcedError),
})
reopen()
@@ -289,7 +280,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
}
toast.show({
variant: "error",
title: "Failed to delete project copy",
title: "Failed to delete worktree",
message: errorMessage(error),
})
return
@@ -301,7 +292,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
}
async function create() {
const name = await DialogProjectCopyName.show(dialog)
const name = await DialogWorktreeName.show(dialog)
if (name === null) return
props.onSelect({ type: "new", name })
}
@@ -6,7 +6,7 @@ import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
import { useConfig } from "../config"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
export function DialogWorktreeName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const shortcuts = Keymap.useShortcuts()
@@ -30,8 +30,8 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
priority: 1,
commands: [
{
id: "dialog.project_copy.generate",
title: "Generate project copy name",
id: "dialog.worktree.generate",
title: "Generate worktree name",
group: "Dialog",
run: generate,
},
@@ -50,7 +50,7 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Name project copy
Name worktree
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
@@ -63,7 +63,7 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
setInputTarget(value)
}}
onSubmit={confirm}
placeholder="Project copy name"
placeholder="Worktree name"
placeholderColor={theme.text.subdued}
textColor={theme.text.formfield.default}
focusedTextColor={theme.text.formfield.default}
@@ -74,17 +74,17 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
enter <span style={{ fg: theme.text.subdued }}>submit</span>
</text>
<text fg={theme.text.default}>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
{shortcuts.get("dialog.worktree.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
</text>
</box>
</box>
)
}
DialogProjectCopyName.show = (dialog: DialogContext) =>
DialogWorktreeName.show = (dialog: DialogContext) =>
new Promise<string | null>((resolve) => {
dialog.replace(
() => <DialogProjectCopyName onConfirm={resolve} />,
() => <DialogWorktreeName onConfirm={resolve} />,
() => resolve(null),
)
})
+1 -1
View File
@@ -1843,7 +1843,7 @@ export function Prompt(props: PromptProps) {
<Match when={move.pendingNew()}>
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
(new working copy)
(new worktree)
</text>
</box>
</Match>
+4 -5
View File
@@ -23,17 +23,16 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const projectID = await resolveProjectID()
if (!projectID) return
setCreating(true)
setProgress("Creating copy")
setProgress("Creating worktree")
try {
const result = await client.api.projectCopy.create({
const result = await client.api.worktree.create({
projectID,
location: { directory: data.location.info()?.directory || paths.cwd },
strategy: "git_worktree",
strategy: "git",
directory: path.join(paths.worktree, projectID.slice(0, 6)),
name,
})
const directory = result.directory
if (!directory) throw new Error("No project copy directory returned")
if (!directory) throw new Error("No worktree directory returned")
// Call a location-based route to initialize it before moving on.
await client.api.location.get({ location: { directory } })
+4 -4
View File
@@ -243,10 +243,10 @@ export const Definitions = {
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"),
"dialog.worktree.generate": keybind("tab", "Generate worktree name"),
"dialog.move_session.new": keybind("ctrl+m", "New worktree"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete worktree"),
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh worktrees"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
+3 -3
View File
@@ -39,7 +39,7 @@ import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { Worktree } from "@opencode-ai/schema/worktree"
import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
@@ -458,9 +458,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
break
}
case "project.directory.resolved": {
case "worktree.resolved": {
for (const [sessionID, info] of Object.entries(store.session.info)) {
const adopted = ProjectDirectories.adopt(
const adopted = Worktree.adopt(
{ projectID: info.projectID, directory: info.location.directory },
event.data,
)
+7 -1
View File
@@ -107,7 +107,13 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
})
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
if (url.pathname === "/api/project") return json([])
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
if (url.pathname === "/api/experimental/project/proj_test/worktree") {
if (request.method === "GET") return json([{ directory: worktree }])
if (request.method === "POST") return json({ directory: `${worktree}/created` })
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/experimental/project/proj_test/worktree/refresh")
return new Response(null, { status: 204 })
if (url.pathname === "/api/shell")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },