mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 04:42:17 +00:00
feat(plugin): add vcs provider api (#44979)
This commit is contained in:
parent
7601ab9fc4
commit
9c1787617c
22 changed files with 605 additions and 39 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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<Info>
|
||||
readonly branches: (options?: BranchOptions) => Effect.Effect<BranchList>
|
||||
readonly status: () => Effect.Effect<FileStatus[]>
|
||||
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
|
||||
}
|
||||
|
||||
export interface Interface extends Adapter, State.Transformable<VcsDraft> {}
|
||||
|
||||
interface Data {
|
||||
readonly providers: Map<string, VcsDefinition>
|
||||
selection?: string
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<Data, VcsDraft> = State.create<Data, VcsDraft>({
|
||||
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 = <A>(provider: VcsDefinition, operation: string, effect: Effect.Effect<A, unknown>, 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
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<VcsDefinition> = {}) =>
|
||||
({
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
45
packages/plugin/src/effect/vcs.ts
Normal file
45
packages/plugin/src/effect/vcs.ts
Normal file
|
|
@ -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<Vcs.Info, unknown>
|
||||
readonly branches: (input: VcsBranchesInput) => Effect.Effect<Vcs.BranchList, unknown>
|
||||
readonly status: (input: VcsScope) => Effect.Effect<readonly Vcs.FileStatus[], unknown>
|
||||
readonly diff: (input: VcsDiffInput) => Effect.Effect<readonly FileDiff.Info[], unknown>
|
||||
}
|
||||
|
||||
export interface VcsDomain extends VcsApi<unknown> {
|
||||
readonly transform: Transform<VcsDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface VcsDraft {
|
||||
add(definition: VcsDefinition): void
|
||||
readonly default: {
|
||||
get(): string | undefined
|
||||
set(selection: string): void
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Scope.Scope>()
|
||||
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
44
packages/plugin/src/promise/vcs.ts
Normal file
44
packages/plugin/src/promise/vcs.ts
Normal file
|
|
@ -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<Vcs.Info>
|
||||
readonly branches: (input: VcsBranchesInput, context: { readonly signal: AbortSignal }) => Promise<Vcs.BranchList>
|
||||
readonly status: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<readonly Vcs.FileStatus[]>
|
||||
readonly diff: (input: VcsDiffInput, context: { readonly signal: AbortSignal }) => Promise<readonly FileDiff.Info[]>
|
||||
}
|
||||
|
||||
export interface VcsDomain extends VcsApi {
|
||||
readonly transform: Transform<VcsDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface VcsDraft {
|
||||
add(definition: VcsDefinition): void
|
||||
readonly default: {
|
||||
get(): string | undefined
|
||||
set(selection: string): void
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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([]),
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
||||
readonly transform: Transform<VcsDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
```
|
||||
|
||||
### Websearch
|
||||
|
||||
List providers or run a query through the selected provider.
|
||||
|
|
|
|||
|
|
@ -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<VcsGetOutput>
|
||||
branches(input?: VcsBranchesInput, requestOptions?: RequestOptions): Promise<VcsBranchesOutput>
|
||||
status(input?: VcsStatusInput, requestOptions?: RequestOptions): Promise<VcsStatusOutput>
|
||||
diff(input: VcsDiffInput, requestOptions?: RequestOptions): Promise<VcsDiffOutput>
|
||||
transform(callback: (draft: VcsDraft) => void): Promise<Registration>
|
||||
reload(): Promise<void>
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue