project-copy.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  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 { Bus } from "@opencode-ai/core/bus"
  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, Bus.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("reports unavailable strategy ids", () =>
  79. Effect.gen(function* () {
  80. const input = yield* setup()
  81. const copy = yield* ProjectCopy.Service
  82. const unavailable = ProjectCopy.StrategyID.make("acme/missing")
  83. const error = yield* copy
  84. .create({
  85. projectID: input.projectID,
  86. strategy: unavailable,
  87. sourceDirectory: input.sourceDirectory,
  88. directory: abs(`${input.root.path}-missing-strategy`),
  89. name: "copy",
  90. })
  91. .pipe(Effect.flip)
  92. expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
  93. if (error instanceof ProjectCopy.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
  94. }),
  95. )
  96. it.live("creates and removes a git worktree directory", () =>
  97. Effect.gen(function* () {
  98. const input = yield* setup()
  99. const copy = yield* ProjectCopy.Service
  100. const bus = yield* Bus.Service
  101. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  102. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-created"))
  103. const target = abs(path.join(parent, "copy"))
  104. yield* Effect.addFinalizer(() =>
  105. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  106. )
  107. const fiber = yield* bus
  108. .subscribe(ProjectCopy.Event.Updated)
  109. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  110. yield* Effect.yieldNow
  111. const created = yield* copy.create({
  112. projectID: input.projectID,
  113. strategy: gitWorktree,
  114. sourceDirectory: input.sourceDirectory,
  115. directory: parent,
  116. name: "copy",
  117. })
  118. expect(created.directory).toBe(target)
  119. expect(yield* stored(input.projectID)).toEqual(
  120. [
  121. { directory: input.sourceDirectory, strategy: null },
  122. { directory: created.directory, strategy: "git_worktree" },
  123. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  124. )
  125. expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
  126. yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
  127. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
  128. expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
  129. }),
  130. )
  131. it.live("requires force to remove a dirty git worktree", () =>
  132. Effect.gen(function* () {
  133. const input = yield* setup()
  134. const copy = yield* ProjectCopy.Service
  135. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  136. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-dirty"))
  137. yield* Effect.addFinalizer(() =>
  138. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  139. )
  140. const created = yield* copy.create({
  141. projectID: input.projectID,
  142. strategy: gitWorktree,
  143. sourceDirectory: input.sourceDirectory,
  144. directory: parent,
  145. name: "copy",
  146. })
  147. yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
  148. const error = yield* copy
  149. .remove({ projectID: input.projectID, directory: created.directory, force: false })
  150. .pipe(Effect.flip)
  151. expect(error).toBeInstanceOf(Git.WorktreeError)
  152. if (error instanceof Git.WorktreeError) {
  153. expect(error.operation).toBe("remove")
  154. expect(error.forceRequired).toBe(true)
  155. }
  156. expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" })
  157. expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
  158. yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
  159. expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
  160. }),
  161. )
  162. it.live("preserves copies whose stored strategy is unavailable", () =>
  163. Effect.gen(function* () {
  164. const input = yield* setup()
  165. const copy = yield* ProjectCopy.Service
  166. const unavailable = abs(`${input.root.path}-copy-unavailable`)
  167. yield* Effect.promise(() => fs.mkdir(unavailable))
  168. yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
  169. yield* input.db
  170. .insert(ProjectDirectoryTable)
  171. .values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" })
  172. .run()
  173. .pipe(Effect.orDie)
  174. const error = yield* copy
  175. .remove({ projectID: input.projectID, directory: unavailable, force: false })
  176. .pipe(Effect.flip)
  177. expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
  178. expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
  179. }),
  180. )
  181. it.live("adds a numeric suffix when a copy directory already exists", () =>
  182. Effect.gen(function* () {
  183. const input = yield* setup()
  184. const copy = yield* ProjectCopy.Service
  185. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  186. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-suffix"))
  187. const target = abs(path.join(parent, "copy-3"))
  188. yield* Effect.addFinalizer(() =>
  189. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  190. )
  191. yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true }))
  192. yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2")))
  193. const created = yield* copy.create({
  194. projectID: input.projectID,
  195. strategy: gitWorktree,
  196. sourceDirectory: input.sourceDirectory,
  197. directory: parent,
  198. name: "copy",
  199. })
  200. expect(created.directory).toBe(target)
  201. expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy")).then((item) => item.isDirectory()))).toBe(
  202. true,
  203. )
  204. expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy-2")).then((item) => item.isDirectory()))).toBe(
  205. true,
  206. )
  207. yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
  208. }),
  209. )
  210. it.live("fails after ten copy directory conflicts", () =>
  211. Effect.gen(function* () {
  212. const input = yield* setup()
  213. const copy = yield* ProjectCopy.Service
  214. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  215. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-conflicts"))
  216. yield* Effect.addFinalizer(() =>
  217. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  218. )
  219. yield* Effect.promise(() =>
  220. Promise.all(
  221. Array.from({ length: 10 }, (_, index) =>
  222. fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }),
  223. ),
  224. ),
  225. )
  226. const error = yield* copy
  227. .create({
  228. projectID: input.projectID,
  229. strategy: gitWorktree,
  230. sourceDirectory: input.sourceDirectory,
  231. directory: parent,
  232. name: "copy",
  233. })
  234. .pipe(Effect.flip)
  235. expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
  236. if (error instanceof ProjectCopy.DestinationExistsError)
  237. expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
  238. }),
  239. )
  240. it.live("does not publish an event when refresh finds no directory changes", () =>
  241. Effect.gen(function* () {
  242. const input = yield* setup()
  243. const copy = yield* ProjectCopy.Service
  244. const bus = yield* Bus.Service
  245. const event = yield* bus.subscribe(ProjectCopy.Event.Updated).pipe(
  246. Stream.take(1),
  247. Stream.runCollect,
  248. Effect.forkScoped,
  249. Effect.flatMap((fiber) =>
  250. Effect.gen(function* () {
  251. yield* Effect.yieldNow
  252. yield* copy.refresh({ projectID: input.projectID })
  253. return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
  254. }),
  255. ),
  256. )
  257. expect(event._tag).toBe("None")
  258. }),
  259. )
  260. it.live("refresh discovers and prunes an externally managed git worktree", () =>
  261. Effect.gen(function* () {
  262. const input = yield* setup()
  263. const copy = yield* ProjectCopy.Service
  264. const bus = yield* Bus.Service
  265. const target = abs(`${input.root.path}-copy-external`)
  266. yield* Effect.addFinalizer(() =>
  267. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  268. )
  269. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  270. yield* input.db
  271. .insert(ProjectDirectoryTable)
  272. .values({ project_id: input.projectID, directory: target })
  273. .run()
  274. .pipe(Effect.orDie)
  275. const fiber = yield* bus
  276. .subscribe(ProjectCopy.Event.Updated)
  277. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  278. yield* Effect.yieldNow
  279. const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
  280. expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
  281. expect(yield* stored(input.projectID)).toEqual(
  282. [
  283. { directory: input.sourceDirectory, strategy: null },
  284. { directory: discovered, strategy: "git_worktree" },
  285. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  286. )
  287. expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
  288. yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
  289. expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
  290. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
  291. }),
  292. )
  293. it.live("refresh ignores stale git worktree registrations", () =>
  294. Effect.gen(function* () {
  295. const input = yield* setup()
  296. const copy = yield* ProjectCopy.Service
  297. const stale = abs(`${input.root.path}-copy-stale`)
  298. const target = abs(`${input.root.path}-copy-after-stale`)
  299. yield* Effect.addFinalizer(() =>
  300. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  301. )
  302. yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
  303. yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
  304. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  305. yield* copy.refresh({ projectID: input.projectID })
  306. const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
  307. expect(yield* stored(input.projectID)).toEqual(
  308. [
  309. { directory: input.sourceDirectory, strategy: null },
  310. { directory: discovered, strategy: "git_worktree" },
  311. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  312. )
  313. }),
  314. )
  315. it.live("refresh ignores existing directories that are no longer git checkouts", () =>
  316. Effect.gen(function* () {
  317. const input = yield* setup()
  318. yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
  319. const copy = yield* ProjectCopy.Service
  320. yield* copy.refresh({ projectID: input.projectID })
  321. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
  322. }),
  323. )
  324. it.live("refresh with no roots is a no-op", () =>
  325. Effect.gen(function* () {
  326. const copy = yield* ProjectCopy.Service
  327. expect(yield* copy.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
  328. updated: [],
  329. removed: [],
  330. })
  331. }),
  332. )
  333. it.live("refresh removes missing ordinary checkouts", () =>
  334. Effect.gen(function* () {
  335. const input = yield* setup()
  336. const missing = abs(`${input.root.path}-missing-checkout`)
  337. yield* input.db
  338. .insert(ProjectDirectoryTable)
  339. .values({ project_id: input.projectID, directory: missing })
  340. .run()
  341. .pipe(Effect.orDie)
  342. const copy = yield* ProjectCopy.Service
  343. expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
  344. expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
  345. }),
  346. )
  347. })