1
0

copy.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. export * as ProjectCopy from "./copy"
  2. import { Context, Effect, Layer, Schema } from "effect"
  3. import path from "path"
  4. import { AbsolutePath } from "../schema"
  5. import { FSUtil } from "../fs-util"
  6. import { Git } from "../git"
  7. import { LayerNode } from "../effect/layer-node"
  8. import { Project } from "../project"
  9. import { ProjectDirectories } from "./directories"
  10. import { makeGitWorktreeStrategy } from "./copy-strategies"
  11. import { Slug } from "../util/slug"
  12. import { EventV2 } from "../event"
  13. import { Database } from "../database/database"
  14. import { Location } from "../location"
  15. import { PluginBoot } from "../plugin/boot"
  16. export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
  17. export type StrategyID = typeof StrategyID.Type
  18. export const CreateInput = Schema.Struct({
  19. projectID: Project.ID,
  20. strategy: StrategyID,
  21. sourceDirectory: AbsolutePath,
  22. directory: AbsolutePath,
  23. name: Schema.optional(Schema.String),
  24. }).annotate({ identifier: "ProjectCopy.CreateInput" })
  25. export type CreateInput = typeof CreateInput.Type
  26. export const RemoveInput = Schema.Struct({
  27. projectID: Project.ID,
  28. directory: AbsolutePath,
  29. force: Schema.Boolean,
  30. }).annotate({ identifier: "ProjectCopy.RemoveInput" })
  31. export type RemoveInput = typeof RemoveInput.Type
  32. export const RefreshInput = Schema.Struct({
  33. projectID: Project.ID,
  34. }).annotate({ identifier: "ProjectCopy.RefreshInput" })
  35. export type RefreshInput = typeof RefreshInput.Type
  36. export const RefreshResult = Schema.Struct({
  37. updated: Schema.Array(AbsolutePath),
  38. removed: Schema.Array(AbsolutePath),
  39. }).annotate({ identifier: "ProjectCopy.RefreshResult" })
  40. export type RefreshResult = typeof RefreshResult.Type
  41. export const Copy = Schema.Struct({
  42. directory: AbsolutePath,
  43. }).annotate({ identifier: "ProjectCopy.Copy" })
  44. export type Copy = typeof Copy.Type
  45. export const ListEntry = Schema.Struct({
  46. directory: AbsolutePath,
  47. type: Schema.Literals(["root", "copy"]),
  48. }).annotate({ identifier: "ProjectCopy.ListEntry" })
  49. export type ListEntry = typeof ListEntry.Type
  50. export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
  51. "ProjectCopy.SourceDirectoryNotFoundError",
  52. { directory: AbsolutePath },
  53. ) {}
  54. export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
  55. "ProjectCopy.DestinationExistsError",
  56. { directory: AbsolutePath },
  57. ) {}
  58. export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
  59. "ProjectCopy.DirectoryUnavailableError",
  60. { directory: AbsolutePath },
  61. ) {}
  62. export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
  63. "ProjectCopy.InvalidDirectoryError",
  64. { directory: AbsolutePath },
  65. ) {}
  66. export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
  67. "ProjectCopy.StrategyUnavailableError",
  68. { strategy: StrategyID },
  69. ) {}
  70. export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
  71. "ProjectCopy.DuplicateStrategyError",
  72. { strategy: StrategyID },
  73. ) {}
  74. export type Error =
  75. | SourceDirectoryNotFoundError
  76. | DestinationExistsError
  77. | DirectoryUnavailableError
  78. | InvalidDirectoryError
  79. | StrategyUnavailableError
  80. | Git.WorktreeError
  81. export interface Strategy {
  82. readonly id: StrategyID
  83. readonly create: (input: {
  84. sourceDirectory: AbsolutePath
  85. directory: AbsolutePath
  86. }) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
  87. readonly remove: (input: {
  88. directory: AbsolutePath
  89. force: boolean
  90. }) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
  91. readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
  92. }
  93. export const Event = {
  94. Updated: EventV2.define({
  95. type: "project.directories.updated",
  96. schema: { projectID: Project.ID },
  97. }),
  98. }
  99. export interface Interface {
  100. readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
  101. readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
  102. readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
  103. readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
  104. }
  105. export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
  106. export const refreshAfterBoot = Effect.gen(function* () {
  107. const location = yield* Location.Service
  108. const boot = yield* PluginBoot.Service
  109. const copies = yield* Service
  110. yield* Effect.gen(function* () {
  111. yield* boot.wait()
  112. yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
  113. const result = yield* copies.refresh({ projectID: location.project.id })
  114. yield* Effect.logInfo("project copy refresh done", {
  115. projectID: location.project.id,
  116. updated: result.updated,
  117. removed: result.removed,
  118. })
  119. }).pipe(
  120. Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })),
  121. Effect.forkScoped,
  122. Effect.asVoid,
  123. )
  124. })
  125. export const layer = Layer.effect(
  126. Service,
  127. Effect.gen(function* () {
  128. const fs = yield* FSUtil.Service
  129. const git = yield* Git.Service
  130. const directories = yield* ProjectDirectories.Service
  131. const db = (yield* Database.Service).db
  132. const events = yield* EventV2.Service
  133. const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
  134. if (update) yield* events.publish(Event.Updated, { projectID })
  135. })
  136. const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
  137. const resolved = AbsolutePath.make(FSUtil.resolve(input))
  138. if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
  139. return resolved
  140. })
  141. const registry = new Map<StrategyID, Strategy>()
  142. const register = Effect.fn("ProjectCopy.register")(function* (strategy: Strategy) {
  143. if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
  144. registry.set(strategy.id, strategy)
  145. })
  146. // Register default strategies
  147. yield* register(makeGitWorktreeStrategy({ git, canonical })).pipe(Effect.orDie)
  148. const strategies = () => Array.from(registry.values())
  149. const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
  150. const sourceDirectory = yield* canonical(input)
  151. if (!(yield* directories.contains({ projectID, directory: sourceDirectory })))
  152. return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
  153. return sourceDirectory
  154. })
  155. const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
  156. const found = registry.get(id)
  157. if (!found) return yield* new StrategyUnavailableError({ strategy: id })
  158. return found
  159. })
  160. const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
  161. const selected = yield* getStrategy(input.strategy)
  162. const sourceDirectory = yield* source(input.sourceDirectory, input.projectID)
  163. yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
  164. const name = input.name ?? Slug.create()
  165. let suffix = 1
  166. let copyDirectory = AbsolutePath.make(path.join(input.directory, name))
  167. while (yield* fs.existsSafe(copyDirectory)) {
  168. suffix++
  169. if (suffix > 10) return yield* new DestinationExistsError({ directory: copyDirectory })
  170. copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
  171. }
  172. const result = yield* selected.create({
  173. directory: copyDirectory,
  174. sourceDirectory,
  175. })
  176. yield* changed(
  177. input.projectID,
  178. yield* directories.create({
  179. projectID: input.projectID,
  180. directory: result.directory,
  181. strategy: input.strategy,
  182. behavior: "replace",
  183. }),
  184. )
  185. return result
  186. })
  187. const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
  188. const copyDirectory = yield* canonical(input.directory)
  189. const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
  190. if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
  191. yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
  192. directory: copyDirectory,
  193. force: input.force,
  194. })
  195. yield* changed(
  196. input.projectID,
  197. yield* directories.remove({ projectID: input.projectID, directory: copyDirectory }),
  198. )
  199. })
  200. const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
  201. const stored = yield* directories.list(input.projectID)
  202. const checked = yield* Effect.forEach(
  203. stored,
  204. (item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
  205. { concurrency: "unbounded" },
  206. )
  207. const sourceDirectories = checked
  208. .filter((item) => item.strategy === undefined && item.exists)
  209. .map((item) => item.directory)
  210. const discovered = yield* Effect.forEach(
  211. sourceDirectories,
  212. (sourceDirectory) =>
  213. Effect.forEach(strategies(), (strategy) =>
  214. strategy.list(sourceDirectory).pipe(
  215. Effect.map((items) =>
  216. items.map((item) => ({
  217. directory: item.directory,
  218. strategy: item.type === "copy" ? strategy.id : undefined,
  219. })),
  220. ),
  221. ),
  222. ),
  223. { concurrency: "unbounded" },
  224. ).pipe(
  225. Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
  226. )
  227. const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
  228. const result = yield* db
  229. .transaction((tx) =>
  230. Effect.all({
  231. updated: Effect.forEach(discovered, (item) =>
  232. directories.create(
  233. {
  234. projectID: input.projectID,
  235. directory: item.directory,
  236. strategy: item.strategy,
  237. behavior: "replace",
  238. },
  239. tx,
  240. ),
  241. ),
  242. removed: Effect.forEach(removed, (directory) =>
  243. directories.remove({ projectID: input.projectID, directory }, tx),
  244. ),
  245. }),
  246. )
  247. .pipe(Effect.orDie)
  248. const changes = {
  249. updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
  250. removed: removed.filter((_, index) => result.removed[index]),
  251. }
  252. yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
  253. return changes
  254. })
  255. return Service.of({
  256. register,
  257. create,
  258. remove,
  259. refresh,
  260. })
  261. }),
  262. )
  263. export const locationLayer = layer
  264. export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node])