repository-cache.ts 9.5 KB

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