feat(core): detect plugin vcs backends from config markers
This commit is contained in:
parent
5657cec4f2
commit
38502f7268
9 changed files with 162 additions and 15 deletions
|
|
@ -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),
|
||||
}) {}
|
||||
|
|
|
|||
57
packages/core/src/config/vcs.ts
Normal file
57
packages/core/src/config/vcs.ts
Normal 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
|
||||
})
|
||||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
33
packages/core/test/config-vcs.test.ts
Normal file
33
packages/core/test/config-vcs.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -744,7 +744,7 @@ export type Project = {
|
|||
id: string
|
||||
worktree: string
|
||||
vcsDir?: string
|
||||
vcs?: "git" | "hg"
|
||||
vcs?: string
|
||||
time: {
|
||||
created: number
|
||||
initialized?: number
|
||||
|
|
|
|||
|
|
@ -3540,7 +3540,7 @@ export type FormAnswer = {
|
|||
[key: string]: FormValue
|
||||
}
|
||||
|
||||
export type ProjectVcs = "git" | "hg"
|
||||
export type ProjectVcs = string
|
||||
|
||||
export type ProjectIcon = {
|
||||
url?: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue