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<{
|
||||
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 }
|
||||
|
||||
@@ -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),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -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 { 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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 { 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")}`)))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 { 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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user