repository.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import path from "path"
  2. import { fileURLToPath } from "url"
  3. import { Schema } from "effect"
  4. import { Global } from "@opencode-ai/core/global"
  5. type BaseReference = {
  6. host: string
  7. path: string
  8. segments: string[]
  9. owner?: string
  10. repo: string
  11. remote: string
  12. label: string
  13. }
  14. export type RemoteReference = BaseReference & {
  15. protocol?: string
  16. }
  17. export type FileReference = BaseReference & {
  18. host: "file"
  19. protocol: "file:"
  20. }
  21. export type Reference = RemoteReference | FileReference
  22. export class InvalidRepositoryReferenceError extends Schema.TaggedErrorClass<InvalidRepositoryReferenceError>()(
  23. "RepositoryInvalidReferenceError",
  24. {
  25. repository: Schema.String,
  26. message: Schema.String,
  27. },
  28. ) {}
  29. export class UnsupportedLocalRepositoryError extends Schema.TaggedErrorClass<UnsupportedLocalRepositoryError>()(
  30. "RepositoryUnsupportedLocalRepositoryError",
  31. {
  32. repository: Schema.String,
  33. message: Schema.String,
  34. },
  35. ) {}
  36. export class InvalidRepositoryBranchError extends Schema.TaggedErrorClass<InvalidRepositoryBranchError>()(
  37. "RepositoryInvalidBranchError",
  38. {
  39. branch: Schema.String,
  40. message: Schema.String,
  41. },
  42. ) {}
  43. export type RepositoryError =
  44. | InvalidRepositoryReferenceError
  45. | UnsupportedLocalRepositoryError
  46. | InvalidRepositoryBranchError
  47. export function isRepositoryError(error: unknown): error is RepositoryError {
  48. return (
  49. error instanceof InvalidRepositoryReferenceError ||
  50. error instanceof UnsupportedLocalRepositoryError ||
  51. error instanceof InvalidRepositoryBranchError
  52. )
  53. }
  54. function normalizeRepositoryInput(input: string) {
  55. return input
  56. .trim()
  57. .replace(/^git\+/, "")
  58. .replace(/#.*$/, "")
  59. .replace(/\/+$/, "")
  60. }
  61. function trimGitSuffix(input: string) {
  62. return input.replace(/\.git$/, "")
  63. }
  64. function parts(input: string) {
  65. return input
  66. .split("/")
  67. .map((item) => trimGitSuffix(item.trim()))
  68. .filter(Boolean)
  69. }
  70. function safeHost(input: string) {
  71. return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input)
  72. }
  73. function safeSegment(input: string) {
  74. return input !== "." && input !== ".." && !input.includes(":") && !/[\s/\\]/.test(input)
  75. }
  76. function hostLike(input: string) {
  77. return input.includes(".") || input.includes(":") || input === "localhost"
  78. }
  79. function withSlash(input: string) {
  80. return input.endsWith("/") ? input : `${input}/`
  81. }
  82. function githubRemote(pathname: string) {
  83. const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
  84. if (!base) return `https://github.com/${pathname}.git`
  85. return new URL(`${pathname}.git`, withSlash(base)).href
  86. }
  87. function buildRemoteReference(input: { host: string; segments: string[]; remote?: string; protocol?: string }) {
  88. const segments = input.segments.map(trimGitSuffix).filter(Boolean)
  89. if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null
  90. const pathname = segments.join("/")
  91. const repo = segments[segments.length - 1]
  92. const host = input.host.toLowerCase()
  93. return {
  94. host,
  95. path: pathname,
  96. segments,
  97. owner: segments.length === 2 ? segments[0] : undefined,
  98. repo,
  99. remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`),
  100. label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`,
  101. protocol: input.protocol,
  102. } satisfies RemoteReference
  103. }
  104. function buildFileReference(input: { url: URL; remote: string }) {
  105. const filePath = path.normalize(fileURLToPath(input.url))
  106. const segments = filePath.split(/[\\/]+/).filter(Boolean)
  107. if (!segments.length) return null
  108. return {
  109. host: "file",
  110. path: filePath,
  111. segments: segments.map((segment) => segment.replace(/:$/, "")),
  112. owner: undefined,
  113. repo: trimGitSuffix(segments[segments.length - 1]),
  114. remote: input.remote,
  115. label: filePath,
  116. protocol: "file:",
  117. } satisfies FileReference
  118. }
  119. export function parseRepositoryReference(input: string) {
  120. const cleaned = normalizeRepositoryInput(input)
  121. if (!cleaned) return null
  122. const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/)
  123. if (githubPrefixed) {
  124. return buildRemoteReference({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] })
  125. }
  126. if (!cleaned.includes("://")) {
  127. const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/)
  128. if (scp) return buildRemoteReference({ host: scp[1], segments: parts(scp[2]), remote: cleaned })
  129. const direct = parts(cleaned)
  130. if (direct.length >= 2 && hostLike(direct[0])) {
  131. return buildRemoteReference({ host: direct[0], segments: direct.slice(1) })
  132. }
  133. if (direct.length === 2) {
  134. return buildRemoteReference({ host: "github.com", segments: direct })
  135. }
  136. }
  137. try {
  138. const url = new URL(cleaned)
  139. if (url.protocol === "file:") return buildFileReference({ url, remote: cleaned })
  140. const pathname = parts(url.pathname)
  141. const host = url.host
  142. return buildRemoteReference({
  143. host,
  144. segments: pathname,
  145. remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned,
  146. protocol: url.protocol,
  147. })
  148. } catch {
  149. return null
  150. }
  151. }
  152. export function isFileRepositoryReference(reference: Reference): reference is FileReference {
  153. return reference.protocol === "file:"
  154. }
  155. export function isRemoteRepositoryReference(reference: Reference): reference is RemoteReference {
  156. return !isFileRepositoryReference(reference)
  157. }
  158. export function parseRemoteRepositoryReference(input: string) {
  159. const reference = parseRepositoryReference(input)
  160. if (!reference) {
  161. throw new InvalidRepositoryReferenceError({
  162. repository: input,
  163. message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
  164. })
  165. }
  166. if (!isRemoteRepositoryReference(reference)) {
  167. throw new UnsupportedLocalRepositoryError({
  168. repository: input,
  169. message: "Local file repositories are not supported",
  170. })
  171. }
  172. return reference
  173. }
  174. export function validateRepositoryBranch(branch: string) {
  175. if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) {
  176. throw new InvalidRepositoryBranchError({
  177. branch,
  178. message: "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
  179. })
  180. }
  181. }
  182. export function parseGitHubRemote(input: string) {
  183. const cleaned = normalizeRepositoryInput(input)
  184. if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null
  185. const parsed = parseRepositoryReference(cleaned)
  186. if (!parsed || parsed.host !== "github.com" || !parsed.owner || parsed.segments.length !== 2) return null
  187. return { owner: parsed.owner, repo: parsed.repo }
  188. }
  189. export function repositoryCachePath(input: Reference) {
  190. return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments)
  191. }
  192. export function repositoryCacheIdentity(input: Reference) {
  193. return `${input.host}/${input.path}`
  194. }
  195. export function sameRepositoryReference(left: Reference, right: Reference) {
  196. return repositoryCacheIdentity(left) === repositoryCacheIdentity(right)
  197. }