diff --git a/packages/opencode/src/reference/repository-cache.ts b/packages/opencode/src/reference/repository-cache.ts index 5b6d6572e8..f266cadaba 100644 --- a/packages/opencode/src/reference/repository-cache.ts +++ b/packages/opencode/src/reference/repository-cache.ts @@ -7,8 +7,11 @@ import { repositoryCachePath, sameRepositoryReference, parseRepositoryReference, + parseRemoteRepositoryReference, validateRepositoryBranch, - isRemoteRepositoryReference, + InvalidRepositoryBranchError, + InvalidRepositoryReferenceError, + UnsupportedLocalRepositoryError, type RemoteReference, } from "@/util/repository" @@ -138,23 +141,26 @@ export function isError(error: unknown): error is Error { } export const parseRemoteReference = Effect.fn("RepositoryCache.parseRemoteReference")(function* (repository: string) { - const reference = parseRepositoryReference(repository) - if (!reference) { + try { + return parseRemoteRepositoryReference(repository) + } catch (error) { + if (error instanceof InvalidRepositoryReferenceError || error instanceof UnsupportedLocalRepositoryError) { + return yield* new InvalidRepositoryError({ repository: error.repository, message: error.message }) + } return yield* new InvalidRepositoryError({ repository, - message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", + message: errorMessage(error), }) } - if (!isRemoteRepositoryReference(reference)) { - return yield* new InvalidRepositoryError({ repository, message: "Local file repositories are not supported" }) - } - return reference }) export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) { try { validateRepositoryBranch(branch) } catch (error) { + if (error instanceof InvalidRepositoryBranchError) { + return yield* new InvalidBranchError({ branch: error.branch, message: error.message }) + } return yield* new InvalidBranchError({ branch, message: errorMessage(error) }) } }) diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts index 1754a7bf87..1646f04aef 100644 --- a/packages/opencode/src/util/repository.ts +++ b/packages/opencode/src/util/repository.ts @@ -1,5 +1,6 @@ import path from "path" import { fileURLToPath } from "url" +import { Schema } from "effect" import { Global } from "@opencode-ai/core/global" type BaseReference = { @@ -23,6 +24,43 @@ export type FileReference = BaseReference & { export type Reference = RemoteReference | FileReference +export class InvalidRepositoryReferenceError extends Schema.TaggedErrorClass()( + "RepositoryInvalidReferenceError", + { + repository: Schema.String, + message: Schema.String, + }, +) {} + +export class UnsupportedLocalRepositoryError extends Schema.TaggedErrorClass()( + "RepositoryUnsupportedLocalRepositoryError", + { + repository: Schema.String, + message: Schema.String, + }, +) {} + +export class InvalidRepositoryBranchError extends Schema.TaggedErrorClass()( + "RepositoryInvalidBranchError", + { + branch: Schema.String, + message: Schema.String, + }, +) {} + +export type RepositoryError = + | InvalidRepositoryReferenceError + | UnsupportedLocalRepositoryError + | InvalidRepositoryBranchError + +export function isRepositoryError(error: unknown): error is RepositoryError { + return ( + error instanceof InvalidRepositoryReferenceError || + error instanceof UnsupportedLocalRepositoryError || + error instanceof InvalidRepositoryBranchError + ) +} + function normalizeRepositoryInput(input: string) { return input .trim() @@ -147,16 +185,27 @@ export function isRemoteRepositoryReference(reference: Reference): reference is export function parseRemoteRepositoryReference(input: string) { const reference = parseRepositoryReference(input) - if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") - if (!isRemoteRepositoryReference(reference)) throw new Error("Local file repositories are not supported") + if (!reference) { + throw new InvalidRepositoryReferenceError({ + repository: input, + message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", + }) + } + if (!isRemoteRepositoryReference(reference)) { + throw new UnsupportedLocalRepositoryError({ + repository: input, + message: "Local file repositories are not supported", + }) + } return reference } export function validateRepositoryBranch(branch: string) { if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { - throw new Error( - "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", - ) + throw new InvalidRepositoryBranchError({ + branch, + message: "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", + }) } } diff --git a/packages/opencode/test/util/repository.test.ts b/packages/opencode/test/util/repository.test.ts index ad9fce94be..5c619f2aaf 100644 --- a/packages/opencode/test/util/repository.test.ts +++ b/packages/opencode/test/util/repository.test.ts @@ -3,6 +3,9 @@ import path from "path" import { pathToFileURL } from "url" import { Global } from "@opencode-ai/core/global" import { + InvalidRepositoryBranchError, + InvalidRepositoryReferenceError, + UnsupportedLocalRepositoryError, isFileRepositoryReference, isRemoteRepositoryReference, parseRemoteRepositoryReference, @@ -61,6 +64,14 @@ describe("util.repository", () => { expect(() => parseRemoteRepositoryReference(pathToFileURL(localPath).href)).toThrow( "Local file repositories are not supported", ) + expect(() => parseRemoteRepositoryReference(pathToFileURL(localPath).href)).toThrow(UnsupportedLocalRepositoryError) + }) + + test("rejects invalid remote repository references with typed errors", () => { + expect(() => parseRemoteRepositoryReference("not-a-repo")).toThrow(InvalidRepositoryReferenceError) + expect(() => parseRemoteRepositoryReference("git@github.com:../../../etc/passwd")).toThrow( + InvalidRepositoryReferenceError, + ) }) test("compares cache identity independent of input spelling", () => { @@ -77,5 +88,6 @@ describe("util.repository", () => { expect(() => validateRepositoryBranch("-bad")).toThrow("Branch must contain only alphanumeric characters") expect(() => validateRepositoryBranch("bad..branch")).toThrow("Branch must contain only alphanumeric characters") expect(() => validateRepositoryBranch("bad branch")).toThrow("Branch must contain only alphanumeric characters") + expect(() => validateRepositoryBranch("bad branch")).toThrow(InvalidRepositoryBranchError) }) })