project-reference.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. export * as ProjectReference from "./project-reference"
  2. import path from "path"
  3. import { Context, Effect, Layer } from "effect"
  4. import { Config } from "./config"
  5. import { ConfigReference } from "./config/reference"
  6. import { FSUtil } from "./fs-util"
  7. import { Flag } from "./flag/flag"
  8. import { Global } from "./global"
  9. import { Location } from "./location"
  10. import { Repository } from "./repository"
  11. import { RepositoryCache } from "./repository-cache"
  12. export type Resolved =
  13. | { readonly name: string; readonly kind: "local"; readonly path: string }
  14. | {
  15. readonly name: string
  16. readonly kind: "git"
  17. readonly repository: string
  18. readonly reference: Repository.RemoteReference
  19. readonly path: string
  20. readonly branch?: string
  21. }
  22. | { readonly name: string; readonly kind: "invalid"; readonly repository?: string; readonly message: string }
  23. type Valid = Exclude<Resolved, { kind: "invalid" }>
  24. export type Mention =
  25. | {
  26. readonly name: string
  27. readonly kind: "reference"
  28. readonly reference: Valid
  29. readonly target?: string
  30. readonly path: string
  31. }
  32. | { readonly name: string; readonly kind: "invalid"; readonly target?: string; readonly message: string }
  33. | {
  34. readonly name: string
  35. readonly kind: "missing"
  36. readonly target: string
  37. readonly path: string
  38. readonly message: string
  39. }
  40. export interface Interface {
  41. readonly list: () => Effect.Effect<Resolved[]>
  42. readonly get: (name: string) => Effect.Effect<Resolved | undefined>
  43. readonly resolveMention: (value: string) => Effect.Effect<Mention | undefined, RepositoryCache.Error>
  44. readonly ensurePath: (target?: string) => Effect.Effect<void, RepositoryCache.Error>
  45. readonly containsManagedPath: (target?: string) => Effect.Effect<boolean>
  46. }
  47. export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectReference") {}
  48. type Materializer = {
  49. readonly name: string
  50. readonly repository: string
  51. readonly path: string
  52. readonly run: Effect.Effect<void, RepositoryCache.Error>
  53. }
  54. export const layer = Layer.effect(
  55. Service,
  56. Effect.gen(function* () {
  57. if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
  58. const config = yield* Config.Service
  59. const fs = yield* FSUtil.Service
  60. const global = yield* Global.Service
  61. const location = yield* Location.Service
  62. const cache = yield* RepositoryCache.Service
  63. const references = resolveAll({
  64. references: ConfigReference.normalize(
  65. Object.assign({}, ...(yield* config.get()).map((document) => document.info.references ?? {})),
  66. ),
  67. directory: location.project.directory,
  68. home: global.home,
  69. repos: global.repos,
  70. })
  71. const materializers = yield* Effect.forEach(
  72. uniqueGitReferences(references),
  73. Effect.fnUntraced(function* (reference) {
  74. return {
  75. name: reference.name,
  76. repository: reference.repository,
  77. path: reference.path,
  78. run: yield* Effect.cached(
  79. cache
  80. .ensure({ reference: reference.reference, branch: reference.branch, refresh: true })
  81. .pipe(Effect.asVoid),
  82. ),
  83. }
  84. }),
  85. )
  86. yield* Effect.forEach(
  87. materializers,
  88. (materializer) =>
  89. materializer.run.pipe(
  90. Effect.catchCause((cause) =>
  91. Effect.logWarning("failed to materialize project reference").pipe(
  92. Effect.annotateLogs({ name: materializer.name, repository: materializer.repository, cause }),
  93. ),
  94. ),
  95. ),
  96. { concurrency: 4, discard: true },
  97. ).pipe(Effect.forkScoped)
  98. const ensurePath = Effect.fn("ProjectReference.ensurePath")(function* (target?: string) {
  99. const normalized = normalizePath(target)
  100. if (!normalized)
  101. return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true })
  102. yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void
  103. })
  104. return Service.of({
  105. list: Effect.fn("ProjectReference.list")(function* () {
  106. return references
  107. }),
  108. get: Effect.fn("ProjectReference.get")(function* (name: string) {
  109. return references.find((reference) => reference.name === name)
  110. }),
  111. ensurePath,
  112. containsManagedPath: Effect.fn("ProjectReference.containsManagedPath")(function* (target?: string) {
  113. const normalized = normalizePath(target)
  114. return normalized
  115. ? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized))
  116. : false
  117. }),
  118. resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) {
  119. const [name, ...rest] = value.split("/")
  120. const target = rest.length ? rest.join("/") : undefined
  121. const reference = references.find((reference) => reference.name === name)
  122. if (!reference) return
  123. if (reference.kind === "invalid") return { name, kind: "invalid", target, message: reference.message }
  124. if (reference.kind === "git") yield* ensurePath(reference.path)
  125. if (!target) return { name, kind: "reference", reference, path: reference.path }
  126. const resolved = path.resolve(reference.path, target)
  127. if (!FSUtil.contains(reference.path, resolved))
  128. return { name, kind: "invalid", target, message: "Reference target escapes its root" }
  129. if (!(yield* fs.existsSafe(resolved)))
  130. return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
  131. return { name, kind: "reference", reference, target, path: resolved }
  132. }),
  133. })
  134. }),
  135. )
  136. export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
  137. const inert: Interface = {
  138. list: () => Effect.succeed([]),
  139. get: () => Effect.succeed(undefined),
  140. resolveMention: () => Effect.succeed(undefined),
  141. ensurePath: () => Effect.void,
  142. containsManagedPath: () => Effect.succeed(false),
  143. }
  144. export function resolveAll(input: {
  145. references: ConfigReference.NormalizedInfo
  146. directory: string
  147. home: string
  148. repos: string
  149. }) {
  150. const seen = new Map<string, { name: string; branch?: string }>()
  151. return Object.entries(input.references).map(([name, reference]): Resolved => {
  152. const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos })
  153. if (resolved.kind !== "git") return resolved
  154. const existing = seen.get(resolved.path)
  155. if (!existing) {
  156. seen.set(resolved.path, { name, branch: resolved.branch })
  157. return resolved
  158. }
  159. if (existing.branch === resolved.branch) return resolved
  160. return {
  161. name,
  162. kind: "invalid",
  163. repository: resolved.repository,
  164. message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${existing.branch ?? "default branch"} and @${name} requests ${resolved.branch ?? "default branch"}`,
  165. }
  166. })
  167. }
  168. export function resolve(input: {
  169. name: string
  170. reference: ConfigReference.NormalizedEntry
  171. directory: string
  172. home: string
  173. repos: string
  174. }): Resolved {
  175. if (input.reference.kind === "invalid") return { name: input.name, kind: "invalid", message: input.reference.message }
  176. if (input.reference.kind === "local") {
  177. return { name: input.name, kind: "local", path: localPath(input.directory, input.home, input.reference.path) }
  178. }
  179. const reference = Repository.parse(input.reference.repository)
  180. if (!reference || !Repository.isRemote(reference)) {
  181. return {
  182. name: input.name,
  183. kind: "invalid",
  184. repository: input.reference.repository,
  185. message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
  186. }
  187. }
  188. return {
  189. name: input.name,
  190. kind: "git",
  191. repository: input.reference.repository,
  192. reference,
  193. path: Repository.cachePath(input.repos, reference),
  194. branch: input.reference.branch,
  195. }
  196. }
  197. function localPath(directory: string, home: string, value: string) {
  198. if (value.startsWith("~/")) return path.join(home, value.slice(2))
  199. return path.isAbsolute(value) ? value : path.resolve(directory, value)
  200. }
  201. function uniqueGitReferences(references: Resolved[]) {
  202. const seen = new Set<string>()
  203. return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
  204. if (reference.kind !== "git" || seen.has(reference.path)) return false
  205. seen.add(reference.path)
  206. return true
  207. })
  208. }
  209. function normalizePath(target?: string) {
  210. if (!target) return
  211. return process.platform === "win32" ? FSUtil.normalizePath(target) : target
  212. }
  213. function contains(parent: string, child: string) {
  214. return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
  215. }