From 38502f72683b8040d55113d6265f3546245f2894 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 3 Jul 2026 18:45:59 +0530 Subject: [PATCH] feat(core): detect plugin vcs backends from config markers --- packages/core/src/config.ts | 5 +++ packages/core/src/config/vcs.ts | 57 +++++++++++++++++++++++++ packages/core/src/project.ts | 28 +++++++++++- packages/core/src/project/schema.ts | 14 ++---- packages/core/test/config-vcs.test.ts | 33 ++++++++++++++ packages/core/test/project.test.ts | 34 ++++++++++++++- packages/schema/src/project.ts | 2 +- packages/sdk/js/src/gen/types.gen.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- 9 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/config/vcs.ts create mode 100644 packages/core/test/config-vcs.test.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 9d03d09c38..6c295cc06e 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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("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), }) {} diff --git a/packages/core/src/config/vcs.ts b/packages/core/src/config/vcs.ts new file mode 100644 index 0000000000..439e03960c --- /dev/null +++ b/packages/core/src/config/vcs.ts @@ -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((value) => + RESERVED.has(value) ? `'${value}' has built-in detection and cannot be redeclared` : undefined, + ), +) + +const Marker = Schema.String.check( + Schema.makeFilter((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("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>>((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() + 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 +}) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index fe15f11511..eb47ec83b9 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -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], }) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index 86c57e17ca..215aebc425 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -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 diff --git a/packages/core/test/config-vcs.test.ts b/packages/core/test/config-vcs.test.ts new file mode 100644 index 0000000000..d6af0517ff --- /dev/null +++ b/packages/core/test/config-vcs.test.ts @@ -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) + }) +}) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index ee1264f887..801ccb5226 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -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( diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index fce8d19ebf..71d6908105 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -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, diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 1055d82ce2..6bd43a78b2 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -744,7 +744,7 @@ export type Project = { id: string worktree: string vcsDir?: string - vcs?: "git" | "hg" + vcs?: string time: { created: number initialized?: number diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 13fbacae2b..199da657b0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3540,7 +3540,7 @@ export type FormAnswer = { [key: string]: FormValue } -export type ProjectVcs = "git" | "hg" +export type ProjectVcs = string export type ProjectIcon = { url?: string