repository.ts 6.3 KB

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