From 5bfd7fd16c93a8dfcb1bc7d9910483bdbc87ab73 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 18 May 2026 21:04:44 +0530 Subject: [PATCH] refactor(repository): clarify reference domain (#28182) --- packages/opencode/src/reference/reference.ts | 4 +- .../src/reference/repository-cache.ts | 4 +- packages/opencode/src/util/repository.ts | 58 +++++++++---- .../opencode/test/util/repository.test.ts | 81 +++++++++++++++++++ 4 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 packages/opencode/test/util/repository.test.ts diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts index d53cc7d6f7..c305632b6b 100644 --- a/packages/opencode/src/reference/reference.ts +++ b/packages/opencode/src/reference/reference.ts @@ -7,7 +7,7 @@ import { ConfigReference } from "@/config/reference" import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { Git } from "@/git" -import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository" +import { parseRepositoryReference, repositoryCachePath, type RemoteReference } from "@/util/repository" import { RepositoryCache } from "./repository-cache" export type Resolved = @@ -20,7 +20,7 @@ export type Resolved = name: string kind: "git" repository: string - reference: RepositoryReference + reference: RemoteReference path: string branch?: string } diff --git a/packages/opencode/src/reference/repository-cache.ts b/packages/opencode/src/reference/repository-cache.ts index d31db8ab5f..a873990a30 100644 --- a/packages/opencode/src/reference/repository-cache.ts +++ b/packages/opencode/src/reference/repository-cache.ts @@ -8,7 +8,7 @@ import { sameRepositoryReference, parseRepositoryReference, validateRepositoryBranch, - type Reference as RepositoryReference, + type RemoteReference, } from "@/util/repository" export type Result = { @@ -45,7 +45,7 @@ function resetTarget(input: { export const ensure = Effect.fn("RepositoryCache.ensure")(function* ( input: { - reference: RepositoryReference + reference: RemoteReference refresh?: boolean branch?: string }, diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts index 71c001255f..1754a7bf87 100644 --- a/packages/opencode/src/util/repository.ts +++ b/packages/opencode/src/util/repository.ts @@ -2,7 +2,7 @@ import path from "path" import { fileURLToPath } from "url" import { Global } from "@opencode-ai/core/global" -export type Reference = { +type BaseReference = { host: string path: string segments: string[] @@ -10,10 +10,20 @@ export type Reference = { repo: string remote: string label: string +} + +export type RemoteReference = BaseReference & { protocol?: string } -function normalize(input: string) { +export type FileReference = BaseReference & { + host: "file" + protocol: "file:" +} + +export type Reference = RemoteReference | FileReference + +function normalizeRepositoryInput(input: string) { return input .trim() .replace(/^git\+/, "") @@ -54,7 +64,7 @@ function githubRemote(pathname: string) { return new URL(`${pathname}.git`, withSlash(base)).href } -function build(input: { host: string; segments: string[]; remote?: string; protocol?: string }) { +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("/") @@ -69,10 +79,10 @@ function build(input: { host: string; segments: string[]; remote?: string; proto 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 Reference + } satisfies RemoteReference } -function buildFile(input: { url: URL; remote: string }) { +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 @@ -85,36 +95,38 @@ function buildFile(input: { url: URL; remote: string }) { remote: input.remote, label: filePath, protocol: "file:", - } satisfies Reference + } satisfies FileReference } export function parseRepositoryReference(input: string) { - const cleaned = normalize(input) + const cleaned = normalizeRepositoryInput(input) if (!cleaned) return null const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/) - if (githubPrefixed) return build({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] }) + if (githubPrefixed) { + return buildRemoteReference({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] }) + } if (!cleaned.includes("://")) { const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/) - if (scp) return build({ host: scp[1], segments: parts(scp[2]), remote: cleaned }) + 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 build({ host: direct[0], segments: direct.slice(1) }) + return buildRemoteReference({ host: direct[0], segments: direct.slice(1) }) } if (direct.length === 2) { - return build({ host: "github.com", segments: direct }) + return buildRemoteReference({ host: "github.com", segments: direct }) } } try { const url = new URL(cleaned) - if (url.protocol === "file:") return buildFile({ url, remote: cleaned }) + if (url.protocol === "file:") return buildFileReference({ url, remote: cleaned }) const pathname = parts(url.pathname) const host = url.host - return build({ + return buildRemoteReference({ host, segments: pathname, remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned, @@ -125,10 +137,18 @@ export function parseRepositoryReference(input: string) { } } +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 Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") - if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") + if (!isRemoteRepositoryReference(reference)) throw new Error("Local file repositories are not supported") return reference } @@ -141,7 +161,7 @@ export function validateRepositoryBranch(branch: string) { } export function parseGitHubRemote(input: string) { - const cleaned = normalize(input) + const cleaned = normalizeRepositoryInput(input) if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null const parsed = parseRepositoryReference(cleaned) @@ -153,6 +173,10 @@ export function repositoryCachePath(input: Reference) { return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments) } -export function sameRepositoryReference(left: Reference, right: Reference) { - return left.host === right.host && left.path === right.path +export function repositoryCacheIdentity(input: Reference) { + return `${input.host}/${input.path}` +} + +export function sameRepositoryReference(left: Reference, right: Reference) { + return repositoryCacheIdentity(left) === repositoryCacheIdentity(right) } diff --git a/packages/opencode/test/util/repository.test.ts b/packages/opencode/test/util/repository.test.ts new file mode 100644 index 0000000000..ad9fce94be --- /dev/null +++ b/packages/opencode/test/util/repository.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { Global } from "@opencode-ai/core/global" +import { + isFileRepositoryReference, + isRemoteRepositoryReference, + parseRemoteRepositoryReference, + parseRepositoryReference, + repositoryCacheIdentity, + repositoryCachePath, + sameRepositoryReference, + validateRepositoryBranch, +} from "../../src/util/repository" + +describe("util.repository", () => { + test("parses github shorthand and preserves cache path", () => { + const reference = parseRemoteRepositoryReference("owner/repo") + + expect(reference).toMatchObject({ + host: "github.com", + path: "owner/repo", + segments: ["owner", "repo"], + owner: "owner", + repo: "repo", + label: "owner/repo", + }) + expect(repositoryCachePath(reference)).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo")) + expect(repositoryCacheIdentity(reference)).toBe("github.com/owner/repo") + }) + + test("parses host path and scp remote references", () => { + const hostPath = parseRemoteRepositoryReference("gitlab.com/group/repo") + const scp = parseRemoteRepositoryReference("git@github.com:owner/repo.git") + + expect(hostPath).toMatchObject({ + host: "gitlab.com", + path: "group/repo", + remote: "https://gitlab.com/group/repo.git", + label: "gitlab.com/group/repo", + }) + expect(scp).toMatchObject({ + host: "github.com", + path: "owner/repo", + remote: "git@github.com:owner/repo.git", + label: "owner/repo", + }) + }) + + test("keeps local file repositories distinct from remote repositories", () => { + const localPath = path.resolve("repo.git") + const reference = parseRepositoryReference(pathToFileURL(localPath).href) + + expect(reference).toMatchObject({ + host: "file", + protocol: "file:", + label: localPath, + }) + expect(reference && isFileRepositoryReference(reference)).toBe(true) + expect(reference && isRemoteRepositoryReference(reference)).toBe(false) + expect(() => parseRemoteRepositoryReference(pathToFileURL(localPath).href)).toThrow( + "Local file repositories are not supported", + ) + }) + + test("compares cache identity independent of input spelling", () => { + const shorthand = parseRemoteRepositoryReference("owner/repo") + const url = parseRemoteRepositoryReference("https://github.com/owner/repo.git") + const hostPath = parseRemoteRepositoryReference("github.com/owner/repo") + + expect(sameRepositoryReference(shorthand, url)).toBe(true) + expect(sameRepositoryReference(shorthand, hostPath)).toBe(true) + }) + + test("validates repository branch names", () => { + expect(() => validateRepositoryBranch("feature/docs.v1")).not.toThrow() + 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") + }) +})