import path from "path" import { fileURLToPath } from "url" import { Schema } from "effect" import { Global } from "@opencode-ai/core/global" type BaseReference = { host: string path: string segments: string[] owner?: string repo: string remote: string label: string } export type RemoteReference = BaseReference & { protocol?: string } export type FileReference = BaseReference & { host: "file" protocol: "file:" } 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() .replace(/^git\+/, "") .replace(/#.*$/, "") .replace(/\/+$/, "") } function trimGitSuffix(input: string) { return input.replace(/\.git$/, "") } function parts(input: string) { return input .split("/") .map((item) => trimGitSuffix(item.trim())) .filter(Boolean) } function safeHost(input: string) { return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input) } function safeSegment(input: string) { return input !== "." && input !== ".." && !input.includes(":") && !/[\s/\\]/.test(input) } function hostLike(input: string) { return input.includes(".") || input.includes(":") || input === "localhost" } function withSlash(input: string) { return input.endsWith("/") ? input : `${input}/` } function githubRemote(pathname: string) { const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL if (!base) return `https://github.com/${pathname}.git` return new URL(`${pathname}.git`, withSlash(base)).href } function buildRemoteReference(input: { host: string; segments: string[]; remote?: string; protocol?: string }) { const segments = input.segments.map(trimGitSuffix).filter(Boolean) if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null const pathname = segments.join("/") const repo = segments[segments.length - 1] const host = input.host.toLowerCase() return { host, path: pathname, segments, owner: segments.length === 2 ? segments[0] : undefined, repo, remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`), label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`, protocol: input.protocol, } satisfies RemoteReference } function buildFileReference(input: { url: URL; remote: string }) { const filePath = path.normalize(fileURLToPath(input.url)) const segments = filePath.split(/[\\/]+/).filter(Boolean) if (!segments.length) return null return { host: "file", path: filePath, segments: segments.map((segment) => segment.replace(/:$/, "")), owner: undefined, repo: trimGitSuffix(segments[segments.length - 1]), remote: input.remote, label: filePath, protocol: "file:", } satisfies FileReference } export function parseRepositoryReference(input: string) { const cleaned = normalizeRepositoryInput(input) if (!cleaned) return null const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/) if (githubPrefixed) { return buildRemoteReference({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] }) } if (!cleaned.includes("://")) { const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/) if (scp) return buildRemoteReference({ host: scp[1], segments: parts(scp[2]), remote: cleaned }) const direct = parts(cleaned) if (direct.length >= 2 && hostLike(direct[0])) { return buildRemoteReference({ host: direct[0], segments: direct.slice(1) }) } if (direct.length === 2) { return buildRemoteReference({ host: "github.com", segments: direct }) } } try { const url = new URL(cleaned) if (url.protocol === "file:") return buildFileReference({ url, remote: cleaned }) const pathname = parts(url.pathname) const host = url.host return buildRemoteReference({ host, segments: pathname, remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned, protocol: url.protocol, }) } catch { return null } } export function isFileRepositoryReference(reference: Reference): reference is FileReference { return reference.protocol === "file:" } export function isRemoteRepositoryReference(reference: Reference): reference is RemoteReference { return !isFileRepositoryReference(reference) } export function parseRemoteRepositoryReference(input: string) { const reference = parseRepositoryReference(input) 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 InvalidRepositoryBranchError({ branch, message: "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", }) } } export function parseGitHubRemote(input: string) { const cleaned = normalizeRepositoryInput(input) if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null const parsed = parseRepositoryReference(cleaned) if (!parsed || parsed.host !== "github.com" || !parsed.owner || parsed.segments.length !== 2) return null return { owner: parsed.owner, repo: parsed.repo } } export function repositoryCachePath(input: Reference) { return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments) } export function repositoryCacheIdentity(input: Reference) { return `${input.host}/${input.path}` } export function sameRepositoryReference(left: Reference, right: Reference) { return repositoryCacheIdentity(left) === repositoryCacheIdentity(right) }