refactor(repository): clarify reference domain (#28182)
This commit is contained in:
@@ -7,7 +7,7 @@ import { ConfigReference } from "@/config/reference"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { Git } from "@/git"
|
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"
|
import { RepositoryCache } from "./repository-cache"
|
||||||
|
|
||||||
export type Resolved =
|
export type Resolved =
|
||||||
@@ -20,7 +20,7 @@ export type Resolved =
|
|||||||
name: string
|
name: string
|
||||||
kind: "git"
|
kind: "git"
|
||||||
repository: string
|
repository: string
|
||||||
reference: RepositoryReference
|
reference: RemoteReference
|
||||||
path: string
|
path: string
|
||||||
branch?: string
|
branch?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
sameRepositoryReference,
|
sameRepositoryReference,
|
||||||
parseRepositoryReference,
|
parseRepositoryReference,
|
||||||
validateRepositoryBranch,
|
validateRepositoryBranch,
|
||||||
type Reference as RepositoryReference,
|
type RemoteReference,
|
||||||
} from "@/util/repository"
|
} from "@/util/repository"
|
||||||
|
|
||||||
export type Result = {
|
export type Result = {
|
||||||
@@ -45,7 +45,7 @@ function resetTarget(input: {
|
|||||||
|
|
||||||
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
||||||
input: {
|
input: {
|
||||||
reference: RepositoryReference
|
reference: RemoteReference
|
||||||
refresh?: boolean
|
refresh?: boolean
|
||||||
branch?: string
|
branch?: string
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path"
|
|||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
|
||||||
export type Reference = {
|
type BaseReference = {
|
||||||
host: string
|
host: string
|
||||||
path: string
|
path: string
|
||||||
segments: string[]
|
segments: string[]
|
||||||
@@ -10,10 +10,20 @@ export type Reference = {
|
|||||||
repo: string
|
repo: string
|
||||||
remote: string
|
remote: string
|
||||||
label: string
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RemoteReference = BaseReference & {
|
||||||
protocol?: string
|
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
|
return input
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/^git\+/, "")
|
.replace(/^git\+/, "")
|
||||||
@@ -54,7 +64,7 @@ function githubRemote(pathname: string) {
|
|||||||
return new URL(`${pathname}.git`, withSlash(base)).href
|
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)
|
const segments = input.segments.map(trimGitSuffix).filter(Boolean)
|
||||||
if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null
|
if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null
|
||||||
const pathname = segments.join("/")
|
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`),
|
remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`),
|
||||||
label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`,
|
label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`,
|
||||||
protocol: input.protocol,
|
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 filePath = path.normalize(fileURLToPath(input.url))
|
||||||
const segments = filePath.split(/[\\/]+/).filter(Boolean)
|
const segments = filePath.split(/[\\/]+/).filter(Boolean)
|
||||||
if (!segments.length) return null
|
if (!segments.length) return null
|
||||||
@@ -85,36 +95,38 @@ function buildFile(input: { url: URL; remote: string }) {
|
|||||||
remote: input.remote,
|
remote: input.remote,
|
||||||
label: filePath,
|
label: filePath,
|
||||||
protocol: "file:",
|
protocol: "file:",
|
||||||
} satisfies Reference
|
} satisfies FileReference
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRepositoryReference(input: string) {
|
export function parseRepositoryReference(input: string) {
|
||||||
const cleaned = normalize(input)
|
const cleaned = normalizeRepositoryInput(input)
|
||||||
if (!cleaned) return null
|
if (!cleaned) return null
|
||||||
|
|
||||||
const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/)
|
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("://")) {
|
if (!cleaned.includes("://")) {
|
||||||
const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/)
|
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)
|
const direct = parts(cleaned)
|
||||||
if (direct.length >= 2 && hostLike(direct[0])) {
|
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) {
|
if (direct.length === 2) {
|
||||||
return build({ host: "github.com", segments: direct })
|
return buildRemoteReference({ host: "github.com", segments: direct })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(cleaned)
|
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 pathname = parts(url.pathname)
|
||||||
const host = url.host
|
const host = url.host
|
||||||
return build({
|
return buildRemoteReference({
|
||||||
host,
|
host,
|
||||||
segments: pathname,
|
segments: pathname,
|
||||||
remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned,
|
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) {
|
export function parseRemoteRepositoryReference(input: string) {
|
||||||
const reference = parseRepositoryReference(input)
|
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) 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
|
return reference
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +161,7 @@ export function validateRepositoryBranch(branch: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseGitHubRemote(input: string) {
|
export function parseGitHubRemote(input: string) {
|
||||||
const cleaned = normalize(input)
|
const cleaned = normalizeRepositoryInput(input)
|
||||||
if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null
|
if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null
|
||||||
|
|
||||||
const parsed = parseRepositoryReference(cleaned)
|
const parsed = parseRepositoryReference(cleaned)
|
||||||
@@ -153,6 +173,10 @@ export function repositoryCachePath(input: Reference) {
|
|||||||
return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments)
|
return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sameRepositoryReference(left: Reference, right: Reference) {
|
export function repositoryCacheIdentity(input: Reference) {
|
||||||
return left.host === right.host && left.path === right.path
|
return `${input.host}/${input.path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameRepositoryReference(left: Reference, right: Reference) {
|
||||||
|
return repositoryCacheIdentity(left) === repositoryCacheIdentity(right)
|
||||||
}
|
}
|
||||||
|
|||||||
81
packages/opencode/test/util/repository.test.ts
Normal file
81
packages/opencode/test/util/repository.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user