feat(plugin): add vcs backend registration api
This commit is contained in:
parent
38502f7268
commit
66878b4c53
12 changed files with 309 additions and 8 deletions
|
|
@ -47,6 +47,7 @@ import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
import { ToolRegistry } from "./tool/registry"
|
import { ToolRegistry } from "./tool/registry"
|
||||||
import { ToolOutputStore } from "./tool-output-store"
|
import { ToolOutputStore } from "./tool-output-store"
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
|
import { VcsBackends } from "./vcs/backends"
|
||||||
|
|
||||||
export { LocationServiceMap } from "./location-service-map"
|
export { LocationServiceMap } from "./location-service-map"
|
||||||
|
|
||||||
|
|
@ -97,6 +98,7 @@ export const locationServices = LayerNode.group([
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
SessionRunnerLLM.node,
|
SessionRunnerLLM.node,
|
||||||
|
VcsBackends.node,
|
||||||
Vcs.node,
|
Vcs.node,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { SkillV2 } from "./skill"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
import { ToolRegistry } from "./tool/registry"
|
import { ToolRegistry } from "./tool/registry"
|
||||||
import { ToolHooks } from "./tool/hooks"
|
import { ToolHooks } from "./tool/hooks"
|
||||||
|
import { VcsBackends } from "./vcs/backends"
|
||||||
|
|
||||||
export const ID = Plugin.ID
|
export const ID = Plugin.ID
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
|
@ -167,6 +168,7 @@ export const node = makeLocationNode({
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
ToolHooks.node,
|
ToolHooks.node,
|
||||||
|
VcsBackends.node,
|
||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { AbsolutePath, type DeepMutable } from "../schema"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
import { Tools } from "../tool/tools"
|
import { Tools } from "../tool/tools"
|
||||||
import { ToolHooks } from "../tool/hooks"
|
import { ToolHooks } from "../tool/hooks"
|
||||||
|
import { VcsBackends } from "../vcs/backends"
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
|
|
||||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||||
|
|
@ -33,6 +34,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* Tools.Service
|
||||||
const toolHooks = yield* ToolHooks.Service
|
const toolHooks = yield* ToolHooks.Service
|
||||||
|
const vcsBackends = yield* VcsBackends.Service
|
||||||
const runtime = yield* PluginRuntime.Service
|
const runtime = yield* PluginRuntime.Service
|
||||||
const locationInfo = () =>
|
const locationInfo = () =>
|
||||||
new Location.Info({
|
new Location.Info({
|
||||||
|
|
@ -291,6 +293,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
vcs: {
|
||||||
|
register: (backend) => vcsBackends.register(backend),
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
runtime.session.create({
|
runtime.session.create({
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import { SkillV2 } from "../skill"
|
||||||
import { State } from "../state"
|
import { State } from "../state"
|
||||||
import { ToolRegistry } from "../tool/registry"
|
import { ToolRegistry } from "../tool/registry"
|
||||||
import { Tools } from "../tool/tools"
|
import { Tools } from "../tool/tools"
|
||||||
|
import { VcsBackends } from "../vcs/backends"
|
||||||
import { HttpClient } from "effect/unstable/http"
|
import { HttpClient } from "effect/unstable/http"
|
||||||
import { AgentPlugin } from "./agent"
|
import { AgentPlugin } from "./agent"
|
||||||
import { CommandPlugin } from "./command"
|
import { CommandPlugin } from "./command"
|
||||||
|
|
@ -67,6 +68,7 @@ export type Requirements =
|
||||||
| Shell.Service
|
| Shell.Service
|
||||||
| SkillV2.Service
|
| SkillV2.Service
|
||||||
| Tools.Service
|
| Tools.Service
|
||||||
|
| VcsBackends.Service
|
||||||
|
|
||||||
export interface Plugin<R = never> {
|
export interface Plugin<R = never> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
|
|
@ -102,6 +104,7 @@ const layer = Layer.effectDiscard(
|
||||||
Context.make(Ripgrep.Service, yield* Ripgrep.Service),
|
Context.make(Ripgrep.Service, yield* Ripgrep.Service),
|
||||||
Context.make(Shell.Service, yield* Shell.Service),
|
Context.make(Shell.Service, yield* Shell.Service),
|
||||||
Context.make(Tools.Service, yield* Tools.Service),
|
Context.make(Tools.Service, yield* Tools.Service),
|
||||||
|
Context.make(VcsBackends.Service, yield* VcsBackends.Service),
|
||||||
Context.make(PluginRuntime.Service, yield* PluginRuntime.Service),
|
Context.make(PluginRuntime.Service, yield* PluginRuntime.Service),
|
||||||
)
|
)
|
||||||
const add = (input: Plugin<Requirements | Scope.Scope>) =>
|
const add = (input: Plugin<Requirements | Scope.Scope>) =>
|
||||||
|
|
@ -158,6 +161,7 @@ export const node = makeLocationNode({
|
||||||
Ripgrep.node,
|
Ripgrep.node,
|
||||||
Shell.node,
|
Shell.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
|
VcsBackends.node,
|
||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
SdkPlugins.node,
|
SdkPlugins.node,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { makeLocationNode } from "./effect/app-node"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { AppProcess } from "./process"
|
import { AppProcess } from "./process"
|
||||||
|
import { VcsBackends } from "./vcs/backends"
|
||||||
import { VcsGit } from "./vcs/git"
|
import { VcsGit } from "./vcs/git"
|
||||||
import { VcsHg } from "./vcs/hg"
|
import { VcsHg } from "./vcs/hg"
|
||||||
|
|
||||||
|
|
@ -17,16 +18,13 @@ export interface DiffOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly status: () => Effect.Effect<FileStatus[]>
|
readonly status: () => Effect.Effect<readonly FileStatus[]>
|
||||||
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
|
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<readonly FileDiff.Info[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Vcs") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Vcs") {}
|
||||||
|
|
||||||
// Adapter seam: one working-copy implementation per VCS type, selected by the
|
const builtIn = (proc: AppProcess.Interface, fs: FSUtil.Interface, location: Location.Interface) => {
|
||||||
// resolved location. Locations without a supported VCS degrade to empty
|
|
||||||
// results so callers never need to special-case.
|
|
||||||
const adapter = (proc: AppProcess.Interface, fs: FSUtil.Interface, location: Location.Interface) => {
|
|
||||||
const scope = { directory: location.directory, worktree: location.project.directory }
|
const scope = { directory: location.directory, worktree: location.project.directory }
|
||||||
if (location.vcs?.type === "git") return VcsGit.make(proc, scope)
|
if (location.vcs?.type === "git") return VcsGit.make(proc, scope)
|
||||||
if (location.vcs?.type === "hg") return VcsHg.make(proc, fs, scope)
|
if (location.vcs?.type === "hg") return VcsHg.make(proc, fs, scope)
|
||||||
|
|
@ -38,13 +36,29 @@ const layer = Layer.effect(
|
||||||
const proc = yield* AppProcess.Service
|
const proc = yield* AppProcess.Service
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const impl = adapter(proc, fs, location)
|
const backends = yield* VcsBackends.Service
|
||||||
|
const native = builtIn(proc, fs, location)
|
||||||
|
let warned = false
|
||||||
|
|
||||||
|
const adapter = Effect.fnUntraced(function* () {
|
||||||
|
if (native) return native
|
||||||
|
if (!location.vcs) return undefined
|
||||||
|
const plugin = backends.get(location.vcs.type)
|
||||||
|
if (!plugin && !warned) {
|
||||||
|
warned = true
|
||||||
|
yield* Effect.logWarning("vcs backend declared but not registered", { type: location.vcs.type })
|
||||||
|
}
|
||||||
|
return plugin
|
||||||
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
status: Effect.fn("Vcs.status")(function* () {
|
status: Effect.fn("Vcs.status")(function* () {
|
||||||
|
const impl = yield* adapter()
|
||||||
if (!impl) return []
|
if (!impl) return []
|
||||||
return yield* impl.status()
|
return yield* impl.status()
|
||||||
}),
|
}),
|
||||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
|
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
|
||||||
|
const impl = yield* adapter()
|
||||||
if (!impl) return []
|
if (!impl) return []
|
||||||
return yield* impl.diff(mode, options)
|
return yield* impl.diff(mode, options)
|
||||||
}),
|
}),
|
||||||
|
|
@ -55,5 +69,5 @@ const layer = Layer.effect(
|
||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
layer: layer,
|
||||||
deps: [AppProcess.node, FSUtil.node, Location.node],
|
deps: [AppProcess.node, FSUtil.node, Location.node, VcsBackends.node],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
103
packages/core/src/vcs/backends.ts
Normal file
103
packages/core/src/vcs/backends.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
export * as VcsBackends from "./backends"
|
||||||
|
|
||||||
|
import { Vcs } from "@opencode-ai/plugin/v2/effect"
|
||||||
|
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
|
import { FileStatus } from "@opencode-ai/schema/vcs"
|
||||||
|
import { Context, Effect, Exit, Layer, Option, Schema } from "effect"
|
||||||
|
import type { Scope } from "effect"
|
||||||
|
import { ConfigVcs } from "../config/vcs"
|
||||||
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
import { Location } from "../location"
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly register: (backend: Vcs.Backend) => Effect.Effect<void, Vcs.RegistrationError, Scope.Scope>
|
||||||
|
readonly get: (type: string) => Vcs.Adapter | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/VcsBackends") {}
|
||||||
|
|
||||||
|
const decodeType = Schema.decodeUnknownOption(ConfigVcs.Type)
|
||||||
|
const decodeStatus = Schema.decodeUnknownOption(Schema.Array(FileStatus))
|
||||||
|
const decodeDiff = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
readonly backend: Vcs.Backend
|
||||||
|
adapter?: Vcs.Adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const registry = new Map<string, Entry>()
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
register: (backend) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (Option.isNone(decodeType(backend.type))) {
|
||||||
|
return yield* new Vcs.RegistrationError({
|
||||||
|
type: backend.type,
|
||||||
|
message: `Invalid vcs backend type '${backend.type}'`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (registry.has(backend.type)) {
|
||||||
|
return yield* new Vcs.RegistrationError({
|
||||||
|
type: backend.type,
|
||||||
|
message: `Vcs backend '${backend.type}' is already registered`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
registry.set(backend.type, { backend })
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => registry.delete(backend.type)))
|
||||||
|
}),
|
||||||
|
get: (type) => {
|
||||||
|
const vcs = location.vcs
|
||||||
|
const entry = registry.get(type)
|
||||||
|
if (!entry || vcs?.type !== type) return undefined
|
||||||
|
entry.adapter ??= guard(type, () =>
|
||||||
|
entry.backend.make({
|
||||||
|
directory: location.directory,
|
||||||
|
worktree: location.project.directory,
|
||||||
|
store: vcs.store,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return entry.adapter
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer: layer, deps: [Location.node] })
|
||||||
|
|
||||||
|
function guard(type: string, make: () => Vcs.Adapter): Vcs.Adapter {
|
||||||
|
let underlying: Vcs.Adapter | undefined
|
||||||
|
const adapter = Effect.sync(() => (underlying ??= make()))
|
||||||
|
return {
|
||||||
|
status: () => adapter.pipe(Effect.flatMap((impl) => impl.status()), sanitize(type, "status", decodeStatus)),
|
||||||
|
diff: (mode, options) =>
|
||||||
|
adapter.pipe(
|
||||||
|
Effect.flatMap((impl) => impl.diff(mode, options)),
|
||||||
|
sanitize(type, "diff", decodeDiff),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize<A>(type: string, operation: string, decode: (input: unknown) => Option.Option<readonly A[]>) {
|
||||||
|
return <E, R>(effect: Effect.Effect<readonly A[], E, R>) =>
|
||||||
|
effect.pipe(
|
||||||
|
Effect.exit,
|
||||||
|
Effect.flatMap((exit) => {
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
return Effect.logWarning("vcs backend failed", { type, operation, cause: exit.cause }).pipe(
|
||||||
|
Effect.as([] as readonly A[]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Option.match(decode(exit.value), {
|
||||||
|
onNone: () =>
|
||||||
|
Effect.logWarning("vcs backend returned invalid data", { type, operation }).pipe(
|
||||||
|
Effect.as([] as readonly A[]),
|
||||||
|
),
|
||||||
|
onSome: (value) => Effect.succeed(value),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import { Reference } from "@opencode-ai/core/reference"
|
||||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
|
import { VcsBackends } from "@opencode-ai/core/vcs/backends"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { tempLocationLayer } from "../fixture/location"
|
import { tempLocationLayer } from "../fixture/location"
|
||||||
|
|
||||||
|
|
@ -50,6 +51,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolHooks.node,
|
ToolHooks.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
|
VcsBackends.node,
|
||||||
]),
|
]),
|
||||||
[
|
[
|
||||||
[Location.node, tempLocationLayer],
|
[Location.node, tempLocationLayer],
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,9 @@ export function host(overrides: Overrides = {}): PluginContext {
|
||||||
command: () => Effect.die("unused session.command"),
|
command: () => Effect.die("unused session.command"),
|
||||||
interrupt: () => Effect.die("unused session.interrupt"),
|
interrupt: () => Effect.die("unused session.interrupt"),
|
||||||
},
|
},
|
||||||
|
vcs: overrides.vcs ?? {
|
||||||
|
register: () => Effect.die("unused vcs.register"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
129
packages/core/test/vcs-backends.test.ts
Normal file
129
packages/core/test/vcs-backends.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Exit, Layer, Scope } from "effect"
|
||||||
|
import { Vcs as PluginVcs } from "@opencode-ai/plugin/v2/effect"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { Vcs } from "@opencode-ai/core/vcs"
|
||||||
|
import { VcsBackends } from "@opencode-ai/core/vcs/backends"
|
||||||
|
import { location } from "./fixture/location"
|
||||||
|
import { it } from "./lib/effect"
|
||||||
|
|
||||||
|
const directory = AbsolutePath.make("/repo")
|
||||||
|
|
||||||
|
const provide = Effect.provide(
|
||||||
|
LayerNode.compile(LayerNode.group([Vcs.node, VcsBackends.node]), [
|
||||||
|
[
|
||||||
|
Location.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(
|
||||||
|
location({ directory }, { vcs: { type: "fake", store: AbsolutePath.make("/repo/.fake") } }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const status = [{ file: "a.txt", additions: 1, deletions: 0, status: "added" as const }]
|
||||||
|
const diff = [{ file: "a.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" as const }]
|
||||||
|
|
||||||
|
const backend = (overrides: Partial<PluginVcs.Adapter> = {}): PluginVcs.Backend => ({
|
||||||
|
type: "fake",
|
||||||
|
make: () => ({
|
||||||
|
status: () => Effect.succeed(status),
|
||||||
|
diff: () => Effect.succeed(diff),
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const register = (input: PluginVcs.Backend) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const backends = yield* VcsBackends.Service
|
||||||
|
return yield* backends.register(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("VcsBackends", () => {
|
||||||
|
it.live("serves status and diff through a registered backend", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* register(backend())
|
||||||
|
const vcs = yield* Vcs.Service
|
||||||
|
expect(yield* vcs.status()).toEqual(status)
|
||||||
|
expect(yield* vcs.diff("working")).toEqual(diff)
|
||||||
|
}).pipe(Effect.scoped, provide),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("passes the location scope to the adapter factory", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let scope: PluginVcs.AdapterScope | undefined
|
||||||
|
yield* register({
|
||||||
|
type: "fake",
|
||||||
|
make: (input) => {
|
||||||
|
scope = input
|
||||||
|
return backend().make(input)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
yield* (yield* Vcs.Service).status()
|
||||||
|
expect(scope).toEqual({ directory: "/repo", worktree: "/repo", store: "/repo/.fake" })
|
||||||
|
}).pipe(Effect.scoped, provide),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("returns empty results when no backend is registered", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const vcs = yield* Vcs.Service
|
||||||
|
expect(yield* vcs.status()).toEqual([])
|
||||||
|
expect(yield* vcs.diff("working")).toEqual([])
|
||||||
|
}).pipe(Effect.scoped, provide),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("frees the type when the registration scope closes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const backends = yield* VcsBackends.Service
|
||||||
|
const scope = yield* Scope.make()
|
||||||
|
yield* backends.register(backend()).pipe(Scope.provide(scope))
|
||||||
|
expect((yield* (yield* Vcs.Service).status()).length).toBe(1)
|
||||||
|
yield* Scope.close(scope, Exit.void)
|
||||||
|
expect(yield* (yield* Vcs.Service).status()).toEqual([])
|
||||||
|
yield* register(backend())
|
||||||
|
}).pipe(Effect.scoped, provide),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("rejects duplicate and reserved types", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* register(backend())
|
||||||
|
const duplicate = yield* register(backend()).pipe(Effect.exit)
|
||||||
|
expect(Exit.isFailure(duplicate)).toBe(true)
|
||||||
|
const reserved = yield* register({ ...backend(), type: "git" }).pipe(Effect.exit)
|
||||||
|
expect(Exit.isFailure(reserved)).toBe(true)
|
||||||
|
const invalid = yield* register({ ...backend(), type: "Not A Slug" }).pipe(Effect.exit)
|
||||||
|
expect(Exit.isFailure(invalid)).toBe(true)
|
||||||
|
}).pipe(Effect.scoped, provide),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("degrades failing adapters to empty results", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* register(
|
||||||
|
backend({
|
||||||
|
status: () => Effect.die(new Error("backend exploded")),
|
||||||
|
diff: () => Effect.sync(() => {
|
||||||
|
throw new Error("sync explosion")
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const vcs = yield* Vcs.Service
|
||||||
|
expect(yield* vcs.status()).toEqual([])
|
||||||
|
expect(yield* vcs.diff("working")).toEqual([])
|
||||||
|
}).pipe(Effect.scoped, provide),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("drops rows that fail schema validation", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* register(
|
||||||
|
backend({
|
||||||
|
status: () => Effect.succeed([{ file: "a.txt", additions: -1, deletions: 0, status: "added" }]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(yield* (yield* Vcs.Service).status()).toEqual([])
|
||||||
|
}).pipe(Effect.scoped, provide),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -10,6 +10,7 @@ import type { SkillHooks } from "./skill.js"
|
||||||
import type { Reload } from "./registration.js"
|
import type { Reload } from "./registration.js"
|
||||||
import type { ToolDomain } from "./tool.js"
|
import type { ToolDomain } from "./tool.js"
|
||||||
import type { SessionDomain } from "./runtime.js"
|
import type { SessionDomain } from "./runtime.js"
|
||||||
|
import type { VcsDomain } from "./vcs.js"
|
||||||
|
|
||||||
export interface PluginContext {
|
export interface PluginContext {
|
||||||
readonly options: PluginOptions
|
readonly options: PluginOptions
|
||||||
|
|
@ -23,4 +24,5 @@ export interface PluginContext {
|
||||||
readonly skill: SkillHooks & Reload
|
readonly skill: SkillHooks & Reload
|
||||||
readonly tool: ToolDomain
|
readonly tool: ToolDomain
|
||||||
readonly session: SessionDomain
|
readonly session: SessionDomain
|
||||||
|
readonly vcs: VcsDomain
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,5 @@ export type { Plugin } from "./plugin.js"
|
||||||
export * as Tool from "./tool.js"
|
export * as Tool from "./tool.js"
|
||||||
export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js"
|
export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js"
|
||||||
export type { SessionDomain } from "./runtime.js"
|
export type { SessionDomain } from "./runtime.js"
|
||||||
|
export { Vcs } from "./vcs.js"
|
||||||
|
export type { VcsDomain } from "./vcs.js"
|
||||||
|
|
|
||||||
33
packages/plugin/src/v2/effect/vcs.ts
Normal file
33
packages/plugin/src/v2/effect/vcs.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export * as Vcs from "./vcs.js"
|
||||||
|
|
||||||
|
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
|
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||||
|
import { Schema, type Effect, type Scope } from "effect"
|
||||||
|
|
||||||
|
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Vcs.RegistrationError", {
|
||||||
|
type: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export interface AdapterScope {
|
||||||
|
readonly directory: string
|
||||||
|
readonly worktree: string
|
||||||
|
readonly store: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Adapter {
|
||||||
|
readonly status: () => Effect.Effect<readonly FileStatus[]>
|
||||||
|
readonly diff: (
|
||||||
|
mode: Mode,
|
||||||
|
options?: { readonly context?: number },
|
||||||
|
) => Effect.Effect<readonly FileDiff.Info[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Backend {
|
||||||
|
readonly type: string
|
||||||
|
readonly make: (scope: AdapterScope) => Adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VcsDomain {
|
||||||
|
readonly register: (backend: Backend) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue