mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ceb5f3554e | |||
| 10a71bae4a | |||
| ef1c76a2bf | |||
| 8ede176244 | |||
| 66878b4c53 | |||
| 38502f7268 |
@@ -2461,7 +2461,7 @@ export type CredentialRemoveOutput = void
|
|||||||
export type ProjectListOutput = ReadonlyArray<{
|
export type ProjectListOutput = ReadonlyArray<{
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly worktree: string
|
readonly worktree: string
|
||||||
readonly vcs?: "git" | "hg"
|
readonly vcs?: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||||
readonly commands?: { readonly start?: string }
|
readonly commands?: { readonly start?: string }
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { ConfigProvider } from "./config/provider"
|
|||||||
import { ConfigReference } from "./config/reference"
|
import { ConfigReference } from "./config/reference"
|
||||||
import { ConfigToolOutput } from "./config/tool-output"
|
import { ConfigToolOutput } from "./config/tool-output"
|
||||||
import { ConfigVariable } from "./config/variable"
|
import { ConfigVariable } from "./config/variable"
|
||||||
|
import { ConfigVcs } from "./config/vcs"
|
||||||
import { ConfigWatcher } from "./config/watcher"
|
import { ConfigWatcher } from "./config/watcher"
|
||||||
import { ConfigV1 } from "./v1/config/config"
|
import { ConfigV1 } from "./v1/config/config"
|
||||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
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({
|
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||||
description: "Ordered plugin enablement directives and external package declarations",
|
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),
|
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -54,6 +54,7 @@ import { ToolRegistry } from "./tool/registry"
|
|||||||
import { WebSearchTool } from "./tool/websearch"
|
import { WebSearchTool } from "./tool/websearch"
|
||||||
import { ToolOutputStore } from "./tool-output-store"
|
import { ToolOutputStore } from "./tool-output-store"
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
|
import { VcsBackends } from "./vcs/backends"
|
||||||
|
|
||||||
export { LocationServiceMap } from "./location-service-map"
|
export { LocationServiceMap } from "./location-service-map"
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ const pluginSupervisorNode = makeLocationNode({
|
|||||||
Shell.node,
|
Shell.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
|
VcsBackends.node,
|
||||||
WebSearchTool.configNode,
|
WebSearchTool.configNode,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -137,6 +139,7 @@ const locationServiceNodes = [
|
|||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
SessionRunnerLLM.node,
|
SessionRunnerLLM.node,
|
||||||
|
VcsBackends.node,
|
||||||
Vcs.node,
|
Vcs.node,
|
||||||
// Start repository watches only after boot-critical filesystem and Git work.
|
// Start repository watches only after boot-critical filesystem and Git work.
|
||||||
LocationWatcher.node,
|
LocationWatcher.node,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export { Info, Ref, response }
|
|||||||
|
|
||||||
export interface Interface extends Info {
|
export interface Interface extends Info {
|
||||||
readonly vcs?: Project.Vcs
|
readonly vcs?: Project.Vcs
|
||||||
|
readonly vcsBackend?: Project.Vcs
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
||||||
@@ -27,6 +28,7 @@ const layer = (ref: Ref) =>
|
|||||||
workspaceID: ref.workspaceID,
|
workspaceID: ref.workspaceID,
|
||||||
project: { id: resolved.id, directory: resolved.directory },
|
project: { id: resolved.id, directory: resolved.directory },
|
||||||
vcs: resolved.vcs,
|
vcs: resolved.vcs,
|
||||||
|
vcsBackend: resolved.vcsBackend,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { SkillV2 } from "./skill"
|
|||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
import { ToolRegistry } from "./tool/registry"
|
import { ToolRegistry } from "./tool/registry"
|
||||||
import { ToolHooks } from "./tool/hooks"
|
import { ToolHooks } from "./tool/hooks"
|
||||||
|
import { VcsBackends } from "./vcs/backends"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
|
readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
|
||||||
@@ -131,6 +132,7 @@ export const node = makeLocationNode({
|
|||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
ToolHooks.node,
|
ToolHooks.node,
|
||||||
|
VcsBackends.node,
|
||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { AbsolutePath, type DeepMutable } from "../schema"
|
|||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
import { Tools } from "../tool/tools"
|
import { Tools } from "../tool/tools"
|
||||||
import { ToolHooks } from "../tool/hooks"
|
import { ToolHooks } from "../tool/hooks"
|
||||||
|
import { VcsBackends } from "../vcs/backends"
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
|
|
||||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
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 skill = yield* SkillV2.Service
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* Tools.Service
|
||||||
const toolHooks = yield* ToolHooks.Service
|
const toolHooks = yield* ToolHooks.Service
|
||||||
|
const vcsBackends = yield* VcsBackends.Service
|
||||||
const runtime = yield* PluginRuntime.Service
|
const runtime = yield* PluginRuntime.Service
|
||||||
const locationInfo = () =>
|
const locationInfo = () =>
|
||||||
new Location.Info({
|
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: {
|
session: {
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
runtime.session.create({
|
runtime.session.create({
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { Tools } from "../tool/tools"
|
|||||||
import { WebFetchTool } from "../tool/webfetch"
|
import { WebFetchTool } from "../tool/webfetch"
|
||||||
import { WebSearchTool } from "../tool/websearch"
|
import { WebSearchTool } from "../tool/websearch"
|
||||||
import { WriteTool } from "../tool/write"
|
import { WriteTool } from "../tool/write"
|
||||||
|
import { VcsBackends } from "../vcs/backends"
|
||||||
import { AgentPlugin } from "./agent"
|
import { AgentPlugin } from "./agent"
|
||||||
import { CommandPlugin } from "./command"
|
import { CommandPlugin } from "./command"
|
||||||
import { ModelsDevPlugin } from "./models-dev"
|
import { ModelsDevPlugin } from "./models-dev"
|
||||||
@@ -83,6 +84,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* Tools.Service
|
||||||
const websearch = yield* WebSearchTool.ConfigService
|
const websearch = yield* WebSearchTool.ConfigService
|
||||||
|
const vcs = yield* VcsBackends.Service
|
||||||
return Context.mergeAll(
|
return Context.mergeAll(
|
||||||
Context.make(AgentV2.Service, agent),
|
Context.make(AgentV2.Service, agent),
|
||||||
Context.make(Catalog.Service, catalog),
|
Context.make(Catalog.Service, catalog),
|
||||||
@@ -112,6 +114,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
Context.make(SkillV2.Service, skill),
|
Context.make(SkillV2.Service, skill),
|
||||||
Context.make(Tools.Service, tools),
|
Context.make(Tools.Service, tools),
|
||||||
Context.make(WebSearchTool.ConfigService, websearch),
|
Context.make(WebSearchTool.ConfigService, websearch),
|
||||||
|
Context.make(VcsBackends.Service, vcs),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { ChildProcess } from "effect/unstable/process"
|
|||||||
import { asc, desc } from "drizzle-orm"
|
import { asc, desc } from "drizzle-orm"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
|
import { ConfigVcs } from "./config/vcs"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Git } from "./git"
|
import { Git } from "./git"
|
||||||
|
import { Global } from "./global"
|
||||||
import { AppProcess } from "./process"
|
import { AppProcess } from "./process"
|
||||||
import { makeGlobalNode } from "./effect/app-node"
|
import { makeGlobalNode } from "./effect/app-node"
|
||||||
import { Hash } from "./util/hash"
|
import { Hash } from "./util/hash"
|
||||||
@@ -42,8 +44,14 @@ export interface Resolved {
|
|||||||
readonly id: ID
|
readonly id: ID
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly vcs?: Vcs
|
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.
|
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
|
||||||
export const root = Effect.fn("Project.root")(function* (
|
export const root = Effect.fn("Project.root")(function* (
|
||||||
fs: FSUtil.Interface,
|
fs: FSUtil.Interface,
|
||||||
@@ -103,6 +111,7 @@ const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
|
const global = yield* Global.Service
|
||||||
const proc = yield* AppProcess.Service
|
const proc = yield* AppProcess.Service
|
||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const projectDirectories = yield* ProjectDirectories.Service
|
const projectDirectories = yield* ProjectDirectories.Service
|
||||||
@@ -195,34 +204,85 @@ const layer = Layer.effect(
|
|||||||
Effect.catch(() => Effect.succeed(undefined)),
|
Effect.catch(() => Effect.succeed(undefined)),
|
||||||
)
|
)
|
||||||
if (!dotHg) return 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 {
|
return {
|
||||||
previous,
|
type: "hg" as const,
|
||||||
id: id ?? ID.global,
|
directory: AbsolutePath.make(path.dirname(dotHg)),
|
||||||
directory: worktree,
|
vcs: { type: "hg", store: AbsolutePath.make(dotHg) },
|
||||||
vcs: { type: "hg" as const, store },
|
} 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 resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||||
const repo = yield* git.repo.discover(input)
|
const repo = yield* git.repo.discover(input)
|
||||||
if (repo) {
|
const [hg, marker] = yield* Effect.all([hgDiscover(input), markerDiscover(input)])
|
||||||
const previous = yield* cached(repo.commonDirectory)
|
const discoveries: Discovery[] = [
|
||||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
...(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 {
|
return {
|
||||||
previous,
|
previous,
|
||||||
id: id ?? ID.global,
|
id: id ?? ID.global,
|
||||||
directory: repo.worktree,
|
directory: selected.directory,
|
||||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
vcs: selected.vcs,
|
||||||
|
vcsBackend,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hg = yield* hgDiscover(input)
|
const previous = yield* cached(selected.vcs.store)
|
||||||
if (hg) return hg
|
if (selected.type === "hg") {
|
||||||
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
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 }) {
|
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
||||||
@@ -236,5 +296,5 @@ const layer = Layer.effect(
|
|||||||
export const node = makeGlobalNode({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,14 +22,8 @@ export type DirectoriesInput = typeof DirectoriesInput.Type
|
|||||||
export const Directories = Project.Directories
|
export const Directories = Project.Directories
|
||||||
export type Directories = typeof Directories.Type
|
export type Directories = typeof Directories.Type
|
||||||
|
|
||||||
export const Vcs = Schema.Union([
|
export const Vcs = Schema.Struct({
|
||||||
Schema.Struct({
|
type: Schema.String,
|
||||||
type: Schema.Literal("git"),
|
store: AbsolutePath,
|
||||||
store: AbsolutePath,
|
})
|
||||||
}),
|
|
||||||
Schema.Struct({
|
|
||||||
type: Schema.Literal("hg"),
|
|
||||||
store: AbsolutePath,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
export type Vcs = typeof Vcs.Type
|
export type Vcs = typeof Vcs.Type
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ProjectSchema } from "./schema"
|
|||||||
export const ProjectTable = sqliteTable("project", {
|
export const ProjectTable = sqliteTable("project", {
|
||||||
id: text().$type<ProjectSchema.ID>().primaryKey(),
|
id: text().$type<ProjectSchema.ID>().primaryKey(),
|
||||||
worktree: absoluteColumn().notNull(),
|
worktree: absoluteColumn().notNull(),
|
||||||
vcs: text().$type<"git" | "hg">(),
|
vcs: text(),
|
||||||
name: text(),
|
name: text(),
|
||||||
icon_url: text(),
|
icon_url: text(),
|
||||||
icon_url_override: text(),
|
icon_url_override: text(),
|
||||||
|
|||||||
+29
-11
@@ -7,8 +7,10 @@ import { makeLocationNode } from "./effect/app-node"
|
|||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { AppProcess } from "./process"
|
import { AppProcess } from "./process"
|
||||||
|
import { VcsBackends } from "./vcs/backends"
|
||||||
import { VcsGit } from "./vcs/git"
|
import { VcsGit } from "./vcs/git"
|
||||||
import { VcsHg } from "./vcs/hg"
|
import { VcsHg } from "./vcs/hg"
|
||||||
|
import { MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch"
|
||||||
|
|
||||||
export { FileStatus, Mode }
|
export { FileStatus, Mode }
|
||||||
|
|
||||||
@@ -17,19 +19,16 @@ export interface DiffOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly status: () => Effect.Effect<FileStatus[]>
|
readonly status: () => Effect.Effect<readonly FileStatus[]>
|
||||||
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
|
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<readonly FileDiff.Info[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Vcs") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Vcs") {}
|
||||||
|
|
||||||
// Adapter seam: one working-copy implementation per VCS type, selected by the
|
const builtIn = (proc: AppProcess.Interface, fs: FSUtil.Interface, location: Location.Interface) => {
|
||||||
// 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 scope = { directory: location.directory, worktree: location.project.directory }
|
const scope = { directory: location.directory, worktree: location.project.directory }
|
||||||
if (location.vcs?.type === "git") return VcsGit.make(proc, scope)
|
if (location.vcsBackend?.type === "git") return VcsGit.make(proc, scope)
|
||||||
if (location.vcs?.type === "hg") return VcsHg.make(proc, fs, scope)
|
if (location.vcsBackend?.type === "hg") return VcsHg.make(proc, fs, scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
@@ -38,15 +37,34 @@ const layer = Layer.effect(
|
|||||||
const proc = yield* AppProcess.Service
|
const proc = yield* AppProcess.Service
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.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({
|
return Service.of({
|
||||||
status: Effect.fn("Vcs.status")(function* () {
|
status: Effect.fn("Vcs.status")(function* () {
|
||||||
|
const impl = yield* adapter()
|
||||||
if (!impl) return []
|
if (!impl) return []
|
||||||
return yield* impl.status()
|
return yield* impl.status()
|
||||||
}),
|
}),
|
||||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
|
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
|
||||||
|
const impl = yield* adapter()
|
||||||
if (!impl) return []
|
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({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
layer: layer,
|
||||||
deps: [AppProcess.node, FSUtil.node, Location.node],
|
deps: [AppProcess.node, FSUtil.node, Location.node, VcsBackends.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,12 +4,16 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
|||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { tmpdir } from "./tmpdir"
|
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 {
|
return {
|
||||||
directory: ref.directory,
|
directory: ref.directory,
|
||||||
workspaceID: ref.workspaceID,
|
workspaceID: ref.workspaceID,
|
||||||
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
|
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
|
||||||
vcs: input.vcs,
|
vcs: input.vcs,
|
||||||
|
vcsBackend: input.vcsBackend ?? input.vcs,
|
||||||
} satisfies Location.Interface
|
} satisfies Location.Interface
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Reference } from "@opencode-ai/core/reference"
|
|||||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
|
import { VcsBackends } from "@opencode-ai/core/vcs/backends"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { tempLocationLayer } from "../fixture/location"
|
import { tempLocationLayer } from "../fixture/location"
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
|||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolHooks.node,
|
ToolHooks.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
|
VcsBackends.node,
|
||||||
]),
|
]),
|
||||||
[
|
[
|
||||||
[Location.node, tempLocationLayer],
|
[Location.node, tempLocationLayer],
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||||||
command: () => Effect.die("unused session.command"),
|
command: () => Effect.die("unused session.command"),
|
||||||
interrupt: () => Effect.die("unused session.interrupt"),
|
interrupt: () => Effect.die("unused session.interrupt"),
|
||||||
},
|
},
|
||||||
|
vcs: overrides.vcs ?? {
|
||||||
|
register: () => Effect.die("unused vcs.register"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { afterAll, describe, expect } from "bun:test"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
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) {
|
function remoteID(remote: string) {
|
||||||
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
|
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")))
|
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
|
||||||
|
|
||||||
expect(result.vcs?.type).toBe("hg")
|
expect(result.vcs?.type).toBe("hg")
|
||||||
|
expect(result.vcsBackend?.type).toBe("hg")
|
||||||
expect(result.directory).toBe(abs(tmp.path))
|
expect(result.directory).toBe(abs(tmp.path))
|
||||||
expect(result.id).not.toBe(ProjectV2.ID.make("global"))
|
expect(result.id).not.toBe(ProjectV2.ID.make("global"))
|
||||||
expect(result.previous).toBeUndefined()
|
expect(result.previous).toBeUndefined()
|
||||||
@@ -290,6 +298,83 @@ describe("ProjectV2.resolve", () => {
|
|||||||
const result = yield* project.resolve(abs(tmp.path))
|
const result = yield* project.resolve(abs(tmp.path))
|
||||||
|
|
||||||
expect(result.vcs?.type).toBe("git")
|
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")}`)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ import type { ReferenceHooks } from "./reference.js"
|
|||||||
import type { SkillHooks } from "./skill.js"
|
import type { SkillHooks } from "./skill.js"
|
||||||
import type { ToolDomain } from "./tool.js"
|
import type { ToolDomain } from "./tool.js"
|
||||||
import type { SessionHooks } from "./runtime.js"
|
import type { SessionHooks } from "./runtime.js"
|
||||||
|
import type { VcsDomain } from "./vcs.js"
|
||||||
|
|
||||||
export interface PluginContext {
|
export interface PluginContext {
|
||||||
readonly options: PluginOptions
|
readonly options: PluginOptions
|
||||||
@@ -24,4 +25,5 @@ export interface PluginContext {
|
|||||||
readonly skill: SkillHooks
|
readonly skill: SkillHooks
|
||||||
readonly tool: ToolDomain
|
readonly tool: ToolDomain
|
||||||
readonly session: SessionHooks
|
readonly session: SessionHooks
|
||||||
|
readonly vcs: VcsDomain
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ export type { SkillDraft, SkillHooks } from "./skill.js"
|
|||||||
export * as Tool from "./tool.js"
|
export * as Tool from "./tool.js"
|
||||||
export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js"
|
export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js"
|
||||||
export type { SessionHooks } from "./runtime.js"
|
export type { SessionHooks } from "./runtime.js"
|
||||||
|
export { Vcs } from "./vcs.js"
|
||||||
|
export type { VcsDomain } from "./vcs.js"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { ProjectID } from "./project-id.js"
|
|||||||
export const ID = ProjectID
|
export const ID = ProjectID
|
||||||
export type ID = typeof ID.Type
|
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({
|
export const Current = Schema.Struct({
|
||||||
id: ID,
|
id: ID,
|
||||||
directory: AbsolutePath,
|
directory: AbsolutePath,
|
||||||
|
|||||||
@@ -744,7 +744,7 @@ export type Project = {
|
|||||||
id: string
|
id: string
|
||||||
worktree: string
|
worktree: string
|
||||||
vcsDir?: string
|
vcsDir?: string
|
||||||
vcs?: "git" | "hg"
|
vcs?: string
|
||||||
time: {
|
time: {
|
||||||
created: number
|
created: number
|
||||||
initialized?: number
|
initialized?: number
|
||||||
|
|||||||
@@ -334,6 +334,8 @@ import type {
|
|||||||
V2ProjectCurrentResponses,
|
V2ProjectCurrentResponses,
|
||||||
V2ProjectDirectoriesErrors,
|
V2ProjectDirectoriesErrors,
|
||||||
V2ProjectDirectoriesResponses,
|
V2ProjectDirectoriesResponses,
|
||||||
|
V2ProjectListErrors,
|
||||||
|
V2ProjectListResponses,
|
||||||
V2ProviderGetErrors,
|
V2ProviderGetErrors,
|
||||||
V2ProviderGetResponses,
|
V2ProviderGetResponses,
|
||||||
V2ProviderListErrors,
|
V2ProviderListErrors,
|
||||||
@@ -7032,6 +7034,18 @@ export class Credential extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Project2 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
|
* Get current project
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3492,7 +3492,7 @@ export type FormAnswer = {
|
|||||||
[key: string]: FormValue
|
[key: string]: FormValue
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectVcs = "git" | "hg"
|
export type ProjectVcs = string
|
||||||
|
|
||||||
export type ProjectIcon = {
|
export type ProjectIcon = {
|
||||||
url?: string
|
url?: string
|
||||||
@@ -9329,6 +9329,38 @@ export type McpServer2 = {
|
|||||||
integrationID?: string
|
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 = {
|
export type ProjectCurrent2 = {
|
||||||
id: string
|
id: string
|
||||||
directory: string
|
directory: string
|
||||||
@@ -17353,6 +17385,35 @@ export type V2CredentialUpdateResponses = {
|
|||||||
|
|
||||||
export type V2CredentialUpdateResponse = V2CredentialUpdateResponses[keyof 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 = {
|
export type V2ProjectCurrentData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|||||||
Reference in New Issue
Block a user