wip
This commit is contained in:
parent
6e2bcafd34
commit
78b7781ab9
18 changed files with 478 additions and 128 deletions
|
|
@ -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({
|
||||
|
|
|
|||
165
packages/core/test/plugin/project-copy-destination.test.ts
Normal file
165
packages/core/test/plugin/project-copy-destination.test.ts
Normal 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()),
|
||||
)
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue