From 2bba2915dbd5fdac3e8ddd0ed0d188e4c2228a8a Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 12 Aug 2026 21:57:11 +0000 Subject: [PATCH] refactor(core): consolidate worktree domain --- packages/core/schema.json | 232 ++++++++++-- packages/core/src/database/migration.gen.ts | 2 + .../migration/20260812213948_worktree.ts | 35 ++ packages/core/src/database/schema.gen.ts | 10 + packages/core/src/location-services.ts | 5 +- packages/core/src/project.ts | 51 +-- packages/core/src/project/copy-strategies.ts | 35 -- packages/core/src/project/copy.ts | 272 -------------- packages/core/src/project/directories.ts | 128 ------- packages/core/src/project/schema.ts | 9 - packages/core/src/project/sql.ts | 1 + packages/core/src/session/projector.ts | 4 +- packages/core/src/worktree.ts | 349 ++++++++++++++++++ packages/core/src/worktree/directory.ts | 16 + packages/core/src/worktree/git.ts | 39 ++ packages/core/src/worktree/sql.ts | 20 + packages/core/test/database-migration.test.ts | 26 ++ .../test/effect/layer-node/node-build.test.ts | 1 - packages/core/test/location.test.ts | 1 - .../core/test/project-directories.test.ts | 69 ---- packages/core/test/project.test.ts | 13 +- packages/core/test/session-compact.test.ts | 1 - packages/core/test/session-create.test.ts | 3 +- .../core/test/session-instructions.test.ts | 1 - packages/core/test/session-log.test.ts | 1 - packages/core/test/session-move.test.ts | 5 +- packages/core/test/session-remove.test.ts | 1 - packages/core/test/shared-schema.test.ts | 16 +- ...{project-copy.test.ts => worktree.test.ts} | 245 +++++++----- packages/schema/src/durable-event-manifest.ts | 4 +- packages/schema/src/event-manifest.ts | 4 +- packages/schema/src/index.ts | 2 +- packages/schema/src/project-copy.ts | 30 -- packages/schema/src/project-directories.ts | 53 --- packages/schema/src/project.ts | 11 - packages/schema/src/worktree.ts | 81 ++++ packages/schema/test/contract-hygiene.test.ts | 7 +- packages/schema/test/event-manifest.test.ts | 2 +- 38 files changed, 983 insertions(+), 802 deletions(-) create mode 100644 packages/core/src/database/migration/20260812213948_worktree.ts delete mode 100644 packages/core/src/project/copy-strategies.ts delete mode 100644 packages/core/src/project/copy.ts delete mode 100644 packages/core/src/project/directories.ts create mode 100644 packages/core/src/worktree.ts create mode 100644 packages/core/src/worktree/directory.ts create mode 100644 packages/core/src/worktree/git.ts create mode 100644 packages/core/src/worktree/sql.ts delete mode 100644 packages/core/test/project-directories.test.ts rename packages/core/test/{project-copy.test.ts => worktree.test.ts} (58%) delete mode 100644 packages/schema/src/project-copy.ts delete mode 100644 packages/schema/src/project-directories.ts create mode 100644 packages/schema/src/worktree.ts diff --git a/packages/core/schema.json b/packages/core/schema.json index 0bfde100e1..cc8f66c542 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -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": [] -} +} \ No newline at end of file diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 1fb55eb24c..809ce55190 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -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[] diff --git a/packages/core/src/database/migration/20260812213948_worktree.ts b/packages/core/src/database/migration/20260812213948_worktree.ts new file mode 100644 index 0000000000..e279806f47 --- /dev/null +++ b/packages/core/src/database/migration/20260812213948_worktree.ts @@ -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 diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 13f94e420e..327758147c 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -225,6 +225,16 @@ const schema: Omit = { \`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( diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index e03c235301..02db84a829 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -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, diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 755779d11c..8a9ecd4737 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -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 {} -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> - readonly directories: (input: DirectoriesInput) => Effect.Effect readonly resolve: (input: AbsolutePath) => Effect.Effect } @@ -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() 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], }) diff --git a/packages/core/src/project/copy-strategies.ts b/packages/core/src/project/copy-strategies.ts deleted file mode 100644 index f205196c54..0000000000 --- a/packages/core/src/project/copy-strategies.ts +++ /dev/null @@ -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 -}) { - 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 -} diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts deleted file mode 100644 index de488f3a43..0000000000 --- a/packages/core/src/project/copy.ts +++ /dev/null @@ -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()( - "ProjectCopy.SourceDirectoryNotFoundError", - { directory: AbsolutePath }, -) {} - -export class DestinationExistsError extends Schema.TaggedErrorClass()( - "ProjectCopy.DestinationExistsError", - { directory: AbsolutePath }, -) {} - -export class DirectoryUnavailableError extends Schema.TaggedErrorClass()( - "ProjectCopy.DirectoryUnavailableError", - { directory: AbsolutePath }, -) {} - -export class InvalidDirectoryError extends Schema.TaggedErrorClass()( - "ProjectCopy.InvalidDirectoryError", - { directory: AbsolutePath }, -) {} - -export class StrategyUnavailableError extends Schema.TaggedErrorClass()( - "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 - readonly remove: (input: { - directory: AbsolutePath - force: boolean - }) => Effect.Effect - readonly list: (directory: AbsolutePath) => Effect.Effect -} - -export { Event } - -export interface Interface { - readonly create: (input: CreateInput) => Effect.Effect - readonly remove: (input: RemoveInput) => Effect.Effect - readonly refresh: (input: RefreshInput) => Effect.Effect -} - -export class Service extends Context.Service()("@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], -}) diff --git a/packages/core/src/project/directories.ts b/packages/core/src/project/directories.ts deleted file mode 100644 index 77f815662a..0000000000 --- a/packages/core/src/project/directories.ts +++ /dev/null @@ -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[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> - readonly get: (input: { - projectID: ProjectSchema.ID - directory: AbsolutePath - }) => Effect.Effect - readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect - readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect -} - -export class Service extends Context.Service()("@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] }) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index 2eef1ed1f0..4f5fa90fb3 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -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 {} -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"), diff --git a/packages/core/src/project/sql.ts b/packages/core/src/project/sql.ts index 4506e05623..86ff6416ca 100644 --- a/packages/core/src/project/sql.ts +++ b/packages/core/src/project/sql.ts @@ -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", { diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 1393536df5..10c86095d8 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -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 diff --git a/packages/core/src/worktree.ts b/packages/core/src/worktree.ts new file mode 100644 index 0000000000..a1d79fd909 --- /dev/null +++ b/packages/core/src/worktree.ts @@ -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()( + "Worktree.SourceDirectoryNotFoundError", + { projectID: ProjectSchema.ID }, +) {} + +export class DestinationExistsError extends Schema.TaggedErrorClass()( + "Worktree.DestinationExistsError", + { directory: AbsolutePath }, +) {} + +export class InvalidDirectoryError extends Schema.TaggedErrorClass()( + "Worktree.InvalidDirectoryError", + { directory: AbsolutePath }, +) {} + +export class StrategyUnavailableError extends Schema.TaggedErrorClass()( + "Worktree.StrategyUnavailableError", + { strategy: StrategyID }, +) {} + +export class DuplicateStrategyError extends Schema.TaggedErrorClass()( + "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 + readonly remove: (input: { + directory: AbsolutePath + force: boolean + }) => Effect.Effect + readonly list: (directory: AbsolutePath) => Effect.Effect +} + +export const Event = Worktree.Event + +interface StoredInput { + readonly projectID: ProjectSchema.ID + readonly directory: AbsolutePath + readonly strategy?: string +} + +type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase +type Transaction = Parameters[0]>[0] + +export interface Interface { + readonly register: (strategy: Strategy) => Effect.Effect + readonly list: (projectID: ProjectSchema.ID) => Effect.Effect + readonly create: (input: CreateInput) => Effect.Effect + readonly remove: (input: RemoveInput) => Effect.Effect + readonly refresh: (input: RefreshInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Worktree") {} + +export const refreshAfterBoot = Effect.gen(function* () { + const location = yield* Location.Service + const worktrees = yield* Service + yield* Effect.gen(function* () { + yield* Effect.logInfo("worktree refresh started", { projectID: location.project.id }) + const result = yield* worktrees.refresh({ projectID: location.project.id }) + yield* Effect.logInfo("worktree refresh done", { + projectID: location.project.id, + updated: result.updated, + removed: result.removed, + }) + }).pipe( + Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })), + Effect.forkScoped, + Effect.asVoid, + ) +}) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const 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() + + 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], +}) diff --git a/packages/core/src/worktree/directory.ts b/packages/core/src/worktree/directory.ts new file mode 100644 index 0000000000..4d240a1c65 --- /dev/null +++ b/packages/core/src/worktree/directory.ts @@ -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()( + "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 +}) diff --git a/packages/core/src/worktree/git.ts b/packages/core/src/worktree/git.ts new file mode 100644 index 0000000000..7b6a7557f7 --- /dev/null +++ b/packages/core/src/worktree/git.ts @@ -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 +}) diff --git a/packages/core/src/worktree/sql.ts b/packages/core/src/worktree/sql.ts new file mode 100644 index 0000000000..86e1904fc2 --- /dev/null +++ b/packages/core/src/worktree/sql.ts @@ -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() + .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] })], +) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 36dbc1eb52..f36124bb2b 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -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 = ( @@ -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") diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index 4ce180ba4a..5edbece2c6 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -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 }), }) }), diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index eaec7897c2..fdda8566fc 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -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"), diff --git a/packages/core/test/project-directories.test.ts b/packages/core/test/project-directories.test.ts deleted file mode 100644 index 9071dcb4ef..0000000000 --- a/packages/core/test/project-directories.test.ts +++ /dev/null @@ -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" }]) - }), - ) -}) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index da4ddf4fb7..58e1f89102 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -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" }]) }), ) }) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 8f8da70b21..c208581240 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -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[] = [] diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 57ebf17d0e..149f473c37 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -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"]) }), ), ) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index a738499a60..425096ad3c 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -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( diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index e72ff388e0..c428c2ae58 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -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( diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts index fba3649687..0dbfdad2ad 100644 --- a/packages/core/test/session-move.test.ts +++ b/packages/core/test/session-move.test.ts @@ -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, diff --git a/packages/core/test/session-remove.test.ts b/packages/core/test/session-remove.test.ts index ec7c532bc7..5132a7e4f1 100644 --- a/packages/core/test/session-remove.test.ts +++ b/packages/core/test/session-remove.test.ts @@ -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( diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 7b2bda8d5f..5984fde552 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -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], diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/worktree.test.ts similarity index 58% rename from packages/core/test/project-copy.test.ts rename to packages/core/test/worktree.test.ts index 56e764591f..b1911b3d89 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/worktree.test.ts @@ -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 }) }), diff --git a/packages/schema/src/durable-event-manifest.ts b/packages/schema/src/durable-event-manifest.ts index c1b56e1465..269190b0c3 100644 --- a/packages/schema/src/durable-event-manifest.ts +++ b/packages/schema/src/durable-event-manifest.ts @@ -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]) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 1898f50cb7..842e9287d1 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -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, diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 63c352523b..e63522a808 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -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" diff --git a/packages/schema/src/project-copy.ts b/packages/schema/src/project-copy.ts deleted file mode 100644 index 2f6688aef4..0000000000 --- a/packages/schema/src/project-copy.ts +++ /dev/null @@ -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 {} - -export const RemoveInput = Schema.Struct({ - projectID: ProjectID, - directory: AbsolutePath, - force: Schema.Boolean, -}).annotate({ identifier: "ProjectCopy.RemoveInput" }) -export interface RemoveInput extends Schema.Schema.Type {} - -export const Copy = Schema.Struct({ - directory: AbsolutePath, -}).annotate({ identifier: "ProjectCopy.Copy" }) -export interface Copy extends Schema.Schema.Type {} diff --git a/packages/schema/src/project-directories.ts b/packages/schema/src/project-directories.ts deleted file mode 100644 index bea76edd66..0000000000 --- a/packages/schema/src/project-directories.ts +++ /dev/null @@ -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("\\", "/"), - } -} diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index e47e45678c..678f975978 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -15,17 +15,6 @@ export const Current = Schema.Struct({ canonical: AbsolutePath, }).annotate({ identifier: "Project.Current" }) export interface Current extends Schema.Schema.Type {} -export const Directory = Schema.Struct({ - directory: AbsolutePath, - strategy: optional(Schema.String), -}).annotate({ identifier: "Project.Directory" }) -export interface Directory extends Schema.Schema.Type {} -export const DirectoriesInput = Schema.Struct({ - projectID: ID, -}).annotate({ identifier: "Project.DirectoriesInput" }) -export interface DirectoriesInput extends Schema.Schema.Type {} -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), diff --git a/packages/schema/src/worktree.ts b/packages/schema/src/worktree.ts new file mode 100644 index 0000000000..0f3fab1cac --- /dev/null +++ b/packages/schema/src/worktree.ts @@ -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 {} + +export const RemoveInput = Schema.Struct({ + projectID: ProjectID, + directory: AbsolutePath, + force: Schema.Boolean, +}).annotate({ identifier: "Worktree.RemoveInput" }) +export interface RemoveInput extends Schema.Schema.Type {} + +export const Info = Schema.Struct({ + directory: AbsolutePath, +}).annotate({ identifier: "Worktree.Info" }) +export interface Info extends Schema.Schema.Type {} + +export const Directory = Schema.Struct({ + directory: AbsolutePath, + strategy: optional(Schema.String), +}).annotate({ identifier: "Worktree.Directory" }) +export interface Directory extends Schema.Schema.Type {} + +export const ListInput = Schema.Struct({ + projectID: ProjectID, +}).annotate({ identifier: "Worktree.ListInput" }) +export interface ListInput extends Schema.Schema.Type {} + +export const 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("\\", "/"), + } +} diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index ae9d22d2d3..a1ef01fea8 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -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, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index a13075df11..754ac9eb4c 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -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([