This commit is contained in:
James Long 2026-06-11 10:06:41 -04:00
commit 78b7781ab9
18 changed files with 478 additions and 128 deletions

View file

@ -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<Info>("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),
}) {}

View file

@ -0,0 +1,8 @@
export * as ConfigProjectCopy from "./project-copy"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("ConfigProjectCopy.Info")({
strategy: Schema.Literal("git_worktree").pipe(Schema.optional),
directory: Schema.String.pipe(Schema.optional),
}) {}

View file

@ -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<LocationServiceMap>()("
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<LocationServiceMap>()("
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
AppProcess.defaultLayer,
Git.defaultLayer,
Global.defaultLayer,
Ripgrep.defaultLayer,
Database.defaultLayer,

View file

@ -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

View file

@ -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)
}

View file

@ -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(/-+$/, "")
}

View file

@ -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<StrategyNotFo
{ directory: AbsolutePath },
) {}
export class CreateUnavailableError extends Schema.TaggedErrorClass<CreateUnavailableError>()(
"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])

View file

@ -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.",

View file

@ -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),
}

View file

@ -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({

View file

@ -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()),
)
})

View file

@ -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()