project-copy.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import { describe, expect } from "bun:test"
  2. import { $ } from "bun"
  3. import fs from "fs/promises"
  4. import path from "path"
  5. import { eq } from "drizzle-orm"
  6. import { Effect, Fiber, Layer, Stream } from "effect"
  7. import { AbsolutePath } from "@opencode-ai/core/schema"
  8. import { FSUtil } from "@opencode-ai/core/fs-util"
  9. import { Git } from "@opencode-ai/core/git"
  10. import { Database } from "@opencode-ai/core/database/database"
  11. import { EventV2 } from "@opencode-ai/core/event"
  12. import { Project } from "@opencode-ai/core/project"
  13. import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
  14. import { ProjectCopy } from "@opencode-ai/core/project/copy"
  15. import { ProjectDirectories } from "@opencode-ai/core/project/directories"
  16. import { tmpdir } from "./fixture/tmpdir"
  17. import { testEffect } from "./lib/effect"
  18. const copyLayer = ProjectCopy.layer.pipe(
  19. Layer.provide(Database.defaultLayer),
  20. Layer.provide(ProjectDirectories.defaultLayer),
  21. Layer.provide(EventV2.defaultLayer),
  22. Layer.provide(FSUtil.defaultLayer),
  23. Layer.provide(Git.defaultLayer),
  24. )
  25. const it = testEffect(
  26. Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer),
  27. )
  28. function abs(input: string) {
  29. return AbsolutePath.make(input)
  30. }
  31. const gitWorktree = ProjectCopy.StrategyID.make("git_worktree")
  32. async function initRepo(directory: string) {
  33. await $`git init`.cwd(directory).quiet()
  34. await $`git config core.fsmonitor false`.cwd(directory).quiet()
  35. await $`git config commit.gpgsign false`.cwd(directory).quiet()
  36. await $`git config user.email test@opencode.test`.cwd(directory).quiet()
  37. await $`git config user.name Test`.cwd(directory).quiet()
  38. await $`git commit --allow-empty -m root`.cwd(directory).quiet()
  39. }
  40. function setup() {
  41. return Effect.gen(function* () {
  42. const root = yield* Effect.acquireRelease(
  43. Effect.promise(() => tmpdir()),
  44. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  45. )
  46. yield* Effect.promise(() => initRepo(root.path))
  47. const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
  48. const projectID = Project.ID.make("copy-project")
  49. const { db } = yield* Database.Service
  50. yield* db
  51. .insert(ProjectTable)
  52. .values({ id: projectID, worktree: sourceDirectory, sandboxes: [], time_created: 1, time_updated: 1 })
  53. .run()
  54. .pipe(Effect.orDie)
  55. yield* db
  56. .insert(ProjectDirectoryTable)
  57. .values({ project_id: projectID, directory: sourceDirectory })
  58. .run()
  59. .pipe(Effect.orDie)
  60. return { root, sourceDirectory, projectID, db }
  61. })
  62. }
  63. function stored(projectID: Project.ID) {
  64. return Database.Service.use(({ db }) =>
  65. db
  66. .select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
  67. .from(ProjectDirectoryTable)
  68. .where(eq(ProjectDirectoryTable.project_id, projectID))
  69. .all()
  70. .pipe(
  71. Effect.orDie,
  72. Effect.map((rows) => rows.toSorted((a, b) => a.directory.localeCompare(b.directory))),
  73. ),
  74. )
  75. }
  76. describe("ProjectCopy", () => {
  77. it.effect("accepts arbitrary non-empty strategy ids", () =>
  78. Effect.sync(() => {
  79. expect(String(ProjectCopy.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
  80. expect(() => ProjectCopy.StrategyID.make(" acme/snapshot ")).toThrow()
  81. expect(() => ProjectCopy.StrategyID.make(" ")).toThrow()
  82. }),
  83. )
  84. it.effect("rejects duplicate strategies and reports unavailable ids", () =>
  85. Effect.gen(function* () {
  86. const input = yield* setup()
  87. const copy = yield* ProjectCopy.Service
  88. const strategy: ProjectCopy.Strategy = {
  89. id: ProjectCopy.StrategyID.make("test/duplicate"),
  90. create: () => Effect.die("unused"),
  91. remove: () => Effect.die("unused"),
  92. list: () => Effect.succeed([]),
  93. }
  94. yield* copy.register(strategy)
  95. expect(yield* copy.register(strategy).pipe(Effect.flip)).toBeInstanceOf(ProjectCopy.DuplicateStrategyError)
  96. const unavailable = ProjectCopy.StrategyID.make("acme/missing")
  97. const error = yield* copy
  98. .create({
  99. projectID: input.projectID,
  100. strategy: unavailable,
  101. sourceDirectory: input.sourceDirectory,
  102. directory: abs(`${input.root.path}-missing-strategy`),
  103. name: "copy",
  104. })
  105. .pipe(Effect.flip)
  106. expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
  107. if (error instanceof ProjectCopy.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
  108. }),
  109. )
  110. it.live("creates and removes a git worktree directory", () =>
  111. Effect.gen(function* () {
  112. const input = yield* setup()
  113. const copy = yield* ProjectCopy.Service
  114. const events = yield* EventV2.Service
  115. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  116. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-created"))
  117. const target = abs(path.join(parent, "copy"))
  118. yield* Effect.addFinalizer(() =>
  119. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  120. )
  121. const fiber = yield* events
  122. .subscribe(ProjectCopy.Event.Updated)
  123. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  124. yield* Effect.yieldNow
  125. const created = yield* copy.create({
  126. projectID: input.projectID,
  127. strategy: gitWorktree,
  128. sourceDirectory: input.sourceDirectory,
  129. directory: parent,
  130. name: "copy",
  131. })
  132. expect(created.directory).toBe(target)
  133. expect(yield* stored(input.projectID)).toEqual(
  134. [
  135. { directory: input.sourceDirectory, strategy: null },
  136. { directory: created.directory, strategy: "git_worktree" },
  137. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  138. )
  139. expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
  140. yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
  141. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
  142. expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
  143. }),
  144. )
  145. it.live("requires force to remove a dirty git worktree", () =>
  146. Effect.gen(function* () {
  147. const input = yield* setup()
  148. const copy = yield* ProjectCopy.Service
  149. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  150. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-dirty"))
  151. yield* Effect.addFinalizer(() =>
  152. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  153. )
  154. const created = yield* copy.create({
  155. projectID: input.projectID,
  156. strategy: gitWorktree,
  157. sourceDirectory: input.sourceDirectory,
  158. directory: parent,
  159. name: "copy",
  160. })
  161. yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
  162. const error = yield* copy
  163. .remove({ projectID: input.projectID, directory: created.directory, force: false })
  164. .pipe(Effect.flip)
  165. expect(error).toBeInstanceOf(Git.WorktreeError)
  166. if (error instanceof Git.WorktreeError) {
  167. expect(error.operation).toBe("remove")
  168. expect(error.forceRequired).toBe(true)
  169. }
  170. expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" })
  171. expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
  172. yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
  173. expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
  174. }),
  175. )
  176. it.live("preserves copies whose stored strategy is unavailable", () =>
  177. Effect.gen(function* () {
  178. const input = yield* setup()
  179. const copy = yield* ProjectCopy.Service
  180. const unavailable = abs(`${input.root.path}-copy-unavailable`)
  181. yield* Effect.promise(() => fs.mkdir(unavailable))
  182. yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
  183. yield* input.db
  184. .insert(ProjectDirectoryTable)
  185. .values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" })
  186. .run()
  187. .pipe(Effect.orDie)
  188. const error = yield* copy
  189. .remove({ projectID: input.projectID, directory: unavailable, force: false })
  190. .pipe(Effect.flip)
  191. expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
  192. expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
  193. }),
  194. )
  195. it.live("adds a numeric suffix when a copy directory already exists", () =>
  196. Effect.gen(function* () {
  197. const input = yield* setup()
  198. const copy = yield* ProjectCopy.Service
  199. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  200. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-suffix"))
  201. const target = abs(path.join(parent, "copy-3"))
  202. yield* Effect.addFinalizer(() =>
  203. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  204. )
  205. yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true }))
  206. yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2")))
  207. const created = yield* copy.create({
  208. projectID: input.projectID,
  209. strategy: gitWorktree,
  210. sourceDirectory: input.sourceDirectory,
  211. directory: parent,
  212. name: "copy",
  213. })
  214. expect(created.directory).toBe(target)
  215. expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy")).then((item) => item.isDirectory()))).toBe(
  216. true,
  217. )
  218. expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy-2")).then((item) => item.isDirectory()))).toBe(
  219. true,
  220. )
  221. yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
  222. }),
  223. )
  224. it.live("fails after ten copy directory conflicts", () =>
  225. Effect.gen(function* () {
  226. const input = yield* setup()
  227. const copy = yield* ProjectCopy.Service
  228. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  229. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-conflicts"))
  230. yield* Effect.addFinalizer(() =>
  231. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  232. )
  233. yield* Effect.promise(() =>
  234. Promise.all(
  235. Array.from({ length: 10 }, (_, index) =>
  236. fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }),
  237. ),
  238. ),
  239. )
  240. const error = yield* copy
  241. .create({
  242. projectID: input.projectID,
  243. strategy: gitWorktree,
  244. sourceDirectory: input.sourceDirectory,
  245. directory: parent,
  246. name: "copy",
  247. })
  248. .pipe(Effect.flip)
  249. expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
  250. if (error instanceof ProjectCopy.DestinationExistsError)
  251. expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
  252. }),
  253. )
  254. it.live("does not publish an event when refresh finds no directory changes", () =>
  255. Effect.gen(function* () {
  256. const input = yield* setup()
  257. const copy = yield* ProjectCopy.Service
  258. const events = yield* EventV2.Service
  259. const event = yield* events.subscribe(ProjectCopy.Event.Updated).pipe(
  260. Stream.take(1),
  261. Stream.runCollect,
  262. Effect.forkScoped,
  263. Effect.flatMap((fiber) =>
  264. Effect.gen(function* () {
  265. yield* Effect.yieldNow
  266. yield* copy.refresh({ projectID: input.projectID })
  267. return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
  268. }),
  269. ),
  270. )
  271. expect(event._tag).toBe("None")
  272. }),
  273. )
  274. it.live("refresh discovers and prunes an externally managed git worktree", () =>
  275. Effect.gen(function* () {
  276. const input = yield* setup()
  277. const copy = yield* ProjectCopy.Service
  278. const events = yield* EventV2.Service
  279. const target = abs(`${input.root.path}-copy-external`)
  280. yield* Effect.addFinalizer(() =>
  281. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  282. )
  283. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  284. yield* input.db
  285. .insert(ProjectDirectoryTable)
  286. .values({ project_id: input.projectID, directory: target })
  287. .run()
  288. .pipe(Effect.orDie)
  289. const fiber = yield* events
  290. .subscribe(ProjectCopy.Event.Updated)
  291. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  292. yield* Effect.yieldNow
  293. const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
  294. expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
  295. expect(yield* stored(input.projectID)).toEqual(
  296. [
  297. { directory: input.sourceDirectory, strategy: null },
  298. { directory: discovered, strategy: "git_worktree" },
  299. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  300. )
  301. expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
  302. yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
  303. expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
  304. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
  305. }),
  306. )
  307. it.live("refresh ignores stale git worktree registrations", () =>
  308. Effect.gen(function* () {
  309. const input = yield* setup()
  310. const copy = yield* ProjectCopy.Service
  311. const stale = abs(`${input.root.path}-copy-stale`)
  312. const target = abs(`${input.root.path}-copy-after-stale`)
  313. yield* Effect.addFinalizer(() =>
  314. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  315. )
  316. yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
  317. yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
  318. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  319. yield* copy.refresh({ projectID: input.projectID })
  320. const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
  321. expect(yield* stored(input.projectID)).toEqual(
  322. [
  323. { directory: input.sourceDirectory, strategy: null },
  324. { directory: discovered, strategy: "git_worktree" },
  325. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  326. )
  327. }),
  328. )
  329. it.live("refresh ignores existing directories that are no longer git checkouts", () =>
  330. Effect.gen(function* () {
  331. const input = yield* setup()
  332. yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
  333. const copy = yield* ProjectCopy.Service
  334. yield* copy.refresh({ projectID: input.projectID })
  335. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
  336. }),
  337. )
  338. it.live("refresh with no roots is a no-op", () =>
  339. Effect.gen(function* () {
  340. const copy = yield* ProjectCopy.Service
  341. expect(yield* copy.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
  342. updated: [],
  343. removed: [],
  344. })
  345. }),
  346. )
  347. it.live("refresh removes missing ordinary checkouts", () =>
  348. Effect.gen(function* () {
  349. const input = yield* setup()
  350. const missing = abs(`${input.root.path}-missing-checkout`)
  351. yield* input.db
  352. .insert(ProjectDirectoryTable)
  353. .values({ project_id: input.projectID, directory: missing })
  354. .run()
  355. .pipe(Effect.orDie)
  356. const copy = yield* ProjectCopy.Service
  357. expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
  358. expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
  359. }),
  360. )
  361. })