project-reference.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  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(
  66. {},
  67. ...(yield* config.entries())
  68. .filter((entry): entry is Config.Document => entry.type === "document")
  69. .map((document) => document.info.references ?? {}),
  70. ),
  71. ),
  72. directory: location.project.directory,
  73. home: global.home,
  74. repos: global.repos,
  75. })
  76. const materializers = yield* Effect.forEach(
  77. uniqueGitReferences(references),
  78. Effect.fnUntraced(function* (reference) {
  79. return {
  80. name: reference.name,
  81. repository: reference.repository,
  82. path: reference.path,
  83. run: yield* Effect.cached(
  84. cache
  85. .ensure({ reference: reference.reference, branch: reference.branch, refresh: true })
  86. .pipe(Effect.asVoid),
  87. ),
  88. }
  89. }),
  90. )
  91. yield* Effect.forEach(
  92. materializers,
  93. (materializer) =>
  94. materializer.run.pipe(
  95. Effect.catchCause((cause) =>
  96. Effect.logWarning("failed to materialize project reference").pipe(
  97. Effect.annotateLogs({ name: materializer.name, repository: materializer.repository, cause }),
  98. ),
  99. ),
  100. ),
  101. { concurrency: 4, discard: true },
  102. ).pipe(Effect.forkScoped)
  103. const ensurePath = Effect.fn("ProjectReference.ensurePath")(function* (target?: string) {
  104. const normalized = normalizePath(target)
  105. if (!normalized)
  106. return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true })
  107. yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void
  108. })
  109. return Service.of({
  110. list: Effect.fn("ProjectReference.list")(function* () {
  111. return references
  112. }),
  113. get: Effect.fn("ProjectReference.get")(function* (name: string) {
  114. return references.find((reference) => reference.name === name)
  115. }),
  116. ensurePath,
  117. containsManagedPath: Effect.fn("ProjectReference.containsManagedPath")(function* (target?: string) {
  118. const normalized = normalizePath(target)
  119. return normalized
  120. ? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized))
  121. : false
  122. }),
  123. resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) {
  124. const [name, ...rest] = value.split("/")
  125. const target = rest.length ? rest.join("/") : undefined
  126. const reference = references.find((reference) => reference.name === name)
  127. if (!reference) return
  128. if (reference.kind === "invalid") return { name, kind: "invalid", target, message: reference.message }
  129. if (reference.kind === "git") yield* ensurePath(reference.path)
  130. if (!target) return { name, kind: "reference", reference, path: reference.path }
  131. const resolved = path.resolve(reference.path, target)
  132. if (!FSUtil.contains(reference.path, resolved))
  133. return { name, kind: "invalid", target, message: "Reference target escapes its root" }
  134. if (!(yield* fs.existsSafe(resolved)))
  135. return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
  136. return { name, kind: "reference", reference, target, path: resolved }
  137. }),
  138. })
  139. }),
  140. )
  141. export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
  142. const inert: Interface = {
  143. list: () => Effect.succeed([]),
  144. get: () => Effect.succeed(undefined),
  145. resolveMention: () => Effect.succeed(undefined),
  146. ensurePath: () => Effect.void,
  147. containsManagedPath: () => Effect.succeed(false),
  148. }
  149. export function resolveAll(input: {
  150. references: ConfigReference.NormalizedInfo
  151. directory: string
  152. home: string
  153. repos: string
  154. }) {
  155. const seen = new Map<string, { name: string; branch?: string }>()
  156. return Object.entries(input.references).map(([name, reference]): Resolved => {
  157. const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos })
  158. if (resolved.kind !== "git") return resolved
  159. const existing = seen.get(resolved.path)
  160. if (!existing) {
  161. seen.set(resolved.path, { name, branch: resolved.branch })
  162. return resolved
  163. }
  164. if (existing.branch === resolved.branch) return resolved
  165. return {
  166. name,
  167. kind: "invalid",
  168. repository: resolved.repository,
  169. 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"}`,
  170. }
  171. })
  172. }
  173. export function resolve(input: {
  174. name: string
  175. reference: ConfigReference.NormalizedEntry
  176. directory: string
  177. home: string
  178. repos: string
  179. }): Resolved {
  180. if (input.reference.kind === "invalid") return { name: input.name, kind: "invalid", message: input.reference.message }
  181. if (input.reference.kind === "local") {
  182. return { name: input.name, kind: "local", path: localPath(input.directory, input.home, input.reference.path) }
  183. }
  184. const reference = Repository.parse(input.reference.repository)
  185. if (!reference || !Repository.isRemote(reference)) {
  186. return {
  187. name: input.name,
  188. kind: "invalid",
  189. repository: input.reference.repository,
  190. message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
  191. }
  192. }
  193. return {
  194. name: input.name,
  195. kind: "git",
  196. repository: input.reference.repository,
  197. reference,
  198. path: Repository.cachePath(input.repos, reference),
  199. branch: input.reference.branch,
  200. }
  201. }
  202. function localPath(directory: string, home: string, value: string) {
  203. if (value.startsWith("~/")) return path.join(home, value.slice(2))
  204. return path.isAbsolute(value) ? value : path.resolve(directory, value)
  205. }
  206. function uniqueGitReferences(references: Resolved[]) {
  207. const seen = new Set<string>()
  208. return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
  209. if (reference.kind !== "git" || seen.has(reference.path)) return false
  210. seen.add(reference.path)
  211. return true
  212. })
  213. }
  214. function normalizePath(target?: string) {
  215. if (!target) return
  216. return process.platform === "win32" ? FSUtil.normalizePath(target) : target
  217. }
  218. function contains(parent: string, child: string) {
  219. return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
  220. }