From 66878b4c53edd2dd852377a20fb685576ded9c97 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 3 Jul 2026 18:45:59 +0530 Subject: [PATCH] feat(plugin): add vcs backend registration api --- packages/core/src/location-services.ts | 2 + packages/core/src/plugin.ts | 2 + packages/core/src/plugin/host.ts | 5 + packages/core/src/plugin/internal.ts | 4 + packages/core/src/vcs.ts | 30 ++++-- packages/core/src/vcs/backends.ts | 103 ++++++++++++++++++ packages/core/test/plugin/fixture.ts | 2 + packages/core/test/plugin/host.ts | 3 + packages/core/test/vcs-backends.test.ts | 129 +++++++++++++++++++++++ packages/plugin/src/v2/effect/context.ts | 2 + packages/plugin/src/v2/effect/index.ts | 2 + packages/plugin/src/v2/effect/vcs.ts | 33 ++++++ 12 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/vcs/backends.ts create mode 100644 packages/core/test/vcs-backends.test.ts create mode 100644 packages/plugin/src/v2/effect/vcs.ts diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 830b9b48be..446fa84ec1 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -47,6 +47,7 @@ import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" import { ToolOutputStore } from "./tool-output-store" import { Vcs } from "./vcs" +import { VcsBackends } from "./vcs/backends" export { LocationServiceMap } from "./location-service-map" @@ -97,6 +98,7 @@ export const locationServices = LayerNode.group([ SessionTitle.node, Snapshot.node, SessionRunnerLLM.node, + VcsBackends.node, Vcs.node, ]) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index dd80647a73..56612dfcff 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -19,6 +19,7 @@ import { SkillV2 } from "./skill" import { State } from "./state" import { ToolRegistry } from "./tool/registry" import { ToolHooks } from "./tool/hooks" +import { VcsBackends } from "./vcs/backends" export const ID = Plugin.ID export type ID = typeof ID.Type @@ -167,6 +168,7 @@ export const node = makeLocationNode({ SkillV2.node, ToolRegistry.toolsNode, ToolHooks.node, + VcsBackends.node, PluginRuntime.node, ], }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 00ff7c82af..ba11098d16 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -18,6 +18,7 @@ import { AbsolutePath, type DeepMutable } from "../schema" import { SkillV2 } from "../skill" import { Tools } from "../tool/tools" import { ToolHooks } from "../tool/hooks" +import { VcsBackends } from "../vcs/backends" import { WorkspaceV2 } from "../workspace" const mutable = (value: T) => value as DeepMutable @@ -33,6 +34,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int const skill = yield* SkillV2.Service const tools = yield* Tools.Service const toolHooks = yield* ToolHooks.Service + const vcsBackends = yield* VcsBackends.Service const runtime = yield* PluginRuntime.Service const locationInfo = () => 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: { create: (input) => runtime.session.create({ diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index f522e85a66..e334f0e9b2 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -33,6 +33,7 @@ import { SkillV2 } from "../skill" import { State } from "../state" import { ToolRegistry } from "../tool/registry" import { Tools } from "../tool/tools" +import { VcsBackends } from "../vcs/backends" import { HttpClient } from "effect/unstable/http" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" @@ -67,6 +68,7 @@ export type Requirements = | Shell.Service | SkillV2.Service | Tools.Service + | VcsBackends.Service export interface Plugin { readonly id: string @@ -102,6 +104,7 @@ const layer = Layer.effectDiscard( Context.make(Ripgrep.Service, yield* Ripgrep.Service), Context.make(Shell.Service, yield* Shell.Service), Context.make(Tools.Service, yield* Tools.Service), + Context.make(VcsBackends.Service, yield* VcsBackends.Service), Context.make(PluginRuntime.Service, yield* PluginRuntime.Service), ) const add = (input: Plugin) => @@ -158,6 +161,7 @@ export const node = makeLocationNode({ Ripgrep.node, Shell.node, ToolRegistry.toolsNode, + VcsBackends.node, PluginRuntime.node, SdkPlugins.node, ], diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts index 46417eafb7..fcf1aece0c 100644 --- a/packages/core/src/vcs.ts +++ b/packages/core/src/vcs.ts @@ -7,6 +7,7 @@ import { makeLocationNode } from "./effect/app-node" import { FSUtil } from "./fs-util" import { Location } from "./location" import { AppProcess } from "./process" +import { VcsBackends } from "./vcs/backends" import { VcsGit } from "./vcs/git" import { VcsHg } from "./vcs/hg" @@ -17,16 +18,13 @@ export interface DiffOptions { } export interface Interface { - readonly status: () => Effect.Effect - readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect + readonly status: () => Effect.Effect + readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Vcs") {} -// Adapter seam: one working-copy implementation per VCS type, selected by the -// 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 builtIn = (proc: AppProcess.Interface, fs: FSUtil.Interface, location: Location.Interface) => { const scope = { directory: location.directory, worktree: location.project.directory } if (location.vcs?.type === "git") return VcsGit.make(proc, 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 fs = yield* FSUtil.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({ status: Effect.fn("Vcs.status")(function* () { + const impl = yield* adapter() if (!impl) return [] return yield* impl.status() }), diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) { + const impl = yield* adapter() if (!impl) return [] return yield* impl.diff(mode, options) }), @@ -55,5 +69,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer: layer, - deps: [AppProcess.node, FSUtil.node, Location.node], + deps: [AppProcess.node, FSUtil.node, Location.node, VcsBackends.node], }) diff --git a/packages/core/src/vcs/backends.ts b/packages/core/src/vcs/backends.ts new file mode 100644 index 0000000000..f8d47ec712 --- /dev/null +++ b/packages/core/src/vcs/backends.ts @@ -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 + readonly get: (type: string) => Vcs.Adapter | undefined +} + +export class Service extends Context.Service()("@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() + + 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(type: string, operation: string, decode: (input: unknown) => Option.Option) { + return (effect: Effect.Effect) => + 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), + }) + }), + ) +} diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index ea6e65bb64..7d2959939f 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -18,6 +18,7 @@ import { Reference } from "@opencode-ai/core/reference" import { SkillV2 } from "@opencode-ai/core/skill" import { ToolHooks } from "@opencode-ai/core/tool/hooks" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { VcsBackends } from "@opencode-ai/core/vcs/backends" import { Effect, Layer } from "effect" import { tempLocationLayer } from "../fixture/location" @@ -50,6 +51,7 @@ export const PluginTestLayer = AppNodeBuilder.build( SkillV2.node, ToolHooks.node, ToolRegistry.toolsNode, + VcsBackends.node, ]), [ [Location.node, tempLocationLayer], diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index b9fc03b9c9..384dcb8f2b 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -64,6 +64,9 @@ export function host(overrides: Overrides = {}): PluginContext { command: () => Effect.die("unused session.command"), interrupt: () => Effect.die("unused session.interrupt"), }, + vcs: overrides.vcs ?? { + register: () => Effect.die("unused vcs.register"), + }, } } diff --git a/packages/core/test/vcs-backends.test.ts b/packages/core/test/vcs-backends.test.ts new file mode 100644 index 0000000000..fd9294b28b --- /dev/null +++ b/packages/core/test/vcs-backends.test.ts @@ -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.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), + ) +}) diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts index 3f77fcd48d..7ab5c3c4fe 100644 --- a/packages/plugin/src/v2/effect/context.ts +++ b/packages/plugin/src/v2/effect/context.ts @@ -10,6 +10,7 @@ import type { SkillHooks } from "./skill.js" import type { Reload } from "./registration.js" import type { ToolDomain } from "./tool.js" import type { SessionDomain } from "./runtime.js" +import type { VcsDomain } from "./vcs.js" export interface PluginContext { readonly options: PluginOptions @@ -23,4 +24,5 @@ export interface PluginContext { readonly skill: SkillHooks & Reload readonly tool: ToolDomain readonly session: SessionDomain + readonly vcs: VcsDomain } diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 295c948766..affa0d0741 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -4,3 +4,5 @@ export type { Plugin } from "./plugin.js" export * as Tool from "./tool.js" export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js" export type { SessionDomain } from "./runtime.js" +export { Vcs } from "./vcs.js" +export type { VcsDomain } from "./vcs.js" diff --git a/packages/plugin/src/v2/effect/vcs.ts b/packages/plugin/src/v2/effect/vcs.ts new file mode 100644 index 0000000000..0012575cd8 --- /dev/null +++ b/packages/plugin/src/v2/effect/vcs.ts @@ -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()("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 diff: ( + mode: Mode, + options?: { readonly context?: number }, + ) => Effect.Effect +} + +export interface Backend { + readonly type: string + readonly make: (scope: AdapterScope) => Adapter +} + +export interface VcsDomain { + readonly register: (backend: Backend) => Effect.Effect +}