Compare commits

...

6 Commits

Author SHA1 Message Date
Shoubhit Dash ceb5f3554e feat(plugin): bound vcs adapter diffs 2026-07-06 18:21:43 +05:30
Shoubhit Dash 10a71bae4a fix(core): harden vcs backend lifecycle 2026-07-06 18:20:17 +05:30
Shoubhit Dash ef1c76a2bf feat(core): split vcs backend selection 2026-07-06 18:19:13 +05:30
Shoubhit Dash 8ede176244 Merge remote-tracking branch 'origin/v2' into nxl/vcs-plugin-api
# Conflicts:
#	packages/core/src/config.ts
#	packages/core/src/plugin/internal.ts
#	packages/core/src/project.ts
#	packages/core/test/project.test.ts
#	packages/plugin/src/v2/effect/context.ts
#	packages/plugin/src/v2/effect/index.ts
2026-07-06 18:06:51 +05:30
Shoubhit Dash 66878b4c53 feat(plugin): add vcs backend registration api 2026-07-03 18:45:59 +05:30
Shoubhit Dash 38502f7268 feat(core): detect plugin vcs backends from config markers 2026-07-03 18:45:59 +05:30
26 changed files with 714 additions and 46 deletions
@@ -2461,7 +2461,7 @@ export type CredentialRemoveOutput = void
export type ProjectListOutput = ReadonlyArray<{
readonly id: string
readonly worktree: string
readonly vcs?: "git" | "hg"
readonly vcs?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
+5
View File
@@ -24,6 +24,7 @@ import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigVcs } from "./config/vcs"
import { ConfigWatcher } from "./config/watcher"
import { ConfigV1 } from "./v1/config/config"
import { ConfigMigrateV1 } from "./v1/config/migrate"
@@ -104,6 +105,10 @@ export class Info extends Schema.Class<Info>("Config.Info")({
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
vcs: ConfigVcs.Info.pipe(Schema.optional).annotate({
description:
"Plugin-provided VCS backends keyed by type; detection markers are only honored in global configuration",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {}
+57
View File
@@ -0,0 +1,57 @@
export * as ConfigVcs from "./vcs"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import { FSUtil } from "../fs-util"
const RESERVED = new Set(["git", "hg"])
export const Type = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9-]{0,31}$/)).check(
Schema.makeFilter<string>((value) =>
RESERVED.has(value) ? `'${value}' has built-in detection and cannot be redeclared` : undefined,
),
)
const Marker = Schema.String.check(
Schema.makeFilter<string>((value) => {
if (!value || value === "." || value === ".." || /[\\/]/.test(value)) {
return `marker must be a single path segment such as ".jj"`
}
return undefined
}),
)
export class Backend extends Schema.Class<Backend>("ConfigV2.Vcs.Backend")({
marker: Marker.annotate({
description: 'Directory name that marks a repository root for this backend, such as ".jj"',
}),
}) {}
export const Info = Schema.Record(Type, Backend).check(
Schema.makeFilter<Readonly<Record<string, Backend>>>((value) => {
const markers = Object.values(value).map((backend) => backend.marker)
return new Set(markers).size === markers.length ? undefined : "vcs backends must declare distinct markers"
}),
)
export type Info = typeof Info.Type
const decode = Schema.decodeUnknownOption(Schema.Struct({ vcs: Info.pipe(Schema.optional) }), {
onExcessProperty: "ignore",
})
export const readGlobal = Effect.fnUntraced(function* (fs: FSUtil.Interface, configDirectory: string) {
const backends = new Map<string, Backend>()
for (const name of ["opencode.json", "opencode.jsonc"]) {
const text = yield* fs
.readFileStringSafe(path.join(configDirectory, name))
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!text) continue
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) continue
const info = Option.getOrUndefined(decode(input))?.vcs
for (const [type, backend] of Object.entries(info ?? {})) backends.set(type, backend)
}
return backends
})
+3
View File
@@ -54,6 +54,7 @@ import { ToolRegistry } from "./tool/registry"
import { WebSearchTool } from "./tool/websearch"
import { ToolOutputStore } from "./tool-output-store"
import { Vcs } from "./vcs"
import { VcsBackends } from "./vcs/backends"
export { LocationServiceMap } from "./location-service-map"
@@ -90,6 +91,7 @@ const pluginSupervisorNode = makeLocationNode({
Shell.node,
SkillV2.node,
ToolRegistry.toolsNode,
VcsBackends.node,
WebSearchTool.configNode,
],
})
@@ -137,6 +139,7 @@ const locationServiceNodes = [
SessionTitle.node,
Snapshot.node,
SessionRunnerLLM.node,
VcsBackends.node,
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
+2
View File
@@ -10,6 +10,7 @@ export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
readonly vcsBackend?: Project.Vcs
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
@@ -27,6 +28,7 @@ const layer = (ref: Ref) =>
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
vcsBackend: resolved.vcsBackend,
})
}),
)
+2
View File
@@ -18,6 +18,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 interface Interface {
readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
@@ -131,6 +132,7 @@ export const node = makeLocationNode({
SkillV2.node,
ToolRegistry.toolsNode,
ToolHooks.node,
VcsBackends.node,
PluginRuntime.node,
],
})
+5
View File
@@ -20,6 +20,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 = <T>(value: T) => value as DeepMutable<T>
@@ -35,6 +36,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({
@@ -341,6 +343,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
},
},
vcs: {
register: (backend) => vcsBackends.register(backend),
},
session: {
create: (input) =>
runtime.session.create({
+3
View File
@@ -46,6 +46,7 @@ import { Tools } from "../tool/tools"
import { WebFetchTool } from "../tool/webfetch"
import { WebSearchTool } from "../tool/websearch"
import { WriteTool } from "../tool/write"
import { VcsBackends } from "../vcs/backends"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
@@ -83,6 +84,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearchTool.ConfigService
const vcs = yield* VcsBackends.Service
return Context.mergeAll(
Context.make(AgentV2.Service, agent),
Context.make(Catalog.Service, catalog),
@@ -112,6 +114,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(SkillV2.Service, skill),
Context.make(Tools.Service, tools),
Context.make(WebSearchTool.ConfigService, websearch),
Context.make(VcsBackends.Service, vcs),
)
})
+78 -18
View File
@@ -6,9 +6,11 @@ import { ChildProcess } from "effect/unstable/process"
import { asc, desc } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema"
import { ConfigVcs } from "./config/vcs"
import { Database } from "./database/database"
import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
import { AppProcess } from "./process"
import { makeGlobalNode } from "./effect/app-node"
import { Hash } from "./util/hash"
@@ -42,8 +44,14 @@ export interface Resolved {
readonly id: ID
readonly directory: AbsolutePath
readonly vcs?: Vcs
readonly vcsBackend?: Vcs
}
type Discovery =
| { readonly type: "git"; readonly directory: AbsolutePath; readonly vcs: Vcs; readonly repo: Git.Repository }
| { readonly type: "hg"; readonly directory: AbsolutePath; readonly vcs: Vcs }
| { readonly type: "plugin"; readonly directory: AbsolutePath; readonly vcs: Vcs }
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
@@ -103,6 +111,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const global = yield* Global.Service
const proc = yield* AppProcess.Service
const db = (yield* Database.Service).db
const projectDirectories = yield* ProjectDirectories.Service
@@ -195,34 +204,85 @@ const layer = Layer.effect(
Effect.catch(() => Effect.succeed(undefined)),
)
if (!dotHg) return undefined
const worktree = AbsolutePath.make(path.dirname(dotHg))
const store = AbsolutePath.make(dotHg)
const previous = yield* cached(store)
const id = previous ?? (yield* hgRoot(worktree))
return {
previous,
id: id ?? ID.global,
directory: worktree,
vcs: { type: "hg" as const, store },
}
type: "hg" as const,
directory: AbsolutePath.make(path.dirname(dotHg)),
vcs: { type: "hg", store: AbsolutePath.make(dotHg) },
} satisfies Discovery
})
const markerDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
const backends = yield* ConfigVcs.readGlobal(fs, global.config)
if (backends.size === 0) return undefined
const types = new Map([...backends].map(([type, backend]) => [backend.marker, type] as const))
const match = yield* fs.up({ targets: [...types.keys()], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
if (!match) return undefined
const type = types.get(path.basename(match))
if (!type) return undefined
return {
type: "plugin" as const,
directory: AbsolutePath.make(path.dirname(match)),
vcs: { type, store: AbsolutePath.make(match) },
} satisfies Discovery
})
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.repo.discover(input)
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
const [hg, marker] = yield* Effect.all([hgDiscover(input), markerDiscover(input)])
const discoveries: Discovery[] = [
...(repo
? [
{
type: "git" as const,
directory: repo.worktree,
vcs: { type: "git", store: repo.commonDirectory },
repo,
} satisfies Discovery,
]
: []),
...(hg ? [hg] : []),
...(marker ? [marker] : []),
]
const distance = (directory: AbsolutePath) =>
path.relative(directory, input).split(path.sep).filter(Boolean).length
const selected = discoveries.toSorted((a, b) => distance(a.directory) - distance(b.directory))[0]
if (!selected) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
const vcsBackend = marker?.directory === selected.directory ? marker.vcs : selected.vcs
if (selected.type === "git") {
const previous = yield* cached(selected.repo.commonDirectory)
const id = (yield* remote(selected.repo)) ?? previous ?? (yield* root(selected.repo))
return {
previous,
id: id ?? ID.global,
directory: repo.worktree,
vcs: { type: "git" as const, store: repo.commonDirectory },
directory: selected.directory,
vcs: selected.vcs,
vcsBackend,
}
}
const hg = yield* hgDiscover(input)
if (hg) return hg
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
const previous = yield* cached(selected.vcs.store)
if (selected.type === "hg") {
const id = previous ?? (yield* hgRoot(selected.directory))
return {
previous,
id: id ?? ID.global,
directory: selected.directory,
vcs: selected.vcs,
vcsBackend,
}
}
return {
previous,
id: previous ?? ID.make(Hash.fast(`vcs-store:${selected.vcs.store}`)),
directory: selected.directory,
vcs: selected.vcs,
vcsBackend,
}
})
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
@@ -236,5 +296,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
deps: [Database.node, FSUtil.node, Git.node, Global.node, AppProcess.node, ProjectDirectories.node],
})
+4 -10
View File
@@ -22,14 +22,8 @@ export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = Project.Directories
export type Directories = typeof Directories.Type
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
store: AbsolutePath,
}),
Schema.Struct({
type: Schema.Literal("hg"),
store: AbsolutePath,
}),
])
export const Vcs = Schema.Struct({
type: Schema.String,
store: AbsolutePath,
})
export type Vcs = typeof Vcs.Type
+1 -1
View File
@@ -6,7 +6,7 @@ import { ProjectSchema } from "./schema"
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectSchema.ID>().primaryKey(),
worktree: absoluteColumn().notNull(),
vcs: text().$type<"git" | "hg">(),
vcs: text(),
name: text(),
icon_url: text(),
icon_url_override: text(),
+29 -11
View File
@@ -7,8 +7,10 @@ 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"
import { MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch"
export { FileStatus, Mode }
@@ -17,19 +19,16 @@ export interface DiffOptions {
}
export interface Interface {
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
readonly status: () => Effect.Effect<readonly FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<readonly FileDiff.Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@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)
if (location.vcsBackend?.type === "git") return VcsGit.make(proc, scope)
if (location.vcsBackend?.type === "hg") return VcsHg.make(proc, fs, scope)
}
const layer = Layer.effect(
@@ -38,15 +37,34 @@ 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.vcsBackend) return undefined
const plugin = backends.get(location.vcsBackend.type)
if (!plugin && !warned) {
warned = true
yield* Effect.logWarning("vcs backend declared but not registered", { type: location.vcsBackend.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)
return yield* impl.diff(mode, {
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
}),
})
}),
@@ -55,5 +73,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],
})
+120
View File
@@ -0,0 +1,120 @@
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 { Cause, Context, Effect, Exit, Layer, Option, Schema } from "effect"
import type { Scope } from "effect"
import { ConfigVcs } from "../config/vcs"
import { makeLocationNode } from "../effect/app-node"
import { Location } from "../location"
export interface Interface {
readonly register: (backend: Vcs.Backend) => Effect.Effect<void, Vcs.RegistrationError, Scope.Scope>
readonly get: (type: string) => Vcs.Adapter | undefined
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/VcsBackends") {}
const decodeType = Schema.decodeUnknownOption(ConfigVcs.Type)
const decodeStatus = Schema.decodeUnknownOption(Schema.Array(FileStatus))
const decodeDiff = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
interface Entry {
readonly backend: Vcs.Backend
adapter?: Vcs.Adapter
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const registry = new Map<string, Entry>()
return Service.of({
register: (backend) =>
Effect.gen(function* () {
if (Option.isNone(decodeType(backend.type))) {
return yield* new Vcs.RegistrationError({
type: backend.type,
message: `Invalid vcs backend type '${backend.type}'`,
})
}
if (registry.has(backend.type)) {
return yield* new Vcs.RegistrationError({
type: backend.type,
message: `Vcs backend '${backend.type}' is already registered`,
})
}
yield* Effect.uninterruptible(
Effect.gen(function* () {
registry.set(backend.type, { backend })
yield* Effect.addFinalizer(() => Effect.sync(() => registry.delete(backend.type)))
}),
)
}),
get: (type) => {
const vcs = location.vcsBackend
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),
Effect.map((rows) => boundDiff(rows, options.maxOutputBytes)),
),
}
}
function boundDiff(rows: readonly FileDiff.Info[], maxOutputBytes: number) {
let total = 0
return rows.map((row) => {
if (row.patch === undefined) return row
const bytes = Buffer.byteLength(row.patch)
if (total + bytes > maxOutputBytes) return { ...row, patch: undefined }
total += bytes
return row
})
}
function sanitize<A>(type: string, operation: string, decode: (input: unknown) => Option.Option<readonly A[]>) {
return <E, R>(effect: Effect.Effect<readonly A[], E, R>) =>
effect.pipe(
Effect.exit,
Effect.flatMap((exit) => {
if (Exit.isFailure(exit)) {
if (Cause.hasInterrupts(exit.cause)) return Effect.failCause(exit.cause)
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),
})
}),
)
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { Option, Schema } from "effect"
import { ConfigVcs } from "@opencode-ai/core/config/vcs"
const decode = Schema.decodeUnknownOption(ConfigVcs.Info)
describe("ConfigVcs", () => {
test("accepts backend declarations keyed by type", () => {
const result = Option.getOrUndefined(decode({ jj: { marker: ".jj" } }))
expect(result?.["jj"]?.marker).toBe(".jj")
})
test("rejects reserved built-in types", () => {
expect(Option.isNone(decode({ git: { marker: ".mygit" } }))).toBe(true)
expect(Option.isNone(decode({ hg: { marker: ".myhg" } }))).toBe(true)
})
test("rejects invalid type slugs", () => {
expect(Option.isNone(decode({ "Not A Slug": { marker: ".x" } }))).toBe(true)
expect(Option.isNone(decode({ "9starts-with-digit": { marker: ".x" } }))).toBe(true)
})
test("rejects markers that are not a single path segment", () => {
expect(Option.isNone(decode({ jj: { marker: "" } }))).toBe(true)
expect(Option.isNone(decode({ jj: { marker: ".." } }))).toBe(true)
expect(Option.isNone(decode({ jj: { marker: "a/b" } }))).toBe(true)
})
test("rejects duplicate markers across types", () => {
expect(Option.isNone(decode({ jj: { marker: ".x" }, piper: { marker: ".x" } }))).toBe(true)
expect(Option.isSome(decode({ jj: { marker: ".jj" }, piper: { marker: ".piper" } }))).toBe(true)
})
})
+5 -1
View File
@@ -4,12 +4,16 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Layer } from "effect"
import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
export function location(
ref: Location.Ref,
input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs; vcsBackend?: Project.Vcs } = {},
) {
return {
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
vcsBackend: input.vcsBackend ?? input.vcs,
} satisfies Location.Interface
}
+2
View File
@@ -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],
+3
View File
@@ -84,6 +84,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"),
},
}
}
+86 -1
View File
@@ -1,10 +1,11 @@
import { describe, expect } from "bun:test"
import { afterAll, describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Database } from "@opencode-ai/core/database/database"
import { Global } from "@opencode-ai/core/global"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -68,6 +69,12 @@ describe("ProjectV2.list", () => {
)
})
const globalConfig = await tmpdir()
afterAll(() => globalConfig[Symbol.asyncDispose]())
const itMarker = testEffect(
AppNodeBuilder.build(ProjectV2.node, [[Global.node, Global.layerWith({ config: globalConfig.path })]]),
)
function remoteID(remote: string) {
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
}
@@ -271,6 +278,7 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
expect(result.vcs?.type).toBe("hg")
expect(result.vcsBackend?.type).toBe("hg")
expect(result.directory).toBe(abs(tmp.path))
expect(result.id).not.toBe(ProjectV2.ID.make("global"))
expect(result.previous).toBeUndefined()
@@ -290,6 +298,83 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
expect(result.vcs?.type).toBe("git")
expect(result.vcsBackend?.type).toBe("git")
}),
)
itMarker.live("uses a plugin backend without replacing colocated git repository identity", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await Bun.write(
path.join(globalConfig.path, "opencode.json"),
JSON.stringify({ vcs: { jj: { marker: ".jj" } } }),
)
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(tmp.path, ".jj"))
})
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.vcs?.type).toBe("git")
expect(result.vcsBackend?.type).toBe("jj")
expect(result.directory).toBe(yield* real(tmp.path))
}),
)
itMarker.live("prefers a nested plugin repository over an ancestor git repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const nested = path.join(tmp.path, "nested")
yield* Effect.promise(async () => {
await Bun.write(
path.join(globalConfig.path, "opencode.json"),
JSON.stringify({ vcs: { jj: { marker: ".jj" } } }),
)
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(nested, ".jj"), { recursive: true })
await fs.mkdir(path.join(nested, "src"))
})
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(path.join(nested, "src")))
expect(result.vcs?.type).toBe("jj")
expect(result.vcsBackend?.type).toBe("jj")
expect(result.directory).toBe(abs(nested))
}),
)
itMarker.live("detects plugin backends from global config markers", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await Bun.write(
path.join(globalConfig.path, "opencode.json"),
JSON.stringify({ vcs: { jj: { marker: ".jj" } } }),
)
await fs.mkdir(path.join(tmp.path, ".jj"))
await fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true })
})
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
expect(result.vcs?.type).toBe("jj")
expect(result.vcsBackend?.type).toBe("jj")
expect(result.vcs?.store).toBe(abs(path.join(tmp.path, ".jj")))
expect(result.directory).toBe(abs(tmp.path))
expect(result.id).toBe(ProjectV2.ID.make(Hash.fast(`vcs-store:${path.join(tmp.path, ".jj")}`)))
}),
)
+158
View File
@@ -0,0 +1,158 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Vcs as PluginVcs } from "@opencode-ai/plugin/v2/effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { VcsBackends } from "@opencode-ai/core/vcs/backends"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
const directory = AbsolutePath.make("/repo")
const provide = Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, VcsBackends.node]), [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory }, { vcs: { type: "fake", store: AbsolutePath.make("/repo/.fake") } }),
),
),
],
]),
)
const status = [{ file: "a.txt", additions: 1, deletions: 0, status: "added" as const }]
const diff = [{ file: "a.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" as const }]
const backend = (overrides: Partial<PluginVcs.Adapter> = {}): PluginVcs.Backend => ({
type: "fake",
make: () => ({
status: () => Effect.succeed(status),
diff: () => Effect.succeed(diff),
...overrides,
}),
})
const register = (input: PluginVcs.Backend) =>
Effect.gen(function* () {
const backends = yield* VcsBackends.Service
return yield* backends.register(input)
})
describe("VcsBackends", () => {
it.live("serves status and diff through a registered backend", () =>
Effect.gen(function* () {
yield* register(backend())
const vcs = yield* Vcs.Service
expect(yield* vcs.status()).toEqual(status)
expect(yield* vcs.diff("working")).toEqual(diff)
}).pipe(Effect.scoped, provide),
)
it.live("passes normalized options and bounds plugin diffs", () =>
Effect.gen(function* () {
let received: Parameters<PluginVcs.Adapter["diff"]>[1] | undefined
yield* register(
backend({
diff: (_mode, options) => {
received = options
return Effect.succeed([{ ...diff[0], patch: "x".repeat(options.maxOutputBytes + 1) }])
},
}),
)
const vcs = yield* Vcs.Service
expect(yield* vcs.diff("working", { context: 3 })).toEqual([{ ...diff[0], patch: undefined }])
expect(received).toEqual({ context: 3, maxOutputBytes: 10_000_000 })
}).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("preserves adapter interruption", () =>
Effect.gen(function* () {
yield* register(backend({ status: () => Effect.never }))
const vcs = yield* Vcs.Service
const fiber = yield* Effect.forkChild(vcs.status())
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true)
}).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),
)
})
+2
View File
@@ -10,6 +10,7 @@ import type { ReferenceHooks } from "./reference.js"
import type { SkillHooks } from "./skill.js"
import type { ToolDomain } from "./tool.js"
import type { SessionHooks } from "./runtime.js"
import type { VcsDomain } from "./vcs.js"
export interface PluginContext {
readonly options: PluginOptions
@@ -24,4 +25,5 @@ export interface PluginContext {
readonly skill: SkillHooks
readonly tool: ToolDomain
readonly session: SessionHooks
readonly vcs: VcsDomain
}
+2
View File
@@ -12,3 +12,5 @@ export type { SkillDraft, SkillHooks } from "./skill.js"
export * as Tool from "./tool.js"
export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js"
export type { SessionHooks } from "./runtime.js"
export { Vcs } from "./vcs.js"
export type { VcsDomain } from "./vcs.js"
+35
View File
@@ -0,0 +1,35 @@
export * as Vcs from "./vcs.js"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { Schema, type Effect, type Scope } from "effect"
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Vcs.RegistrationError", {
type: Schema.String,
message: Schema.String,
}) {}
export interface AdapterScope {
readonly directory: string
readonly worktree: string
readonly store: string
}
export interface DiffOptions {
readonly context: number
readonly maxOutputBytes: number
}
export interface Adapter {
readonly status: () => Effect.Effect<readonly FileStatus[]>
readonly diff: (mode: Mode, options: DiffOptions) => Effect.Effect<readonly FileDiff.Info[]>
}
export interface Backend {
readonly type: string
readonly make: (scope: AdapterScope) => Adapter
}
export interface VcsDomain {
readonly register: (backend: Backend) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { ProjectID } from "./project-id.js"
export const ID = ProjectID
export type ID = typeof ID.Type
export const Vcs = Schema.Literals(["git", "hg"]).annotate({ identifier: "Project.Vcs" })
export const Vcs = Schema.String.annotate({ identifier: "Project.Vcs" })
export const Current = Schema.Struct({
id: ID,
directory: AbsolutePath,
+1 -1
View File
@@ -744,7 +744,7 @@ export type Project = {
id: string
worktree: string
vcsDir?: string
vcs?: "git" | "hg"
vcs?: string
time: {
created: number
initialized?: number
+14
View File
@@ -334,6 +334,8 @@ import type {
V2ProjectCurrentResponses,
V2ProjectDirectoriesErrors,
V2ProjectDirectoriesResponses,
V2ProjectListErrors,
V2ProjectListResponses,
V2ProviderGetErrors,
V2ProviderGetResponses,
V2ProviderListErrors,
@@ -7032,6 +7034,18 @@ export class Credential extends HeyApiClient {
}
export class Project2 extends HeyApiClient {
/**
* List projects
*
* List known projects.
*/
public list<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<V2ProjectListResponses, V2ProjectListErrors, ThrowOnError>({
url: "/api/project",
...options,
})
}
/**
* Get current project
*
+62 -1
View File
@@ -3492,7 +3492,7 @@ export type FormAnswer = {
[key: string]: FormValue
}
export type ProjectVcs = "git" | "hg"
export type ProjectVcs = string
export type ProjectIcon = {
url?: string
@@ -9329,6 +9329,38 @@ export type McpServer2 = {
integrationID?: string
}
export type ProjectVcs2 = string
export type ProjectIcon2 = {
url?: string
override?: string
color?: string
}
export type ProjectCommands2 = {
/**
* Startup script to run when creating a new workspace (worktree)
*/
start?: string
}
export type ProjectTime2 = {
created: number
updated: number
initialized?: number
}
export type ProjectV2 = {
id: string
worktree: string
vcs?: ProjectVcs2
name?: string
icon?: ProjectIcon2
commands?: ProjectCommands2
time: ProjectTime2
sandboxes: Array<string>
}
export type ProjectCurrent2 = {
id: string
directory: string
@@ -17353,6 +17385,35 @@ export type V2CredentialUpdateResponses = {
export type V2CredentialUpdateResponse = V2CredentialUpdateResponses[keyof V2CredentialUpdateResponses]
export type V2ProjectListData = {
body?: never
path?: never
query?: never
url: "/api/project"
}
export type V2ProjectListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
}
export type V2ProjectListError = V2ProjectListErrors[keyof V2ProjectListErrors]
export type V2ProjectListResponses = {
/**
* Success
*/
200: Array<ProjectV2>
}
export type V2ProjectListResponse = V2ProjectListResponses[keyof V2ProjectListResponses]
export type V2ProjectCurrentData = {
body?: never
path?: never