repository-cache.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import path from "path"
  2. import { Context, Effect, Layer, Schema } from "effect"
  3. import { FSUtil } from "./fs-util"
  4. import { Git } from "./git"
  5. import { Global } from "./global"
  6. import { Repository } from "./repository"
  7. import { EffectFlock } from "./util/effect-flock"
  8. export type Result = {
  9. readonly repository: string
  10. readonly host: string
  11. readonly remote: string
  12. readonly localPath: string
  13. readonly status: "cached" | "cloned" | "refreshed"
  14. readonly head?: string
  15. readonly branch?: string
  16. }
  17. export type EnsureInput = {
  18. readonly reference: Repository.RemoteReference
  19. readonly refresh?: boolean
  20. readonly branch?: string
  21. }
  22. export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
  23. "RepositoryCacheInvalidRepositoryError",
  24. {
  25. repository: Schema.String,
  26. message: Schema.String,
  27. },
  28. ) {}
  29. export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
  30. "RepositoryCacheInvalidBranchError",
  31. {
  32. branch: Schema.String,
  33. message: Schema.String,
  34. },
  35. ) {}
  36. export class CloneFailedError extends Schema.TaggedErrorClass<CloneFailedError>()("RepositoryCacheCloneFailedError", {
  37. repository: Schema.String,
  38. message: Schema.String,
  39. }) {}
  40. export class FetchFailedError extends Schema.TaggedErrorClass<FetchFailedError>()("RepositoryCacheFetchFailedError", {
  41. repository: Schema.String,
  42. message: Schema.String,
  43. }) {}
  44. export class CheckoutFailedError extends Schema.TaggedErrorClass<CheckoutFailedError>()(
  45. "RepositoryCacheCheckoutFailedError",
  46. {
  47. repository: Schema.String,
  48. branch: Schema.String,
  49. message: Schema.String,
  50. },
  51. ) {}
  52. export class ResetFailedError extends Schema.TaggedErrorClass<ResetFailedError>()("RepositoryCacheResetFailedError", {
  53. repository: Schema.String,
  54. message: Schema.String,
  55. }) {}
  56. export class LockFailedError extends Schema.TaggedErrorClass<LockFailedError>()("RepositoryCacheLockFailedError", {
  57. localPath: Schema.String,
  58. message: Schema.String,
  59. }) {}
  60. export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationError>()(
  61. "RepositoryCacheOperationError",
  62. {
  63. operation: Schema.String,
  64. path: Schema.String,
  65. message: Schema.String,
  66. },
  67. ) {}
  68. export type Error =
  69. | InvalidRepositoryError
  70. | InvalidBranchError
  71. | CloneFailedError
  72. | FetchFailedError
  73. | CheckoutFailedError
  74. | ResetFailedError
  75. | LockFailedError
  76. | CacheOperationError
  77. export interface Interface {
  78. readonly ensure: (input: EnsureInput) => Effect.Effect<Result, Error>
  79. }
  80. export class Service extends Context.Service<Service, Interface>()("@opencode/RepositoryCache") {}
  81. export function isError(error: unknown): error is Error {
  82. return (
  83. error instanceof InvalidRepositoryError ||
  84. error instanceof InvalidBranchError ||
  85. error instanceof CloneFailedError ||
  86. error instanceof FetchFailedError ||
  87. error instanceof CheckoutFailedError ||
  88. error instanceof ResetFailedError ||
  89. error instanceof LockFailedError ||
  90. error instanceof CacheOperationError
  91. )
  92. }
  93. export const parseRemote = Effect.fn("RepositoryCache.parseRemote")(function* (repository: string) {
  94. return yield* Effect.try({
  95. try: () => Repository.parseRemote(repository),
  96. catch: (error) => new InvalidRepositoryError({ repository, message: errorMessage(error) }),
  97. })
  98. })
  99. export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
  100. return yield* Effect.try({
  101. try: () => Repository.validateBranch(branch),
  102. catch: (error) => new InvalidBranchError({ branch, message: errorMessage(error) }),
  103. })
  104. })
  105. export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFlock.Service | Global.Service> =
  106. Layer.effect(
  107. Service,
  108. Effect.gen(function* () {
  109. const fs = yield* FSUtil.Service
  110. const git = yield* Git.Service
  111. const flock = yield* EffectFlock.Service
  112. const global = yield* Global.Service
  113. return Service.of({
  114. ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
  115. if (input.branch) yield* validateBranch(input.branch)
  116. const repository = input.reference.label
  117. const localPath = Repository.cachePath(global.repos, input.reference)
  118. const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
  119. return yield* flock
  120. .withLock(
  121. Effect.gen(function* () {
  122. yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
  123. const exists = yield* fs.existsSafe(localPath)
  124. const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
  125. const origin = hasGitDir ? yield* git.origin(localPath) : undefined
  126. const originReference = origin ? Repository.parse(origin) : undefined
  127. const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
  128. if (exists && !reuse) {
  129. yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
  130. }
  131. const currentBranch = reuse ? yield* git.branch(localPath) : undefined
  132. const status = statusForRepository({
  133. reuse,
  134. refresh: input.refresh,
  135. branchMatches: input.branch ? currentBranch === input.branch : undefined,
  136. })
  137. if (status === "cloned") {
  138. const result = yield* git
  139. .clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
  140. .pipe(
  141. Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
  142. )
  143. if (result.exitCode !== 0) {
  144. return yield* new CloneFailedError({
  145. repository,
  146. message: resultMessage(result, `Failed to clone ${repository}`),
  147. })
  148. }
  149. }
  150. if (status === "refreshed") {
  151. const fetch = yield* git
  152. .fetch(localPath)
  153. .pipe(
  154. Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
  155. )
  156. if (fetch.exitCode !== 0) {
  157. return yield* new FetchFailedError({
  158. repository,
  159. message: resultMessage(fetch, `Failed to refresh ${repository}`),
  160. })
  161. }
  162. if (input.branch) {
  163. const requestedBranch = input.branch
  164. const fetchBranch = yield* git
  165. .fetchBranch(localPath, requestedBranch)
  166. .pipe(
  167. Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
  168. )
  169. if (fetchBranch.exitCode !== 0) {
  170. return yield* new FetchFailedError({
  171. repository,
  172. message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
  173. })
  174. }
  175. const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
  176. Effect.mapError(
  177. (error) =>
  178. new CheckoutFailedError({
  179. repository,
  180. branch: requestedBranch,
  181. message: errorMessage(error),
  182. }),
  183. ),
  184. )
  185. if (checkout.exitCode !== 0) {
  186. return yield* new CheckoutFailedError({
  187. repository,
  188. branch: requestedBranch,
  189. message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
  190. })
  191. }
  192. }
  193. const reset = yield* git
  194. .reset(localPath, yield* resetTarget(git, localPath, input.branch))
  195. .pipe(
  196. Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })),
  197. )
  198. if (reset.exitCode !== 0) {
  199. return yield* new ResetFailedError({
  200. repository,
  201. message: resultMessage(reset, `Failed to reset ${repository}`),
  202. })
  203. }
  204. }
  205. return {
  206. repository,
  207. host: input.reference.host,
  208. remote: input.reference.remote,
  209. localPath,
  210. status,
  211. head: yield* git.head(localPath),
  212. branch: yield* git.branch(localPath),
  213. } satisfies Result
  214. }),
  215. `repository-cache:${localPath}`,
  216. )
  217. .pipe(
  218. Effect.mapError((error) =>
  219. isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
  220. ),
  221. )
  222. }),
  223. })
  224. }),
  225. )
  226. export const defaultLayer: Layer.Layer<Service> = layer.pipe(
  227. Layer.provide(EffectFlock.defaultLayer),
  228. Layer.provide(FSUtil.defaultLayer),
  229. Layer.provide(Git.defaultLayer),
  230. Layer.provide(Global.defaultLayer),
  231. )
  232. function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
  233. if (!input.reuse) return "cloned" as const
  234. if (input.branchMatches === false || input.refresh) return "refreshed" as const
  235. return "cached" as const
  236. }
  237. function errorMessage(error: unknown) {
  238. return error instanceof globalThis.Error ? error.message : String(error)
  239. }
  240. function cacheOperation<A, E, R>(effect: Effect.Effect<A, E, R>, operation: string, target: string) {
  241. return effect.pipe(
  242. Effect.mapError((error) => new CacheOperationError({ operation, path: target, message: errorMessage(error) })),
  243. )
  244. }
  245. const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, requestedBranch?: string) {
  246. if (requestedBranch) return `origin/${requestedBranch}`
  247. const remoteHead = yield* git.remoteHead(cwd)
  248. if (remoteHead) return remoteHead
  249. const currentBranch = yield* git.branch(cwd)
  250. if (currentBranch) return `origin/${currentBranch}`
  251. return "HEAD"
  252. })
  253. function resultMessage(result: Git.Result, fallback: string) {
  254. return result.stderr.trim() || result.text.trim() || fallback
  255. }
  256. export * as RepositoryCache from "./repository-cache"