diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index 65c3d530641..129b9585c26 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -13,6 +13,7 @@ export type ReferenceApi = Client["reference"] export type WebSearchApi = Client["websearch"] export type SessionApi = Client["session"] export type SkillApi = Client["skill"] +export type VcsApi = Client["vcs"] export interface CatalogApi { readonly provider: ProviderApi diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 82c98e059b6..37e0c1bd1a7 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -22,6 +22,7 @@ import { Reference } from "./reference.js" import { Skill } from "./skill.js" import { State } from "./state.js" import { Tool } from "./tool.js" +import { Vcs } from "./vcs.js" import { PluginHooks } from "./plugin/hooks.js" export interface Interface { @@ -199,6 +200,7 @@ export const node = makeLocationNode({ Reference.node, Skill.node, Tool.node, + Vcs.node, PluginHooks.node, PluginRuntime.node, WebSearch.node, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index b2ef680a7d1..09e9b745ae3 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -24,6 +24,7 @@ import { AbsolutePath, type DeepMutable } from "../schema.js" import { Skill } from "../skill.js" import { Tool } from "../tool.js" import { Workspace } from "../workspace.js" +import { Vcs } from "../vcs.js" import { WebSearch } from "../websearch.js" import { PluginHooks } from "./hooks.js" import type { Interface } from "../plugin.js" @@ -43,6 +44,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p const reference = yield* Reference.Service const skill = yield* Skill.Service const tools = yield* Tool.Service + const vcs = yield* Vcs.Service const websearch = yield* WebSearch.Service const hooks = yield* PluginHooks.Service const runtime = yield* PluginRuntime.Service @@ -354,6 +356,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p .pipe(Effect.orDie, Effect.as({ dispose: Effect.void })), hook: (name, callback) => hooks.register("tool", name, callback), }, + vcs: { + get: () => response(vcs.info()), + branches: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })), + status: () => response(vcs.status()), + diff: (input) => response(vcs.diff(input.mode, { context: input.context })), + transform: vcs.transform, + reload: vcs.reload, + }, websearch: { providers: () => response(websearch.providers()), query: (input) => diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts index 6cd851500fc..301bca79bd6 100644 --- a/packages/core/src/vcs.ts +++ b/packages/core/src/vcs.ts @@ -1,7 +1,8 @@ export * as Vcs from "./vcs.js" import path from "path" -import { Context, Effect, Layer, Stream } from "effect" +import { Cause, Context, Effect, Layer, Schema, Stream } from "effect" +import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs" import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileSystem } from "@opencode-ai/schema/filesystem" import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs" @@ -11,8 +12,10 @@ import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "./location.js" import { AppProcess } from "@opencode-ai/util/process" import { Bus } from "./bus.js" +import { State } from "./state.js" import { VcsGit } from "./vcs/git.js" import { VcsHg } from "./vcs/hg.js" +import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js" export { BranchList, FileStatus, Info, Mode } @@ -25,13 +28,20 @@ export interface BranchOptions { readonly limit?: number } -export interface Interface { +export interface Adapter { readonly info: () => Effect.Effect readonly branches: (options?: BranchOptions) => Effect.Effect readonly status: () => Effect.Effect readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect } +export interface Interface extends Adapter, State.Transformable {} + +interface Data { + readonly providers: Map + selection?: string +} + export class Service extends Context.Service()("@opencode/Vcs") {} // Adapter seam: one working-copy implementation per VCS type, selected by the @@ -50,11 +60,59 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const location = yield* Location.Service const bus = yield* Bus.Service - const impl = adapter(proc, fs, location) + const native = adapter(proc, fs, location) const vcs = location.vcs - const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) } + const current = { info: native ? yield* native.info() : ({ branch: {} } satisfies Info) } + const scope = { + directory: location.directory, + worktree: location.project.directory, + canonical: location.project.canonical, + ...(vcs ? { store: vcs.store } : {}), + } + const decodeInfo = Schema.decodeUnknownEffect(Info) + const decodeBranches = Schema.decodeUnknownEffect(BranchList) + const decodeStatus = Schema.decodeUnknownEffect(Schema.Array(FileStatus)) + const decodeDiff = Schema.decodeUnknownEffect(Schema.Array(FileDiff.Info)) + const state: State.Interface = State.create({ + name: "vcs", + initial: () => ({ providers: new Map() }), + draft: (draft) => ({ + add: (provider) => draft.providers.set(provider.id, provider), + default: { + get: () => draft.selection, + set: (selection) => (draft.selection = selection), + }, + }), + finalize: () => refresh(), + }) + const selected = () => { + const value = state.get() + const id = value.selection ?? vcs?.type + return id ? value.providers.get(id) : undefined + } + const protect = (provider: VcsDefinition, operation: string, effect: Effect.Effect, fallback: A) => + effect.pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause).pipe(Effect.orDie) + : Effect.logWarning("vcs provider failed", { provider: provider.id, operation, cause }).pipe( + Effect.as(fallback), + ), + ), + ) + const refresh = Effect.fn("Vcs.refresh")(function* () { + const provider = selected() + const next: Info = provider + ? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} }) + : native + ? yield* native.info() + : { branch: {} } + const changed = current.info.branch.current !== next.branch.current + current.info = next + if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current }) + }) - if (vcs && impl) { + if (vcs && native) { const store = yield* fs.realPath(vcs.store).pipe(Effect.orElseSucceed(() => vcs.store)) const isBranchMetadata = vcs.type === "git" @@ -63,33 +121,68 @@ const layer = Layer.effect( yield* bus.subscribe(FileSystem.Event.Changed).pipe( Stream.filter((event) => isBranchMetadata(event.data.file)), Stream.runForEach((event) => - Effect.gen(function* () { - const next = yield* impl.info() - const changed = state.info.branch.current !== next.branch.current - state.info = next - if (!changed) return - yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current }) - }).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })), + refresh().pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })), ), Effect.forkScoped({ startImmediately: true }), ) } return Service.of({ + transform: state.transform, + reload: state.reload, info: Effect.fn("Vcs.info")(function* () { - return state.info + return current.info }), branches: Effect.fn("Vcs.branches")(function* (options?: BranchOptions) { - if (!impl) return [] - return yield* impl.branches(options) + const provider = selected() + if (provider) + return yield* protect( + provider, + "branches", + provider.branches({ ...scope, ...options }).pipe(Effect.flatMap(decodeBranches)), + [], + ) + if (!native) return [] + return yield* native.branches(options) }), status: Effect.fn("Vcs.status")(function* () { - if (!impl) return [] - return yield* impl.status() + const provider = selected() + if (provider) + return yield* protect( + provider, + "status", + provider.status(scope).pipe( + Effect.flatMap(decodeStatus), + Effect.map((rows) => Array.from(rows)), + ), + [], + ) + if (!native) return [] + return yield* native.status() }), diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) { - if (!impl) return [] - return yield* impl.diff(mode, options) + const provider = selected() + if (!provider) return native ? yield* native.diff(mode, options) : [] + const rows = yield* protect( + provider, + "diff", + provider + .diff({ + ...scope, + mode, + context: options?.context ?? PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_TOTAL_PATCH_BYTES, + }) + .pipe(Effect.flatMap(decodeDiff)), + [], + ) + let total = 0 + return rows.map((row) => { + const bytes = Buffer.byteLength(row.patch) + if (total + bytes > MAX_TOTAL_PATCH_BYTES) return { ...row, patch: emptyPatch(row.file) } + total += bytes + return row + }) }), }) }), diff --git a/packages/core/src/vcs/git.ts b/packages/core/src/vcs/git.ts index 038a887c22a..d6d0f51d09b 100644 --- a/packages/core/src/vcs/git.ts +++ b/packages/core/src/vcs/git.ts @@ -5,7 +5,7 @@ import { ChildProcess } from "effect/unstable/process" import { FileDiff } from "@opencode-ai/schema/file-diff" import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs" import { AppProcess } from "@opencode-ai/util/process" -import type { BranchOptions, DiffOptions, Interface } from "../vcs.js" +import type { Adapter, BranchOptions, DiffOptions } from "../vcs.js" import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch.js" import type { Patch } from "./patch.js" @@ -14,7 +14,7 @@ import type { Patch } from "./patch.js" * batched through one `git diff` invocation where possible and capped by * per-file and total byte budgets, falling back to empty patches when capped. */ -export function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Interface { +export function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Adapter { // Listing commands scope pathspecs to the requested directory; per-file // commands run from the worktree root because git lists root-relative paths. const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree } @@ -181,20 +181,18 @@ function makeGit(proc: AppProcess.Interface) { const branches = Effect.fn("VcsGit.branches")(function* (cwd: string, options?: BranchOptions) { const search = options?.search?.trim().replace(/[*?[\]\\]/g, "\\$&") - return ( - yield* lines( - [ - "for-each-ref", - "--ignore-case", - "--sort=refname", - "--sort=-committerdate", - "--format=%(refname:short)", - ...(options?.limit ? [`--count=${options.limit}`] : []), - ...(search ? [`refs/heads/*${search}*`, `refs/remotes/*${search}*`] : ["refs/heads", "refs/remotes"]), - ], - { cwd }, - ) - ).filter((item) => !item.endsWith("/HEAD")) satisfies BranchList + return (yield* lines( + [ + "for-each-ref", + "--ignore-case", + "--sort=refname", + "--sort=-committerdate", + "--format=%(refname:short)", + ...(options?.limit ? [`--count=${options.limit}`] : []), + ...(search ? [`refs/heads/*${search}*`, `refs/remotes/*${search}*`] : ["refs/heads", "refs/remotes"]), + ], + { cwd }, + )).filter((item) => !item.endsWith("/HEAD")) satisfies BranchList }) const defaultBranch = Effect.fn("VcsGit.defaultBranch")(function* (cwd: string) { diff --git a/packages/core/src/vcs/hg.ts b/packages/core/src/vcs/hg.ts index 25367172555..1a4471d37f5 100644 --- a/packages/core/src/vcs/hg.ts +++ b/packages/core/src/vcs/hg.ts @@ -7,7 +7,7 @@ import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs" import { FSUtil } from "@opencode-ai/util/fs-util" import { AppProcess } from "@opencode-ai/util/process" -import type { DiffOptions, Interface } from "../vcs.js" +import type { Adapter, DiffOptions } from "../vcs.js" import { addPatch, chunksByFile, @@ -28,7 +28,7 @@ export function make( proc: AppProcess.Interface, fs: FSUtil.Interface, input: { directory: string; worktree: string }, -): Interface { +): Adapter { const hg = makeHg(proc, input.worktree) // All commands run from the worktree root (hg prints root-relative paths); // this pathspec scopes them to the requested directory. diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index fcc1a84083d..aba6f008dd4 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { Tool } from "@opencode-ai/core/tool" +import { Vcs } from "@opencode-ai/core/vcs" import { testEffect } from "./lib/effect" import { PluginTestLayer } from "./plugin/fixture" @@ -93,6 +94,37 @@ describe("Plugin", () => { }), ) + it.effect("registers and removes scoped VCS providers", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const vcs = yield* Vcs.Service + const provider = EffectPlugin.define({ + id: "custom-vcs", + effect: (ctx) => + ctx.vcs + .transform((draft) => { + draft.add({ + id: "custom", + name: "Custom VCS", + info: () => Effect.succeed({ branch: { current: "feature" } }), + branches: () => Effect.succeed(["feature"]), + status: () => Effect.succeed([]), + diff: () => Effect.succeed([]), + }) + draft.default.set("custom") + }) + .pipe(Effect.asVoid), + }) + + yield* plugins.activate([versioned(provider)]) + expect(yield* vcs.info()).toEqual({ branch: { current: "feature" } }) + expect(yield* vcs.branches()).toEqual(["feature"]) + + yield* plugins.activate([]) + expect(yield* vcs.info()).toEqual({ branch: {} }) + }), + ) + it.effect("replaces plugins by ID and version", () => Effect.gen(function* () { const plugins = yield* Plugin.Service diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 868de9bce63..afc0535dddf 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -23,6 +23,7 @@ import { Skill } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Tool } from "@opencode-ai/core/tool" +import { Vcs } from "@opencode-ai/core/vcs" import { WebSearch } from "@opencode-ai/core/websearch" import { Effect, Layer } from "effect" import { tempLocationLayer } from "../fixture/location" @@ -62,6 +63,7 @@ export const PluginTestLayer = LayerNode.compile( SkillDiscovery.node, PluginHooks.node, Tool.node, + Vcs.node, Watcher.node, WebSearch.node, ]), diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index c64321c329a..ea59072edb7 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -107,6 +107,14 @@ export function host(overrides: Overrides = {}): Plugin.Context { transform: () => Effect.die("unused tool.transform"), hook: () => Effect.die("unused tool.hook"), }, + vcs: overrides.vcs ?? { + get: () => Effect.die("unused vcs.get"), + branches: () => Effect.die("unused vcs.branches"), + status: () => Effect.die("unused vcs.status"), + diff: () => Effect.die("unused vcs.diff"), + transform: () => Effect.die("unused vcs.transform"), + reload: () => Effect.die("unused vcs.reload"), + }, websearch: overrides.websearch ?? { providers: () => Effect.die("unused websearch.providers"), query: () => Effect.die("unused websearch.query"), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 8638da2eac6..cf65b9fe080 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -10,6 +10,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginPromise } from "@opencode-ai/core/plugin/promise" import { WebSearch } from "@opencode-ai/core/websearch" +import { Vcs } from "@opencode-ai/core/vcs" import { Session } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionInbox } from "@opencode-ai/core/session/inbox" @@ -432,6 +433,56 @@ describe("fromPromise", () => { }), ) + it.effect("registers a Promise VCS provider and forwards client reads", () => + Effect.gen(function* () { + const vcs = yield* Vcs.Service + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + const signals: AbortSignal[] = [] + const promisePlugin = define({ + id: "promise-vcs", + setup: async (ctx) => { + await ctx.vcs.transform((draft) => { + draft.add({ + id: "custom", + name: "Custom VCS", + info: async (_input, request) => { + signals.push(request.signal) + return { branch: { current: "feature", default: "main" } } + }, + branches: async (input, request) => { + signals.push(request.signal) + expect(input.search).toBe("feat") + return ["feature"] + }, + status: async (_input, request) => { + signals.push(request.signal) + return [{ file: "file.txt", additions: 1, deletions: 0, status: "added" }] + }, + diff: async (input, request) => { + signals.push(request.signal) + expect(input.context).toBe(2) + expect(input.maxOutputBytes).toBe(10_000_000) + return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }] + }, + }) + draft.default.set("custom") + }) + + expect((await ctx.vcs.get()).data.branch.current).toBe("feature") + expect((await ctx.vcs.branches({ search: "feat" })).data).toEqual(["feature"]) + expect((await ctx.vcs.status()).data).toHaveLength(1) + expect((await ctx.vcs.diff({ mode: "working", context: 2 })).data[0].patch).toBe("+hello") + }, + }) + + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + expect((yield* vcs.info()).branch.current).toBe("feature") + expect(signals).toHaveLength(4) + expect(signals.every((signal) => signal instanceof AbortSignal)).toBeTrue() + }), + ) + it.effect("registers a standalone web search provider", () => Effect.gen(function* () { const websearch = yield* WebSearch.Service diff --git a/packages/core/test/vcs.test.ts b/packages/core/test/vcs.test.ts index d0326bc6e49..0558af39d0c 100644 --- a/packages/core/test/vcs.test.ts +++ b/packages/core/test/vcs.test.ts @@ -2,12 +2,13 @@ import { $ } from "bun" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Effect, Fiber, Layer, Stream } from "effect" +import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Vcs } from "@opencode-ai/core/vcs" +import type { VcsDefinition, VcsDiffInput } from "@opencode-ai/plugin/effect/vcs" import { FileSystem } from "@opencode-ai/schema/filesystem" import { VcsEvent } from "@opencode-ai/schema/vcs-event" import { location } from "./fixture/location" @@ -58,6 +59,17 @@ async function commitAll(directory: string, message: string) { await $`git commit -m ${message}`.cwd(directory).quiet() } +const provider = (input: Partial = {}) => + ({ + id: "custom", + name: "Custom VCS", + info: () => Effect.succeed({ branch: { current: "feature", default: "main" } }), + branches: () => Effect.succeed(["feature", "main"]), + status: () => Effect.succeed([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }]), + diff: () => Effect.succeed([{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]), + ...input, + }) satisfies VcsDefinition + describe("Vcs", () => { it.live("returns empty results outside version control", () => withTmp((directory) => @@ -72,6 +84,118 @@ describe("Vcs", () => { ), ) + it.live("serves scoped providers and restores the fallback after disposal", () => + withTmp((directory) => + Effect.gen(function* () { + const vcs = yield* Vcs.Service + const registration = yield* vcs.transform((draft) => { + draft.add(provider()) + draft.default.set("custom") + }) + + expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } }) + expect(yield* vcs.branches()).toEqual(["feature", "main"]) + expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }]) + expect(yield* vcs.diff("working")).toEqual([ + { file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }, + ]) + + yield* registration.dispose + expect(yield* vcs.info()).toEqual({ branch: {} }) + expect(yield* vcs.status()).toEqual([]) + }).pipe(provide(directory)), + ), + ) + + it.live("automatically selects a provider matching the resolved repository", () => + withGit(() => + Effect.gen(function* () { + const vcs = yield* Vcs.Service + const registration = yield* vcs.transform((draft) => draft.add(provider({ id: "git" }))) + expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } }) + + yield* registration.dispose + expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } }) + }), + ), + ) + + it.live("passes location scope and bounded diff options to providers", () => + withTmp((directory) => + Effect.gen(function* () { + const observed: VcsDiffInput[] = [] + const vcs = yield* Vcs.Service + yield* vcs.transform((draft) => { + draft.add( + provider({ + diff: (input) => + Effect.sync(() => { + observed.push(input) + return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }] + }), + }), + ) + draft.default.set("custom") + }) + + yield* vcs.diff("branch", { context: 3 }) + expect(observed).toEqual([ + { + directory, + worktree: directory, + canonical: directory, + mode: "branch", + context: 3, + maxOutputBytes: 10_000_000, + }, + ]) + }).pipe(provide(directory)), + ), + ) + + it.live("validates provider results and bounds oversized patches", () => + withTmp((directory) => + Effect.gen(function* () { + const vcs = yield* Vcs.Service + yield* vcs.transform((draft) => { + draft.add( + provider({ + status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]), + diff: () => + Effect.succeed([ + { file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" }, + ]), + }), + ) + draft.default.set("custom") + }) + + expect(yield* vcs.status()).toEqual([]) + const rows = yield* vcs.diff("working") + expect(rows).toHaveLength(1) + expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000) + expect(rows[0].additions).toBe(1) + }).pipe(provide(directory)), + ), + ) + + it.live("preserves provider interruption", () => + withTmp((directory) => + Effect.gen(function* () { + const vcs = yield* Vcs.Service + yield* vcs.transform((draft) => { + draft.add(provider({ status: () => Effect.never })) + draft.default.set("custom") + }) + + const fiber = yield* Effect.forkChild(vcs.status()) + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue() + }).pipe(provide(directory)), + ), + ) + it.live("lists local branches by recent activity", () => withGit((directory) => Effect.gen(function* () { @@ -83,7 +207,11 @@ describe("Vcs", () => { await $`git add -A`.cwd(directory).quiet() await $`git commit -m recent` .cwd(directory) - .env({ ...process.env, GIT_AUTHOR_DATE: "2030-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2030-01-01T00:00:00Z" }) + .env({ + ...process.env, + GIT_AUTHOR_DATE: "2030-01-01T00:00:00Z", + GIT_COMMITTER_DATE: "2030-01-01T00:00:00Z", + }) .quiet() }) const vcs = yield* Vcs.Service diff --git a/packages/plugin/src/effect/index.ts b/packages/plugin/src/effect/index.ts index cf248173bb8..adc1b66a95b 100644 --- a/packages/plugin/src/effect/index.ts +++ b/packages/plugin/src/effect/index.ts @@ -11,4 +11,5 @@ export { Model } from "@opencode-ai/schema/model" export { Provider } from "@opencode-ai/schema/provider" export { Reference } from "@opencode-ai/schema/reference" export { Skill } from "@opencode-ai/schema/skill" +export { Vcs } from "@opencode-ai/schema/vcs" export { WebSearch } from "@opencode-ai/schema/websearch" diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index 5130fab7cc4..9a9205d7258 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -15,6 +15,7 @@ import type { ShellDomain } from "./shell.js" import type { SkillDomain } from "./skill.js" import type { StorageDomain } from "./storage.js" import type { ToolDomain } from "./tool.js" +import type { VcsDomain } from "./vcs.js" import type { WebSearchDomain } from "./websearch.js" export interface Context { @@ -34,6 +35,7 @@ export interface Context { readonly skill: SkillDomain readonly storage: StorageDomain readonly tool: ToolDomain + readonly vcs: VcsDomain readonly websearch: WebSearchDomain } diff --git a/packages/plugin/src/effect/vcs.ts b/packages/plugin/src/effect/vcs.ts new file mode 100644 index 00000000000..3b8a5c850ed --- /dev/null +++ b/packages/plugin/src/effect/vcs.ts @@ -0,0 +1,45 @@ +import type { VcsApi } from "@opencode-ai/client/effect/api" +import type { FileDiff } from "@opencode-ai/schema/file-diff" +import type { Vcs } from "@opencode-ai/schema/vcs" +import type { Effect } from "effect" +import type { Transform } from "./registration.js" + +export interface VcsScope { + readonly directory: string + readonly worktree: string + readonly canonical: string + readonly store?: string +} + +export interface VcsBranchesInput extends VcsScope { + readonly search?: string + readonly limit?: number +} + +export interface VcsDiffInput extends VcsScope { + readonly mode: Vcs.Mode + readonly context: number + readonly maxOutputBytes: number +} + +export interface VcsDefinition { + readonly id: string + readonly name: string + readonly info: (input: VcsScope) => Effect.Effect + readonly branches: (input: VcsBranchesInput) => Effect.Effect + readonly status: (input: VcsScope) => Effect.Effect + readonly diff: (input: VcsDiffInput) => Effect.Effect +} + +export interface VcsDomain extends VcsApi { + readonly transform: Transform + readonly reload: () => Effect.Effect +} + +export interface VcsDraft { + add(definition: VcsDefinition): void + readonly default: { + get(): string | undefined + set(selection: string): void + } +} diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 0639a83e5e6..8d0f19d31d1 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -84,6 +84,7 @@ export function fromPromise(plugin: Plugin) { const ReferenceEndpoints = ClientApi.groups["server.reference"].endpoints const SessionEndpoints = ClientApi.groups["server.session"].endpoints const SkillEndpoints = ClientApi.groups["server.skill"].endpoints + const VcsEndpoints = ClientApi.groups["server.vcs"].endpoints const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints const context = yield* Effect.context() @@ -295,6 +296,30 @@ export function fromPromise(plugin: Plugin) { hook: (name, callback) => register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), }, + vcs: { + get: adaptApiMethod(VcsEndpoints["vcs.get"], host.vcs.get), + branches: adaptApiMethod(VcsEndpoints["vcs.branches"], host.vcs.branches), + status: adaptApiMethod(VcsEndpoints["vcs.status"], host.vcs.status), + diff: adaptApiMethod(VcsEndpoints["vcs.diff"], host.vcs.diff), + reload: () => run(host.vcs.reload()), + transform: (callback) => + register( + host.vcs.transform((draft) => { + callback({ + add: (definition) => + draft.add({ + id: definition.id, + name: definition.name, + info: (input) => attempt((signal) => definition.info(input, { signal })), + branches: (input) => attempt((signal) => definition.branches(input, { signal })), + status: (input) => attempt((signal) => definition.status(input, { signal })), + diff: (input) => attempt((signal) => definition.diff(input, { signal })), + }), + default: draft.default, + }) + }), + ), + }, websearch: { providers: adaptApiMethod(WebSearchEndpoints["websearch.providers"], host.websearch.providers), query: adaptApiMethod(WebSearchEndpoints["websearch.query"], host.websearch.query), diff --git a/packages/plugin/src/promise/index.ts b/packages/plugin/src/promise/index.ts index a945a7b41aa..e1a6079050f 100644 --- a/packages/plugin/src/promise/index.ts +++ b/packages/plugin/src/promise/index.ts @@ -12,4 +12,5 @@ export { Model } from "@opencode-ai/schema/model" export { Provider } from "@opencode-ai/schema/provider" export { Reference } from "@opencode-ai/schema/reference" export { Skill } from "@opencode-ai/schema/skill" +export { Vcs } from "@opencode-ai/schema/vcs" export { WebSearch } from "@opencode-ai/schema/websearch" diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index d8b782118a9..559cdb2e118 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -14,6 +14,7 @@ import type { ShellDomain } from "./shell.js" import type { SkillDomain } from "./skill.js" import type { StorageDomain } from "./storage.js" import type { ToolDomain } from "./tool.js" +import type { VcsDomain } from "./vcs.js" import type { WebSearchDomain } from "./websearch.js" export interface Context { @@ -33,6 +34,7 @@ export interface Context { readonly skill: SkillDomain readonly storage: StorageDomain readonly tool: ToolDomain + readonly vcs: VcsDomain readonly websearch: WebSearchDomain } diff --git a/packages/plugin/src/promise/vcs.ts b/packages/plugin/src/promise/vcs.ts new file mode 100644 index 00000000000..0732972111b --- /dev/null +++ b/packages/plugin/src/promise/vcs.ts @@ -0,0 +1,44 @@ +import type { VcsApi } from "@opencode-ai/client/promise/api" +import type { FileDiff } from "@opencode-ai/schema/file-diff" +import type { Vcs } from "@opencode-ai/schema/vcs" +import type { Transform } from "./registration.js" + +export interface VcsScope { + readonly directory: string + readonly worktree: string + readonly canonical: string + readonly store?: string +} + +export interface VcsBranchesInput extends VcsScope { + readonly search?: string + readonly limit?: number +} + +export interface VcsDiffInput extends VcsScope { + readonly mode: Vcs.Mode + readonly context: number + readonly maxOutputBytes: number +} + +export interface VcsDefinition { + readonly id: string + readonly name: string + readonly info: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise + readonly branches: (input: VcsBranchesInput, context: { readonly signal: AbortSignal }) => Promise + readonly status: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise + readonly diff: (input: VcsDiffInput, context: { readonly signal: AbortSignal }) => Promise +} + +export interface VcsDomain extends VcsApi { + readonly transform: Transform + readonly reload: () => Promise +} + +export interface VcsDraft { + add(definition: VcsDefinition): void + readonly default: { + get(): string | undefined + set(selection: string): void + } +} diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index 322ab0ce63c..76cf40ac19c 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -9,6 +9,7 @@ import { Model } from "@opencode-ai/schema/model" import { Provider } from "@opencode-ai/schema/provider" import { Reference } from "@opencode-ai/schema/reference" import { Skill } from "@opencode-ai/schema/skill" +import { Vcs } from "@opencode-ai/schema/vcs" import { WebSearch } from "@opencode-ai/schema/websearch" const Plugin = await import("../src/effect/index") @@ -29,6 +30,7 @@ test.each([ expect(entrypoint.Provider).toBe(Provider) expect(entrypoint.Reference).toBe(Reference) expect(entrypoint.Skill).toBe(Skill) + expect(entrypoint.Vcs).toBe(Vcs) expect(entrypoint.WebSearch).toBe(WebSearch) expect(Object.keys(entrypoint).sort()).toEqual([ "Agent", @@ -42,6 +44,7 @@ test.each([ "Provider", "Reference", "Skill", + "Vcs", "WebSearch", ]) }) diff --git a/packages/server/src/workerd.ts b/packages/server/src/workerd.ts index 4f22f203a8c..3638d650dd5 100644 --- a/packages/server/src/workerd.ts +++ b/packages/server/src/workerd.ts @@ -95,6 +95,8 @@ const unavailable = (what: string) => Effect.die(new Error(`${what} is unavailab const vcsLayer = Layer.succeed( Vcs.Service, Vcs.Service.of({ + transform: () => Effect.succeed({ dispose: Effect.void }), + reload: () => Effect.void, info: () => Effect.succeed({ branch: {} }), branches: () => Effect.succeed([]), status: () => Effect.succeed([]), diff --git a/packages/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index 51aaa298104..e97b36e8b92 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -125,6 +125,7 @@ interface Context { readonly skill: SkillDomain readonly storage: StorageDomain readonly tool: ToolDomain + readonly vcs: VcsDomain readonly websearch: WebSearchDomain } @@ -853,6 +854,65 @@ interface ToolDomain { } ``` +### VCS + +Read repository information, working-copy status, or file diffs. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const vcs = ctx.vcs + const info = yield* vcs.get().pipe(Effect.orDie) + const branches = yield* vcs.branches({ search: "feature", limit: 10 }).pipe(Effect.orDie) + const changes = yield* vcs.status().pipe(Effect.orDie) + const diff = yield* vcs.diff({ mode: "working", context: 3 }).pipe(Effect.orDie) + yield* Effect.logInfo("vcs", { branch: info.data.branch.current, files: changes.data.length }) + }), +``` + +Register a location-scoped provider through a scoped transform. Providers matching the detected repository type are +selected automatically; use `draft.default.set` to select a different provider. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const vcs = ctx.vcs + yield* vcs.transform((draft) => { + draft.add({ + id: "custom", + name: "Custom VCS", + info: () => Effect.succeed({ branch: { current: "feature", default: "main" } }), + branches: (input) => readBranches(input), + status: (scope) => readStatus(scope.worktree), + diff: (input) => readDiff(input), + }) + draft.default.set("custom") + }) + }), +``` + +Provider callbacks receive the current location, working-copy root, canonical project root, and optional repository +store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery +continues to use OpenCode's built-in Git and Mercurial detectors. + +Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus), +[`FileDiff.Info`](/api#schema-FileDiff.Info). + +```ts +interface VcsDraft { + add(definition: VcsDefinition): void + readonly default: { + get(): string | undefined + set(selection: string): void + } +} + +interface VcsDomain extends VcsApi { + readonly transform: Transform + readonly reload: () => Effect.Effect +} +``` + ### Websearch List providers or run a query through the selected provider. diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index e8f1db403f9..d5cb949875f 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -797,6 +797,62 @@ interface ToolDraft { } ``` +### VCS + +Read repository information, working-copy status, or file diffs. + +```ts +const info = await ctx.vcs.get() +const branches = await ctx.vcs.branches({ search: "feature", limit: 10 }) +const changes = await ctx.vcs.status() +const diff = await ctx.vcs.diff({ mode: "working", context: 3 }) +``` + +Register a location-scoped VCS provider with a transform. A provider matching the detected repository type is selected +automatically; use `draft.default.set` to select a different provider. + +```ts +await ctx.vcs.transform((draft) => { + draft.add({ + id: "custom", + name: "Custom VCS", + info: async () => ({ branch: { current: "feature", default: "main" } }), + branches: async (input, { signal }) => readBranches(input, signal), + status: async (scope, { signal }) => readStatus(scope.worktree, signal), + diff: async (input, { signal }) => readDiff(input, signal), + }) + draft.default.set("custom") +}) +``` + +Provider callbacks receive the current location, working-copy root, canonical project root, and optional repository +store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery +continues to use OpenCode's built-in Git and Mercurial detectors. + +#### Reference + +Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus), +[`FileDiff.Info`](/api#schema-FileDiff.Info) + +```ts +interface VcsContext { + get(input?: VcsGetInput, requestOptions?: RequestOptions): Promise + branches(input?: VcsBranchesInput, requestOptions?: RequestOptions): Promise + status(input?: VcsStatusInput, requestOptions?: RequestOptions): Promise + diff(input: VcsDiffInput, requestOptions?: RequestOptions): Promise + transform(callback: (draft: VcsDraft) => void): Promise + reload(): Promise +} + +interface VcsDraft { + add(definition: VcsDefinition): void + default: { + get(): string | undefined + set(providerID: string): void + } +} +``` + ### Websearch List websearch providers or run a query through the selected provider.