feat(vcs): initialize through provider definitions

This commit is contained in:
Brendonovich
2026-09-29 06:55:27 +00:00
parent cb7f6b7ba4
commit 8c46c09f8e
20 changed files with 215 additions and 34 deletions
+1 -1
View File
@@ -2225,7 +2225,7 @@ export interface WorktreeApi<E = never> {
export type VcsInitInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: "git" | undefined
readonly provider?: string | undefined
}
export type VcsInitOutput = void
export type VcsInitOperation<E = never> = (input?: VcsInitInput) => Effect.Effect<VcsInitOutput, E>
@@ -6252,11 +6252,11 @@ export type WorktreeRefreshOutput = void
export type VcsInitInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: "git" | undefined
readonly provider?: string | undefined
}["location"]
readonly provider?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: "git" | undefined
readonly provider?: string | undefined
}["provider"]
}
+9 -1
View File
@@ -23,7 +23,8 @@ export const Plugin = define({
id: "opencode.vcs.git",
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
// Markerless locations need this definition to initialize their first repository.
if (location.vcs && location.vcs.type !== "git") return
const processes = yield* AppProcess.Service
const adapter = make(processes, {
@@ -35,6 +36,13 @@ export const Plugin = define({
editor.add({
id: "git",
name: "Git",
init: (input) =>
Effect.gen(function* () {
const result = yield* processes.run(
ChildProcess.make(gitExecutable, ["init"], { cwd: input.worktree, stdin: "ignore" }),
)
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Git initialization failed"))
}),
info: () => adapter.info(),
base: () => adapter.base(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
+14 -1
View File
@@ -26,7 +26,8 @@ export const Plugin = define({
id: "opencode.vcs.hg",
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
// Markerless locations need this definition to initialize their first repository.
if (location.vcs && location.vcs.type !== "hg") return
const processes = yield* AppProcess.Service
const fs = yield* FSUtil.Service
@@ -39,6 +40,18 @@ export const Plugin = define({
editor.add({
id: "hg",
name: "Mercurial",
init: (input) =>
Effect.gen(function* () {
const result = yield* processes.run(
ChildProcess.make("hg", ["init"], {
cwd: input.worktree,
env: { HGPLAIN: "1" },
extendEnv: true,
stdin: "ignore",
}),
)
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Mercurial initialization failed"))
}),
info: () => adapter.info(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
+1 -16
View File
@@ -36,10 +36,6 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Project.
projectID: ID,
}) {}
export class InitializeGitError extends Schema.TaggedError<InitializeGitError>()("Project.InitializeGitError", {
kind: Schema.Literals(["missing", "conflict", "failed"]),
}) {}
export interface Resolved {
readonly previous?: ID
readonly id: ID
@@ -70,7 +66,6 @@ export interface Interface {
readonly activate: (projectID: ID) => Effect.Effect<void>
/** Resolves and persists the owning Project. */
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
readonly initializeGit: (directory: AbsolutePath) => Effect.Effect<Resolved, InitializeGitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@@ -387,17 +382,7 @@ const layer = Layer.effect(
})
})
const initializeGit = Effect.fn("Project.initializeGit")(function* (directory: AbsolutePath) {
if (!(yield* fs.isDir(directory))) return yield* new InitializeGitError({ kind: "missing" })
if ((yield* resolve(directory)).vcs) return yield* new InitializeGitError({ kind: "conflict" })
const result = yield* proc
.run(ChildProcess.make("git", ["init"], { cwd: directory, stdin: "ignore" }))
.pipe(Effect.mapError(() => new InitializeGitError({ kind: "failed" })))
if (result.exitCode !== 0) return yield* new InitializeGitError({ kind: "failed" })
return yield* resolve(directory)
})
return Service.of({ list, update, activate, resolve, initializeGit })
return Service.of({ list, update, activate, resolve })
}),
)
+26 -1
View File
@@ -10,6 +10,7 @@ import { VcsEvent } from "@opencode/schema/vcs-event"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { FSUtil } from "@opencode/util/fs-util"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { Bus } from "./bus.js"
import { State } from "./state.js"
import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js"
@@ -20,6 +21,10 @@ export class DiffError extends Schema.TaggedError<DiffError>()("Vcs.DiffError",
message: Schema.String,
}) {}
export class InitializeError extends Schema.TaggedError<InitializeError>()("Vcs.InitializeError", {
kind: Schema.Literals(["missing", "conflict", "unsupported", "failed"]),
}) {}
export interface DiffOptions {
readonly context?: number
readonly base?: string
@@ -40,6 +45,7 @@ export interface Adapter {
export interface Interface extends Adapter, State.Transformable<VcsEditor> {
readonly base: () => Effect.Effect<Base | null, DiffError>
readonly initialize: (providerID: string) => Effect.Effect<void, InitializeError>
}
interface Data {
@@ -124,7 +130,9 @@ const layer = Layer.effect(
const provider = selected()
const next: Info = provider
? {
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })),
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), {
branch: {},
})),
provider: provider.id,
}
: { branch: {} }
@@ -166,6 +174,23 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
initialize: Effect.fn("Vcs.initialize")(function* (providerID: string) {
if (!(yield* fs.isDir(location.project.directory))) return yield* new InitializeError({ kind: "missing" })
if (yield* Project.root(fs, location.project.directory)) return yield* new InitializeError({ kind: "conflict" })
const provider = state.get().providers.get(providerID)
if (!provider?.init) return yield* new InitializeError({ kind: "unsupported" })
yield* provider
.init(scope)
.pipe(
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.failCause(cause).pipe(Effect.orDie)
: Effect.logWarning("vcs initialization failed", { provider: providerID, cause }).pipe(
Effect.andThen(Effect.fail(new InitializeError({ kind: "failed" }))),
),
),
)
}),
info: Effect.fn("Vcs.info")(function* () {
return current.info
}),
@@ -101,7 +101,6 @@ describe("node build", () => {
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
initializeGit: () => Effect.die("not implemented"),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
}),
-1
View File
@@ -14,7 +14,6 @@ export const globalProjectNode = makeGlobalNode({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
initializeGit: () => Effect.die("not implemented"),
resolve: (directory) => {
const project = { id: Project.ID.global, directory, canonical: directory }
return upsertProject(database.db, project).pipe(Effect.orDie, Effect.as(project))
-1
View File
@@ -15,7 +15,6 @@ const projectLayer = Layer.succeed(
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
initializeGit: () => Effect.die("not implemented"),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
@@ -0,0 +1,48 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode/plugin"
import type { VcsDefinition } from "@opencode/plugin/effect/vcs"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { State } from "@opencode/core/state"
import { Effect } from "effect"
import { it } from "../lib/effect"
import { host } from "./host"
it.live("Promise VCS initializer reaches the registered provider with its location scope", () =>
Effect.gen(function* () {
const calls: string[] = []
const state = State.create({
initial: () => new Map<string, VcsDefinition>(),
editor: (providers) => ({
add: (definition: VcsDefinition) => providers.set(definition.id, definition),
default: { get: () => undefined, set: (_selection: string) => {} },
}),
})
const context = host()
const plugin = PluginPromise.fromPromise(
Plugin.define({
id: "init-vcs",
async setup(ctx) {
await ctx.vcs.transform((editor) =>
editor.add({
id: "custom",
name: "Custom VCS",
init: async (scope, { signal }) => {
expect(signal.aborted).toBe(false)
calls.push(scope.worktree)
},
info: async () => ({ branch: {} }),
branches: async () => [],
status: async () => [],
diff: async () => [],
}),
)
},
}),
)
yield* plugin.effect(host({ vcs: { ...context.vcs, transform: state.transform, reload: state.reload } }))
const init = state.get().get("custom")?.init
if (!init) return yield* Effect.die("Initializer was not registered")
yield* init({ directory: "/workspace", worktree: "/workspace", canonical: "/workspace" })
expect(calls).toEqual(["/workspace"])
}),
)
+23
View File
@@ -103,6 +103,29 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
}) satisfies VcsDefinition
describe("Vcs", () => {
it.live("routes initialization through the requested provider before a repository exists", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const calls: string[] = []
yield* vcs.transform((editor) => {
editor.add(provider({ id: "read-only" }))
editor.add(
provider({
id: "custom",
init: (input) => Effect.sync(() => void calls.push(input.worktree)),
}),
)
})
expect(yield* vcs.initialize("missing").pipe(Effect.flip)).toMatchObject({ kind: "unsupported" })
expect(yield* vcs.initialize("read-only").pipe(Effect.flip)).toMatchObject({ kind: "unsupported" })
expect(calls).toEqual([])
yield* vcs.initialize("custom")
expect(calls).toEqual([directory])
}).pipe(provide(directory)),
),
)
it.live("returns empty results outside version control", () =>
withTmp((directory) =>
Effect.gen(function* () {
+1
View File
@@ -26,6 +26,7 @@ export interface VcsDiffInput extends VcsScope {
export interface VcsDefinition {
readonly id: string
readonly name: string
readonly init?: (input: VcsScope) => Effect.Effect<void, unknown>
readonly info: (input: VcsScope) => Effect.Effect<Vcs.Info, unknown>
readonly base?: (input: VcsScope) => Effect.Effect<Vcs.Base | null, unknown>
readonly branches: (input: VcsBranchesInput) => Effect.Effect<Vcs.BranchList, unknown>
+2
View File
@@ -517,10 +517,12 @@ export function fromPromise(plugin: Plugin) {
host.vcs.transform((editor) => {
callback({
add: (definition) => {
const init = definition.init?.bind(definition)
const base = definition.base?.bind(definition)
editor.add({
id: definition.id,
name: definition.name,
init: init ? (input) => attempt((signal) => init(input, { signal })) : undefined,
info: (input) => attempt((signal) => definition.info(input, { signal })),
base: base ? (input) => attempt((signal) => base(input, { signal })) : undefined,
branches: (input) => attempt((signal) => definition.branches(input, { signal })),
+1
View File
@@ -25,6 +25,7 @@ export interface VcsDiffInput extends VcsScope {
export interface VcsDefinition {
readonly id: string
readonly name: string
readonly init?: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<void>
readonly info: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<Vcs.Info>
readonly base?: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<Vcs.Base | null>
readonly branches: (input: VcsBranchesInput, context: { readonly signal: AbortSignal }) => Promise<Vcs.BranchList>
+3 -3
View File
@@ -9,7 +9,7 @@ import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "../
const InitQuery = Schema.Struct({
...LocationQuery.fields,
provider: Schema.optional(Schema.Literal("git")),
provider: Schema.optional(Schema.String),
})
const BranchesQuery = Schema.Struct({
@@ -36,9 +36,9 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
.annotateMerge(
OpenApi.annotations({
identifier: "vcs.init",
summary: "Initialize Git repository",
summary: "Initialize VCS repository",
description:
"Initialize the selected repository type in a markerless project's directory and refresh its location services. Only git is supported; omitting provider defaults to git.",
"Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization.",
}),
),
)
+14 -3
View File
@@ -1,5 +1,6 @@
import { Vcs } from "@opencode/core/vcs"
import { Project } from "@opencode/core/project"
import { Plugin } from "@opencode/core/plugin"
import { Location } from "@opencode/core/location"
import { LocationServiceMap } from "@opencode/core/location-services"
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol/errors"
@@ -13,19 +14,29 @@ export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
const project = yield* Project.Service
const locations = yield* LocationServiceMap.Service
return handlers
.handle("vcs.init", () =>
.handle("vcs.init", (ctx) =>
Effect.gen(function* () {
const location = yield* Location.Service
const directory = location.project.directory
yield* project.initializeGit(directory).pipe(
const providerID = ctx.query.provider ?? "git"
yield* Plugin.awaitActivation
const vcs = yield* Vcs.Service
yield* vcs.initialize(providerID).pipe(
Effect.mapError((error) => {
if (error.kind === "missing")
return new InvalidRequestError({ message: "Project directory does not exist", field: "location" })
if (error.kind === "conflict")
return new ConflictError({ message: "Project already has version control", resource: directory })
return new ServiceUnavailableError({ service: "git", message: "Git initialization failed" })
if (error.kind === "unsupported")
return new InvalidRequestError({
message: "VCS provider does not support initialization",
field: "provider",
})
return new ServiceUnavailableError({ service: providerID, message: "VCS initialization failed" })
}),
)
if (!(yield* project.resolve(directory)).vcs)
return yield* new ServiceUnavailableError({ service: providerID, message: "VCS initialization failed" })
yield* locations.invalidate(
Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
)
+1
View File
@@ -96,6 +96,7 @@ const vcsLayer = Layer.succeed(
Vcs.Service,
Vcs.Service.of({
base: () => Effect.succeed(null),
initialize: () => Effect.fail(new Vcs.InitializeError({ kind: "unsupported" })),
transform: () => Effect.succeed({ dispose: Effect.void }),
reload: () => Effect.void,
info: () => Effect.succeed({ branch: {} }),
+48 -2
View File
@@ -1,6 +1,6 @@
import path from "node:path"
import { $ } from "bun"
import { expect } from "bun:test"
import { describe, expect } from "bun:test"
import { SdkPlugins } from "@opencode/core/plugin/sdk"
import { Effect, Layer, Schedule } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
@@ -88,10 +88,11 @@ it.live(
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "hg")
url.searchParams.set("provider", "unknown")
const unsupported = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(unsupported.status).toBe(400)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(false)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(false)
url.searchParams.set("provider", "git")
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(initialized.status).toBe(204)
@@ -100,6 +101,51 @@ it.live(
15_000,
)
const describeHg = Bun.which("hg") ? describe : describe.skip
describeHg("Mercurial initialization", () => {
it.live(
"initializes and serves an untracked-file diff through the Hg provider",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-hg-init-")))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "hg")
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(initialized.status).toBe(204)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(true)
url.pathname = "/api/vcs"
const info = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(url, { headers: server.headers })
const body: unknown = await response.json()
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "hg")
throw new Error("Mercurial provider not ready")
return body
},
catch: (cause) => cause,
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
expect(info).toMatchObject({ data: { provider: "hg" } })
url.pathname = "/api/vcs/diff"
url.searchParams.set("mode", "working")
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(diff.status).toBe(200)
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
})
url.pathname = "/api/vcs/init"
url.searchParams.delete("mode")
url.searchParams.set("provider", "git")
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(repeated.status).toBe(409)
}),
15_000,
)
})
it.live(
"serves lazy review bases, committed diffs, and unavailable-base errors",
() =>
@@ -1061,6 +1061,11 @@ Provider callbacks receive the current location, working-copy root, canonical pr
store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery
continues to use OpenCode's built-in Git and Mercurial detectors.
Providers can optionally implement `init(scope)` to create a repository in `scope.worktree`. Initialization selects
the provider by ID (Git by default), not by the detected repository type. Register a provider with `init` even when
the location has no repository yet; OpenCode checks that the directory exists and has no repository before calling
it, then rediscovers the project. New repository formats still need discovery support beyond this callback.
Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus),
[`FileDiff.Info`](/api#schema-FileDiff.Info).
@@ -1073,10 +1078,15 @@ interface VcsEditor {
}
}
interface VcsDomain extends VcsApi<unknown> {
interface VcsDomain extends Omit<VcsApi<unknown>, "init"> {
readonly transform: Transform<VcsEditor>
readonly reload: () => Effect.Effect<void>
}
interface VcsDefinition {
readonly init?: (scope: VcsScope) => Effect.Effect<void, unknown>
// Existing info, branches, status, and diff callbacks are required.
}
```
### Websearch
@@ -1029,6 +1029,11 @@ Provider callbacks receive the current location, working-copy root, canonical pr
store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery
continues to use OpenCode's built-in Git and Mercurial detectors.
Providers can optionally implement `init(scope, { signal })` to create a repository in `scope.worktree`. Initialization
selects the provider by ID (Git by default), not by the detected repository type. Register a provider with `init` even
when the location has no repository yet; OpenCode checks that the directory exists and has no repository before calling
it, then rediscovers the project. New repository formats still need discovery support beyond this callback.
#### Reference
Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus),
@@ -1051,6 +1056,11 @@ interface VcsEditor {
set(providerID: string): void
}
}
interface VcsDefinition {
init?(scope: VcsScope, context: { signal: AbortSignal }): Promise<void>
// Existing info, branches, status, and diff callbacks are required.
}
```
### Worktrees