repository-cache.ts 9.3 KB

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