feat(core): detect plugin vcs backends from config markers

This commit is contained in:
Shoubhit Dash 2026-07-03 18:45:59 +05:30
commit 38502f7268
9 changed files with 162 additions and 15 deletions

View file

@ -22,6 +22,7 @@ import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVcs } from "./config/vcs"
import { ConfigWatcher } from "./config/watcher"
import { ConfigV1 } from "./v1/config/config"
import { ConfigMigrateV1 } from "./v1/config/migrate"
@ -102,6 +103,10 @@ export class Info extends Schema.Class<Info>("Config.Info")({
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered external plugin packages to load",
}),
vcs: ConfigVcs.Info.pipe(Schema.optional).annotate({
description:
"Plugin-provided VCS backends keyed by type; detection markers are only honored in global configuration",
}),
experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {}

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
})

View file

@ -5,8 +5,10 @@ import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "path"
import { AbsolutePath } from "./schema"
import { ConfigVcs } from "./config/vcs"
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"
@ -75,6 +77,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 projectDirectories = yield* ProjectDirectories.Service
@ -168,6 +171,27 @@ const layer = Layer.effect(
}
})
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
const store = AbsolutePath.make(match)
const previous = yield* cached(store)
return {
previous,
id: previous ?? ID.make(Hash.fast(`vcs-store:${store}`)),
directory: AbsolutePath.make(path.dirname(match)),
vcs: { type, store },
}
})
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.repo.discover(input)
if (repo) {
@ -183,6 +207,8 @@ const layer = Layer.effect(
const hg = yield* hgDiscover(input)
if (hg) return hg
const marker = yield* markerDiscover(input)
if (marker) return marker
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
})
@ -197,5 +223,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
deps: [FSUtil.node, Git.node, Global.node, AppProcess.node, ProjectDirectories.node],
})

View file

@ -19,14 +19,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

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)
})
})

View file

@ -1,9 +1,10 @@
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, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/core/util/hash"
@ -12,6 +13,12 @@ import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(ProjectV2.node))
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}`))
}
@ -237,6 +244,31 @@ describe("ProjectV2.resolve", () => {
}),
)
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.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")}`)))
}),
)
it.live("returns global id for unreadable mercurial metadata", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(

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,

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

View file

@ -3540,7 +3540,7 @@ export type FormAnswer = {
[key: string]: FormValue
}
export type ProjectVcs = "git" | "hg"
export type ProjectVcs = string
export type ProjectIcon = {
url?: string