Ver Fonte

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

Shoubhit Dash há 3 meses atrás
pai
commit
5bfd7fd16c

+ 2 - 2
packages/opencode/src/reference/reference.ts

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

+ 2 - 2
packages/opencode/src/reference/repository-cache.ts

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

+ 40 - 16
packages/opencode/src/util/repository.ts

@@ -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 repositoryCacheIdentity(input: Reference) {
+  return `${input.host}/${input.path}`
+}
+
 export function sameRepositoryReference(left: Reference, right: Reference) {
 export function sameRepositoryReference(left: Reference, right: Reference) {
-  return left.host === right.host && left.path === right.path
+  return repositoryCacheIdentity(left) === repositoryCacheIdentity(right)
 }
 }

+ 81 - 0
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")
+  })
+})