diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 26cd3720d9d..f3416ac6afd 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -18,6 +18,7 @@ import { ConfigFormatter } from "./config/formatter" import { ConfigLSP } from "./config/lsp" import { ConfigMCP } from "./config/mcp" import { ConfigPlugin } from "./config/plugin" +import { ConfigProjectCopy } from "./config/project-copy" import { ConfigProvider } from "./config/provider" import { ConfigReference } from "./config/reference" import { ConfigToolOutput } from "./config/tool-output" @@ -101,6 +102,9 @@ export class Info extends Schema.Class("Config.Info")({ plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ description: "Ordered external plugin packages to load", }), + projectCopy: ConfigProjectCopy.Info.pipe(Schema.optional).annotate({ + description: "Project copy strategy and destination template", + }), experimental: ConfigExperimental.Experimental.pipe(Schema.optional), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), }) {} diff --git a/packages/core/src/config/project-copy.ts b/packages/core/src/config/project-copy.ts new file mode 100644 index 00000000000..1e2c926e9d4 --- /dev/null +++ b/packages/core/src/config/project-copy.ts @@ -0,0 +1,8 @@ +export * as ConfigProjectCopy from "./project-copy" + +import { Schema } from "effect" + +export class Info extends Schema.Class("ConfigProjectCopy.Info")({ + strategy: Schema.Literal("git_worktree").pipe(Schema.optional), + directory: Schema.String.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 2bf32fd755c..ae981a63494 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -41,6 +41,8 @@ import { LLMClient } from "@opencode-ai/llm" import { RequestExecutor } from "@opencode-ai/llm/route" import * as SessionRunnerLLM from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" +import { ProjectCopy } from "./project/copy" +import { Git } from "./git" import { SystemContextBuiltIns } from "./system-context/builtins" import { FetchHttpClient } from "effect/unstable/http" @@ -65,6 +67,7 @@ export class LocationServiceMap extends LayerMap.Service()(" Watcher.locationLayer, Pty.locationLayer, SkillV2.locationLayer, + ProjectCopy.locationLayer, systemContext, LocationMutation.locationLayer.pipe(Layer.orDie), ).pipe(Layer.provideMerge(location)) @@ -119,6 +122,7 @@ export class LocationServiceMap extends LayerMap.Service()(" ModelsDev.defaultLayer, FSUtil.defaultLayer, AppProcess.defaultLayer, + Git.defaultLayer, Global.defaultLayer, Ripgrep.defaultLayer, Database.defaultLayer, diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 9cd9d6820a6..3eed326c86f 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -7,6 +7,7 @@ import type { ModelV2 } from "./model" import type { Catalog } from "./catalog" import { EventV2 } from "./event" import { KeyedMutex } from "./effect/keyed-mutex" +import { LayerNode } from "./effect/layer-node" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export type ID = typeof ID.Type @@ -33,6 +34,19 @@ type HookSpec = { } output: {} } + "projectCopy.create.before": { + input: { + projectID: import("./project").Project.ID + sourceDirectory: import("./schema").AbsolutePath + context?: string + } + output: { + strategy: import("./project/copy").ProjectCopy.StrategyID + directory?: import("./schema").AbsolutePath + name?: string + error?: import("./project/copy").ProjectCopy.DestinationExistsError + } + } "aisdk.language": { input: { model: ModelV2.Info @@ -189,6 +203,7 @@ export const layer = Layer.effect( ) export const locationLayer = layer +export const node = LayerNode.make(layer, [EventV2.node]) // opencode // sdcok diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index b59769a0cf2..65364b67a06 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -21,6 +21,7 @@ import { AccountPlugin } from "./account" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { SkillPlugin } from "./skill" +import { ProjectCopyDestinationPlugin } from "./project-copy-destination" import { ConfigProviderPlugin } from "../config/plugin/provider" import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" @@ -101,6 +102,7 @@ export const layer = Layer.effect( yield* add(AgentPlugin.Plugin) yield* add(CommandPlugin.Plugin) yield* add(SkillPlugin.Plugin) + yield* add(ProjectCopyDestinationPlugin.Plugin) for (const item of ProviderPlugins) { yield* add(item) } diff --git a/packages/core/src/plugin/project-copy-destination.ts b/packages/core/src/plugin/project-copy-destination.ts new file mode 100644 index 00000000000..7978a6c6be5 --- /dev/null +++ b/packages/core/src/plugin/project-copy-destination.ts @@ -0,0 +1,72 @@ +export * as ProjectCopyDestinationPlugin from "./project-copy-destination" + +import path from "path" +import { Effect } from "effect" +import { Config } from "../config" +import { Global } from "../global" +import { Location } from "../location" +import { PluginV2 } from "../plugin" +import { ProjectCopy } from "../project/copy" +import { AbsolutePath } from "../schema" +import { FSUtil } from "../fs-util" +import { Slug } from "../util/slug" + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("project-copy-destination"), + effect: Effect.gen(function* () { + const config = Config.latest(yield* (yield* Config.Service).entries(), "projectCopy") + const global = yield* Global.Service + const location = yield* Location.Service + const fs = yield* FSUtil.Service + + return { + "projectCopy.create.before": Effect.fn(function* (event) { + event.strategy = event.strategy ?? config?.strategy + event.name ??= Slug.create() + event.name = sanitize(event.name) || Slug.create() + + if (!event.directory) { + let dest = path.join(location.project.directory, ".worktrees", event.name) + if (config?.directory) { + dest = config.directory + .replaceAll("{project.name}", path.basename(location.project.directory)) + .replaceAll("{project.directory}", location.project.directory) + .replaceAll("{project.id}", location.project.id) + .replaceAll("{name}", event.name) + } + + dest = + dest === "~" + ? global.home + : dest.startsWith("~/") + ? path.join(global.home, dest.slice(2)) + : path.resolve(location.project.directory, dest) + event.directory = AbsolutePath.make(path.dirname(dest)) + event.name = path.basename(dest) + } + + const name = event.name + let suffix = 1 + let destination = AbsolutePath.make(path.join(event.directory, name)) + while (yield* fs.existsSafe(destination)) { + suffix++ + if (suffix > 10) { + event.error = new ProjectCopy.DestinationExistsError({ directory: destination }) + return + } + event.name = `${name}-${suffix}` + destination = AbsolutePath.make(path.join(event.directory, event.name)) + } + }), + } + }), +}) + +function sanitize(input: string) { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") +} diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index de2beda9816..aeeb1195097 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -12,7 +12,7 @@ import { LayerNode } from "../effect/layer-node" import { Project } from "../project" import { ProjectDirectoryTable } from "./sql" import { makeStrategies } from "./copy-strategies" -import { Slug } from "../util/slug" +import { PluginV2 } from "../plugin" export const StrategyID = Schema.Literal("git_worktree") export type StrategyID = typeof StrategyID.Type @@ -26,7 +26,7 @@ export const CreateInput = Schema.Struct({ projectID: Project.ID, strategy: StrategyID, sourceDirectory: AbsolutePath, - directory: AbsolutePath, + directory: Schema.optional(AbsolutePath), name: Schema.optional(Schema.String), context: Schema.optional(Schema.String), }).annotate({ identifier: "ProjectCopy.CreateInput" }) @@ -71,11 +71,20 @@ export class StrategyNotFoundError extends Schema.TaggedErrorClass()( + "ProjectCopy.CreateUnavailableError", + { + directory: Schema.optional(AbsolutePath), + name: Schema.optional(Schema.String), + }, +) {} + export type Error = | SourceDirectoryNotFoundError | DestinationExistsError | DirectoryUnavailableError | StrategyNotFoundError + | CreateUnavailableError | Git.WorktreeError export interface Strategy { @@ -115,6 +124,7 @@ export const layer = Layer.effect( const git = yield* Git.Service const events = yield* EventV2.Service const db = (yield* Database.Service).db + const plugin = yield* PluginV2.Service const canonical = Effect.fnUntraced(function* (input: AbsolutePath) { const resolved = AbsolutePath.make(FSUtil.resolve(input)) @@ -192,21 +202,31 @@ export const layer = Layer.effect( }) const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) { - 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 resolved = yield* plugin.trigger( + "projectCopy.create.before", + { + projectID: input.projectID, + sourceDirectory: input.sourceDirectory, + context: input.context, + }, + { + strategy: input.strategy, + directory: input.directory, + name: input.name, + }, + ) + if (resolved.error) return yield* resolved.error + if (!resolved.directory || !resolved.name) + return yield* new CreateUnavailableError({ directory: resolved.directory, name: resolved.name }) - const result = yield* strategy(input.strategy).create({ + yield* fs.makeDirectory(resolved.directory, { recursive: true }).pipe(Effect.orDie) + const copyDirectory = AbsolutePath.make(path.join(resolved.directory, resolved.name)) + + const result = yield* strategy(resolved.strategy).create({ directory: copyDirectory, sourceDirectory: yield* source(input.sourceDirectory, input.projectID), }) - yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy)) + yield* changed(input.projectID, yield* insert(input.projectID, result.directory, resolved.strategy)) return result }) @@ -270,10 +290,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Database.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(EventV2.defaultLayer), -) -export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node]) +export const locationLayer = layer.pipe(Layer.provide(PluginV2.locationLayer)) + +export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node, PluginV2.node]) diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 2e773f71e25..c00a4943dd5 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -49,6 +49,17 @@ export const Info = Schema.Struct({ description: "@deprecated Use 'references' field instead. Named git or local directory references", }), watcher: Schema.optional(Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))) })), + projectCopy: Schema.optional( + Schema.Struct({ + strategy: Schema.optional(Schema.Literal("git_worktree")).annotate({ + description: "Strategy used to create project copies", + }), + directory: Schema.optional(Schema.String).annotate({ + description: + "Project copy path template supporting {project.name}, {project.directory}, {project.id}, and {name} placeholders", + }), + }), + ).annotate({ description: "Project copy configuration" }), snapshot: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 3b4f13868ed..9962e07214c 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -67,6 +67,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) { plugins: info.plugin?.map((plugin) => typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }, ), + projectCopy: info.projectCopy, experimental: info.experimental?.policies && { policies: info.experimental.policies }, providers: providers(info.provider), } diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 6275d8fed35..3f13a85539c 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -87,6 +87,22 @@ describe("Config", () => { }), ) + it.effect("migrates project copy configuration", () => + Effect.sync(() => { + expect( + ConfigMigrateV1.migrate({ + projectCopy: { + strategy: "git_worktree", + directory: "{project.directory}/.worktrees/{project.name}-{project.id}-{name}", + }, + }).projectCopy, + ).toEqual({ + strategy: "git_worktree", + directory: "{project.directory}/.worktrees/{project.name}-{project.id}-{name}", + }) + }), + ) + it.effect("migrates v1 provider setup options into AISDK settings", () => Effect.sync(() => { const migrated = ConfigMigrateV1.migrate({ diff --git a/packages/core/test/plugin/project-copy-destination.test.ts b/packages/core/test/plugin/project-copy-destination.test.ts new file mode 100644 index 00000000000..eca0c043b3a --- /dev/null +++ b/packages/core/test/plugin/project-copy-destination.test.ts @@ -0,0 +1,165 @@ +import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer } from "effect" +import { Config } from "@opencode-ai/core/config" +import { ConfigProjectCopy } from "@opencode-ai/core/config/project-copy" +import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProjectCopyDestinationPlugin } from "@opencode-ai/core/plugin/project-copy-destination" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "../fixture/tmpdir" +import { it } from "../lib/effect" + +const projectID = Project.ID.make("project-id") +const root = "/projects/example" +const databaseNode = LayerNode.make(Database.layerFromPath(":memory:"), []) +const configNode = makeConfig(new Config.Info({})) +const globalNode = LayerNode.make( + Layer.succeed(Global.Service, Global.Service.of(Global.make({ data: "/data", home: "/home/test" }))), + [], +) +const locationNode = LayerNode.make( + Layer.succeed( + Location.Service, + Location.Service.of({ + directory: AbsolutePath.make(root), + project: { id: projectID, directory: AbsolutePath.make(root) }, + }), + ), + [], +) +const installNode = LayerNode.make( + Layer.effectDiscard( + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const location = yield* Location.Service + yield* plugin.add({ + id: ProjectCopyDestinationPlugin.Plugin.id, + effect: ProjectCopyDestinationPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Global.Service, global), + Effect.provideService(Location.Service, location), + ), + }) + }), + ), + [PluginV2.node, configNode, globalNode, locationNode, FSUtil.node], +) +const graph = LayerNode.group([PluginV2.node, installNode]) + +function makeConfig(info: Config.Info) { + return LayerNode.make( + Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info, + }), + ]), + }), + ), + [], + ) +} + +function providePlugin(info = new Config.Info({})) { + return Effect.provide( + LayerNode.buildLayer(graph, { + replacements: [ + LayerNode.replace(Database.node, databaseNode), + LayerNode.replace(configNode, makeConfig(info)), + ], + }), + ) +} + +function trigger(input: { name?: string; directory?: AbsolutePath }) { + return PluginV2.Service.use((plugin) => + plugin.trigger( + "projectCopy.create.before", + { projectID, sourceDirectory: AbsolutePath.make("/projects/example") }, + { strategy: "git_worktree", ...input }, + ), + ) +} + +describe("ProjectCopyDestinationPlugin", () => { + it.effect( + "expands project variables and sanitizes the copy name", + () => + Effect.gen(function* () { + const result = yield* trigger({ name: "Fix / Auth: Please" }) + + expect(result.directory).toBe(AbsolutePath.make("/projects/example/copies")) + expect(result.name).toBe("example-project-id-fix-auth-please") + }).pipe( + providePlugin( + new Config.Info({ + projectCopy: new ConfigProjectCopy.Info({ + strategy: "git_worktree", + directory: "{project.directory}/copies/{project.name}-{project.id}-{name}", + }), + }), + ), + ), + ) + + it.effect("preserves an explicit directory and sanitizes the name", () => + Effect.gen(function* () { + const result = yield* trigger({ directory: AbsolutePath.make("/tmp/copies"), name: "A/B" }) + + expect(result.directory).toBe(AbsolutePath.make("/tmp/copies")) + expect(result.name).toBe("a-b") + }).pipe(providePlugin()), + ) + + it.live("adds a suffix when destinations already exist", () => + Effect.gen(function* () { + const directory = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (item) => Effect.promise(() => item[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => fs.mkdir(path.join(directory.path, "copy"))) + yield* Effect.promise(() => fs.mkdir(path.join(directory.path, "copy-2"))) + + const result = yield* trigger({ directory: AbsolutePath.make(directory.path), name: "copy" }) + + expect(result.name).toBe("copy-3") + expect(result.error).toBeUndefined() + }).pipe(providePlugin()), + ) + + it.live("returns an error after ten destination conflicts", () => + Effect.gen(function* () { + const directory = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (item) => Effect.promise(() => item[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => + Promise.all( + Array.from({ length: 10 }, (_, index) => + fs.mkdir(path.join(directory.path, index === 0 ? "copy" : `copy-${index + 1}`)), + ), + ), + ) + + const result = yield* trigger({ directory: AbsolutePath.make(directory.path), name: "copy" }) + + expect(result.error).toBeInstanceOf(Error) + expect(result.error?.directory).toBe(AbsolutePath.make(path.join(directory.path, "copy-10"))) + }).pipe(providePlugin()), + ) +}) diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index f1ba10fb091..a93e09c7aac 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -12,18 +12,21 @@ import { EventV2 } from "@opencode-ai/core/event" 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 { PluginV2 } from "@opencode-ai/core/plugin" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" const databaseLayer = Database.layerFromPath(":memory:") const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer)) +const pluginLayer = PluginV2.layer.pipe(Layer.provide(eventLayer)) const copyLayer = ProjectCopy.layer.pipe( Layer.provide(databaseLayer), Layer.provide(eventLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(pluginLayer), ) -const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer)) +const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, pluginLayer)) function abs(input: string) { return AbsolutePath.make(input) @@ -131,6 +134,58 @@ describe("ProjectCopy", () => { }), ) + it.live("runs the before create hook before resolving the destination", () => + Effect.gen(function* () { + const input = yield* setup() + const copy = yield* ProjectCopy.Service + const plugins = yield* PluginV2.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-hook")) + const target = abs(path.join(parent, "configured")) + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore), + ) + yield* plugins.add({ + id: PluginV2.ID.make("project-copy-test"), + effect: Effect.succeed({ + "projectCopy.create.before": Effect.fn(function* (event) { + event.directory = parent + event.name = "configured" + }), + }), + }) + + const created = yield* copy.create({ + projectID: input.projectID, + strategy: "git_worktree", + sourceDirectory: input.sourceDirectory, + directory: abs(path.join(temp, "unused")), + name: "unused", + }) + + expect(created.directory).toBe(target) + yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false }) + }), + ) + + it.live("fails when the before create hook leaves the destination incomplete", () => + Effect.gen(function* () { + const input = yield* setup() + const copy = yield* ProjectCopy.Service + + const error = yield* copy + .create({ + projectID: input.projectID, + strategy: "git_worktree", + sourceDirectory: input.sourceDirectory, + }) + .pipe(Effect.flip) + + expect(error).toBeInstanceOf(ProjectCopy.CreateUnavailableError) + expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" }]) + }), + ) + it.live("requires force to remove a dirty git worktree", () => Effect.gen(function* () { const input = yield* setup() @@ -166,71 +221,6 @@ describe("ProjectCopy", () => { }), ) - it.live("adds a numeric suffix when a copy directory already exists", () => - Effect.gen(function* () { - const input = yield* setup() - const copy = yield* ProjectCopy.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")) - 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"))) - - const created = yield* copy.create({ - projectID: input.projectID, - strategy: "git_worktree", - sourceDirectory: input.sourceDirectory, - directory: parent, - name: "copy", - }) - - 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, - ) - - yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false }) - }), - ) - - it.live("fails after ten copy directory conflicts", () => - Effect.gen(function* () { - const input = yield* setup() - const copy = yield* ProjectCopy.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")) - 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 }), - ), - ), - ) - - const error = yield* copy - .create({ - projectID: input.projectID, - strategy: "git_worktree", - sourceDirectory: input.sourceDirectory, - directory: parent, - name: "copy", - }) - .pipe(Effect.flip) - - expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError) - expect(error.directory).toBe(abs(path.join(parent, "copy-10"))) - }), - ) - it.live("does not publish an event when refresh finds no directory changes", () => Effect.gen(function* () { const input = yield* setup() diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts index 98dc4f3c356..0b2ca8963c2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts @@ -17,8 +17,8 @@ const CopyQuery = Schema.Struct({ }) export const CreatePayload = Schema.Struct({ - strategy: ProjectCopy.StrategyID, - directory: ProjectCopy.CreateInput.fields.directory, + strategy: Schema.optional(ProjectCopy.CreateInput.fields.strategy), + directory: Schema.optional(ProjectCopy.CreateInput.fields.directory), name: ProjectCopy.CreateInput.fields.name, context: ProjectCopy.CreateInput.fields.context, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts index 647c7b6e739..ca0fff34635 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts @@ -2,8 +2,10 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy" import { Git } from "@opencode-ai/core/git" import { ProjectV2 } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { InstanceState } from "@/effect/instance-state" -import { Effect, Stream } from "effect" +import { Effect, Layer, Stream } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy" @@ -43,7 +45,7 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec const llm = yield* LLM.Service const agent = yield* Agent.Service const provider = yield* Provider.Service - const service = yield* ProjectCopy.Service + const locations = yield* LocationServiceMap const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) { const text = context?.trim() @@ -85,11 +87,7 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec }, ], }) - .pipe( - Stream.filter(LLMEvent.is.textDelta), - Stream.map((event) => event.text), - Stream.mkString, - ) + .pipe(Stream.filter(LLMEvent.is.textDelta), Stream.map((event) => event.text), Stream.mkString) const output = result.trim() return output ? slugify(output.split(/\s+/).slice(0, 4).join(" ")) : Slug.create() }) @@ -101,13 +99,18 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec const name = ctx.payload.name ?? (yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create())))) + const instance = yield* InstanceState.context return yield* badRequest( - service.create({ - ...ctx.payload, - name, - projectID: ctx.params.projectID, - sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree), - }), + Effect.gen(function* () { + yield* (yield* PluginBoot.Service).wait() + return yield* (yield* ProjectCopy.Service).create({ + ...ctx.payload, + strategy: ctx.payload.strategy ?? "git_worktree", + name, + projectID: ctx.params.projectID, + sourceDirectory: AbsolutePath.make(instance.worktree), + }) + }).pipe(Effect.provide(locations.get({ directory: AbsolutePath.make(instance.directory) }))), ) }) @@ -115,25 +118,29 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec params: { projectID: ProjectV2.ID } payload: typeof RemovePayload.Type }) { + const instance = yield* InstanceState.context yield* badRequest( - service.remove({ - ...ctx.payload, - projectID: ctx.params.projectID, - }), + ProjectCopy.Service.use((service) => + service.remove({ + ...ctx.payload, + projectID: ctx.params.projectID, + }), + ).pipe(Effect.provide(locations.get({ directory: AbsolutePath.make(instance.directory) }))), ) }) const refresh = Effect.fn("ProjectCopyHttpApi.refresh")(function* (ctx: { params: { projectID: ProjectV2.ID } }) { + const instance = yield* InstanceState.context yield* badRequest( - service.refresh({ - projectID: ctx.params.projectID, - }), + ProjectCopy.Service.use((service) => service.refresh({ projectID: ctx.params.projectID })).pipe( + Effect.provide(locations.get({ directory: AbsolutePath.make(instance.directory) })), + ), ) }) return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh) }), -) +).pipe(Layer.provide(LocationServiceMap.layer)) function slugify(input: string) { return input @@ -153,5 +160,7 @@ function message(error: ProjectCopy.Error) { return `Project copy directory unavailable: ${error.directory}` if (error instanceof ProjectCopy.StrategyNotFoundError) return `Project copy strategy not found for: ${error.directory}` + if (error instanceof ProjectCopy.CreateUnavailableError) + return "Project copy directory and name could not be resolved" return error.message } diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index d1689dd7fa0..8b08c0fb4aa 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -228,7 +228,6 @@ export function createRoutes( Plugin.defaultLayer, Project.defaultLayer, ProjectV2.defaultLayer, - ProjectCopy.defaultLayer, MoveSession.defaultLayer, ProviderAuth.defaultLayer, Provider.defaultLayer, diff --git a/packages/opencode/test/server/project-copy.test.ts b/packages/opencode/test/server/project-copy.test.ts index 61d9b4c0e86..42bfe74deed 100644 --- a/packages/opencode/test/server/project-copy.test.ts +++ b/packages/opencode/test/server/project-copy.test.ts @@ -106,4 +106,43 @@ describe("project directories and copies endpoints", () => { }), { git: true }, ) + + it.instance( + "expands the configured project copy directory template", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const current = yield* request(test.directory, "/project/current") + const projectID = (yield* json<{ id: string }>(current)).id + const response = yield* request(test.directory, `/experimental/project/${projectID}/copy`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Fix / Auth: Please" }), + }) + + expect(response.status).toBe(200) + expect((yield* json<{ directory: string }>(response)).directory).toBe( + path.join(test.directory, "copies", `${path.basename(test.directory)}-${projectID}-fix-auth-please`), + ) + + const duplicate = yield* request(test.directory, `/experimental/project/${projectID}/copy`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Fix / Auth: Please" }), + }) + expect(duplicate.status).toBe(200) + expect((yield* json<{ directory: string }>(duplicate)).directory).toBe( + path.join(test.directory, "copies", `${path.basename(test.directory)}-${projectID}-fix-auth-please-2`), + ) + }), + { + git: true, + config: { + projectCopy: { + strategy: "git_worktree", + directory: "{project.directory}/copies/{project.name}-{project.id}-{name}", + }, + }, + }, + ) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7f5e765a41a..2ff81d1a74e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -67,6 +67,7 @@ export type Event = | EventQuestionV2Replied | EventQuestionV2Rejected | EventTodoUpdated + | EventProjectDirectoriesUpdated | EventLspUpdated | EventPermissionAsked | EventPermissionReplied @@ -77,7 +78,6 @@ export type Event = | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted - | EventProjectDirectoriesUpdated | EventProjectUpdated | EventVcsBranchUpdated | EventQuestionAsked @@ -1378,6 +1378,13 @@ export type GlobalEvent = { todos: Array } } + | { + id: string + type: "project.directories.updated" + properties: { + projectID: string + } + } | { id: string type: "lsp.updated" @@ -1488,13 +1495,6 @@ export type GlobalEvent = { messageID: string } } - | { - id: string - type: "project.directories.updated" - properties: { - projectID: string - } - } | { id: string type: "project.updated" @@ -1937,6 +1937,10 @@ export type Config = { watcher?: { ignore?: Array } + projectCopy?: { + strategy?: "git_worktree" + directory?: string + } snapshot?: boolean plugin?: Array< | string @@ -5005,6 +5009,14 @@ export type EventTodoUpdated = { } } +export type EventProjectDirectoriesUpdated = { + id: string + type: "project.directories.updated" + properties: { + projectID: string + } +} + export type EventLspUpdated = { id: string type: "lsp.updated" @@ -5070,14 +5082,6 @@ export type EventCommandExecuted = { } } -export type EventProjectDirectoriesUpdated = { - id: string - type: "project.directories.updated" - properties: { - projectID: string - } -} - export type EventProjectUpdated = { id: string type: "project.updated" @@ -7013,8 +7017,8 @@ export type ExperimentalProjectCopyRemoveResponse = export type ExperimentalProjectCopyCreateData = { body?: { - strategy: "git_worktree" - directory: string + strategy?: "git_worktree" + directory?: string name?: string context?: string } diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index 56107fd8b13..579743cd261 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -1,6 +1,4 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import path from "path" -import { useTuiPaths } from "../../context/runtime" import { errorMessage } from "../../util/error" import { useDialog } from "../../ui/dialog" import { useSDK } from "../../context/sdk" @@ -20,7 +18,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess const sync = useSync() const toast = useToast() const homeDestination = useHomeSessionDestination() - const paths = useTuiPaths() const [creating, setCreating] = createSignal(false) const [creatingDots, setCreatingDots] = createSignal(3) const [progress, setProgress] = createSignal() @@ -34,8 +31,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess const result = await sdk.client.experimental.projectCopy.create( { projectID, - strategy: "git_worktree", - directory: path.join(paths.worktree, projectID.slice(0, 6)), context, }, { throwOnError: true },