fix(vcs): report unsupported initialization as 501

This commit is contained in:
Brendonovich
2026-09-29 07:14:45 +00:00
parent 8c46c09f8e
commit a241b41545
7 changed files with 90 additions and 9 deletions
@@ -2021,7 +2021,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/init`,
query: { location: input?.["location"], provider: input?.["provider"] },
successStatus: 204,
declaredStatuses: [400, 401, 409, 503],
declaredStatuses: [400, 401, 409, 501, 503],
empty: true,
},
requestOptions,
@@ -2700,6 +2700,14 @@ export type WorktreeError = {
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
export type VcsInitNotSupportedError = {
readonly _tag: "VcsInitNotSupportedError"
readonly providerID: string
readonly message: string
}
export const isVcsInitNotSupportedError = (value: unknown): value is VcsInitNotSupportedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "VcsInitNotSupportedError"
export type ServerInfoOutput = ServerInfo
export type ServerPairOutput = PairingCode
+3 -2
View File
@@ -22,7 +22,7 @@ export class DiffError extends Schema.TaggedError<DiffError>()("Vcs.DiffError",
}) {}
export class InitializeError extends Schema.TaggedError<InitializeError>()("Vcs.InitializeError", {
kind: Schema.Literals(["missing", "conflict", "unsupported", "failed"]),
kind: Schema.Literals(["missing", "conflict", "unknown", "unsupported", "failed"]),
}) {}
export interface DiffOptions {
@@ -178,7 +178,8 @@ const layer = Layer.effect(
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" })
if (!provider) return yield* new InitializeError({ kind: "unknown" })
if (!provider.init) return yield* new InitializeError({ kind: "unsupported" })
yield* provider
.init(scope)
.pipe(
+1 -1
View File
@@ -117,7 +117,7 @@ describe("Vcs", () => {
}),
)
})
expect(yield* vcs.initialize("missing").pipe(Effect.flip)).toMatchObject({ kind: "unsupported" })
expect(yield* vcs.initialize("missing").pipe(Effect.flip)).toMatchObject({ kind: "unknown" })
expect(yield* vcs.initialize("read-only").pipe(Effect.flip)).toMatchObject({ kind: "unsupported" })
expect(calls).toEqual([])
yield* vcs.initialize("custom")
+8 -2
View File
@@ -12,6 +12,12 @@ const InitQuery = Schema.Struct({
provider: Schema.optional(Schema.String),
})
export class VcsInitNotSupportedError extends Schema.TaggedError<VcsInitNotSupportedError>()(
"VcsInitNotSupportedError",
{ providerID: Schema.String, message: Schema.String },
{ httpApiStatus: 501 },
) {}
const BranchesQuery = Schema.Struct({
...LocationQuery.fields,
search: Schema.optional(Schema.String),
@@ -30,7 +36,7 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
HttpApiEndpoint.post("vcs.init", "/api/vcs/init", {
query: InitQuery,
success: HttpApiSchema.NoContent,
error: [ConflictError, InvalidRequestError, ServiceUnavailableError],
error: [ConflictError, InvalidRequestError, VcsInitNotSupportedError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
@@ -38,7 +44,7 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
identifier: "vcs.init",
summary: "Initialize VCS repository",
description:
"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.",
"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. An unknown provider returns 400; a registered provider without init returns 501.",
}),
),
)
+8 -2
View File
@@ -4,6 +4,7 @@ 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"
import { VcsInitNotSupportedError } from "@opencode/protocol/groups/vcs"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
@@ -27,11 +28,16 @@ export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
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 })
if (error.kind === "unsupported")
if (error.kind === "unknown")
return new InvalidRequestError({
message: "VCS provider does not support initialization",
message: "Unknown VCS provider",
field: "provider",
})
if (error.kind === "unsupported")
return new VcsInitNotSupportedError({
providerID,
message: "VCS provider does not support initialization",
})
return new ServiceUnavailableError({ service: providerID, message: "VCS initialization failed" })
}),
)
+61 -1
View File
@@ -81,7 +81,7 @@ it.live(
)
it.live(
"accepts an explicit Git provider and rejects unsupported providers without initializing",
"accepts an explicit Git provider and rejects unknown providers without initializing",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-provider-")))
@@ -91,6 +91,10 @@ it.live(
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(() => unsupported.json())).toMatchObject({
_tag: "InvalidRequestError",
field: "provider",
})
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")
@@ -101,6 +105,62 @@ it.live(
15_000,
)
it.live("returns 501 when a registered VCS provider has no initializer", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-no-init-")))
const handler = yield* ServerFetch.make(
{
database: { path: ":memory:" },
config: { directory: tmp.path },
fs: { filewatcher: false },
models: { fetch: false },
},
{
overrides: [
SdkPlugins.node.replace(
Layer.succeed(
SdkPlugins.Service,
SdkPlugins.Service.of({
register: () => Effect.void,
all: () => [
{
id: "read-only-vcs",
revision: "test",
effect: (ctx) =>
ctx.vcs
.transform((editor) => {
editor.add({
id: "read-only",
name: "Read-only VCS",
info: () => Effect.succeed({ branch: {} }),
branches: () => Effect.succeed([]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
})
})
.pipe(Effect.asVoid),
},
],
}),
),
),
],
},
)
const url = new URL("http://opencode.local/api/vcs/init")
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "read-only")
const response = yield* Effect.promise(() => handler(new Request(url, { method: "POST" })))
expect(response.status).toBe(501)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "VcsInitNotSupportedError",
providerID: "read-only",
})
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)
}),
)
const describeHg = Bun.which("hg") ? describe : describe.skip
describeHg("Mercurial initialization", () => {