project-copy.test.ts 16 KB

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