refactor(repository): clarify reference domain (#28182)

This commit is contained in:
Shoubhit Dash 2026-05-18 21:04:44 +05:30 committed by GitHub
parent 2932b41e64
commit 5bfd7fd16c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 126 additions and 21 deletions

View file

@ -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
}

View file

@ -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
},

View file

@ -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)
}

View file

@ -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")
})
})