project-copy.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  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 { tmpdir } from "./fixture/tmpdir"
  16. import { testEffect } from "./lib/effect"
  17. const databaseLayer = Database.layerFromPath(":memory:")
  18. const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
  19. const copyLayer = ProjectCopy.layer.pipe(
  20. Layer.provide(databaseLayer),
  21. Layer.provide(eventLayer),
  22. Layer.provide(FSUtil.defaultLayer),
  23. Layer.provide(Git.defaultLayer),
  24. )
  25. const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer))
  26. function abs(input: string) {
  27. return AbsolutePath.make(input)
  28. }
  29. async function initRepo(directory: string) {
  30. await $`git init`.cwd(directory).quiet()
  31. await $`git config core.fsmonitor false`.cwd(directory).quiet()
  32. await $`git config commit.gpgsign false`.cwd(directory).quiet()
  33. await $`git config user.email test@opencode.test`.cwd(directory).quiet()
  34. await $`git config user.name Test`.cwd(directory).quiet()
  35. await $`git commit --allow-empty -m root`.cwd(directory).quiet()
  36. }
  37. function setup() {
  38. return Effect.gen(function* () {
  39. const root = yield* Effect.acquireRelease(
  40. Effect.promise(() => tmpdir()),
  41. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  42. )
  43. yield* Effect.promise(() => initRepo(root.path))
  44. const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
  45. const projectID = Project.ID.make("copy-project")
  46. const { db } = yield* Database.Service
  47. yield* db
  48. .insert(ProjectTable)
  49. .values({ id: projectID, worktree: sourceDirectory, sandboxes: [], time_created: 1, time_updated: 1 })
  50. .run()
  51. .pipe(Effect.orDie)
  52. yield* db
  53. .insert(ProjectDirectoryTable)
  54. .values({ project_id: projectID, directory: sourceDirectory, type: "main" })
  55. .run()
  56. .pipe(Effect.orDie)
  57. return { root, sourceDirectory, projectID, db }
  58. })
  59. }
  60. function stored(projectID: Project.ID) {
  61. return Database.Service.use(({ db }) =>
  62. db
  63. .select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
  64. .from(ProjectDirectoryTable)
  65. .where(eq(ProjectDirectoryTable.project_id, projectID))
  66. .all()
  67. .pipe(
  68. Effect.orDie,
  69. Effect.map((rows) => rows.toSorted((a, b) => a.directory.localeCompare(b.directory))),
  70. ),
  71. )
  72. }
  73. describe("ProjectCopy", () => {
  74. it.live("detects linked git worktrees but not root checkouts", () =>
  75. Effect.gen(function* () {
  76. const input = yield* setup()
  77. const copy = yield* ProjectCopy.Service
  78. const target = abs(`${input.root.path}-copy-detected`)
  79. yield* Effect.addFinalizer(() =>
  80. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  81. )
  82. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  83. expect(yield* copy.detect({ directory: input.sourceDirectory })).toBeUndefined()
  84. expect(yield* copy.detect({ directory: target })).toBe("git_worktree")
  85. }),
  86. )
  87. it.live("creates and removes a git worktree directory", () =>
  88. Effect.gen(function* () {
  89. const input = yield* setup()
  90. const copy = yield* ProjectCopy.Service
  91. const events = yield* EventV2.Service
  92. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  93. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-created"))
  94. const target = abs(path.join(parent, "copy"))
  95. yield* Effect.addFinalizer(() =>
  96. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  97. )
  98. const fiber = yield* events
  99. .subscribe(ProjectCopy.Event.Updated)
  100. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  101. yield* Effect.yieldNow
  102. const created = yield* copy.create({
  103. projectID: input.projectID,
  104. strategy: "git_worktree",
  105. sourceDirectory: input.sourceDirectory,
  106. directory: parent,
  107. name: "copy",
  108. })
  109. expect(created.directory).toBe(target)
  110. expect(yield* stored(input.projectID)).toEqual(
  111. [
  112. { directory: input.sourceDirectory, type: "main" as const },
  113. { directory: created.directory, type: "git_worktree" as const },
  114. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  115. )
  116. expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
  117. yield* copy.remove({ projectID: input.projectID, directory: created.directory })
  118. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
  119. expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
  120. }),
  121. )
  122. it.live("adds a numeric suffix when a copy directory already exists", () =>
  123. Effect.gen(function* () {
  124. const input = yield* setup()
  125. const copy = yield* ProjectCopy.Service
  126. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  127. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-suffix"))
  128. const target = abs(path.join(parent, "copy-3"))
  129. yield* Effect.addFinalizer(() =>
  130. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  131. )
  132. yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true }))
  133. yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2")))
  134. const created = yield* copy.create({
  135. projectID: input.projectID,
  136. strategy: "git_worktree",
  137. sourceDirectory: input.sourceDirectory,
  138. directory: parent,
  139. name: "copy",
  140. })
  141. expect(created.directory).toBe(target)
  142. expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy")).then((item) => item.isDirectory()))).toBe(
  143. true,
  144. )
  145. expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy-2")).then((item) => item.isDirectory()))).toBe(
  146. true,
  147. )
  148. yield* copy.remove({ projectID: input.projectID, directory: created.directory })
  149. }),
  150. )
  151. it.live("fails after ten copy directory conflicts", () =>
  152. Effect.gen(function* () {
  153. const input = yield* setup()
  154. const copy = yield* ProjectCopy.Service
  155. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  156. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-conflicts"))
  157. yield* Effect.addFinalizer(() =>
  158. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  159. )
  160. yield* Effect.promise(() =>
  161. Promise.all(
  162. Array.from({ length: 10 }, (_, index) =>
  163. fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }),
  164. ),
  165. ),
  166. )
  167. const error = yield* copy
  168. .create({
  169. projectID: input.projectID,
  170. strategy: "git_worktree",
  171. sourceDirectory: input.sourceDirectory,
  172. directory: parent,
  173. name: "copy",
  174. })
  175. .pipe(Effect.flip)
  176. expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
  177. expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
  178. }),
  179. )
  180. it.live("does not publish an event when refresh finds no directory changes", () =>
  181. Effect.gen(function* () {
  182. const input = yield* setup()
  183. const copy = yield* ProjectCopy.Service
  184. const events = yield* EventV2.Service
  185. const event = yield* events.subscribe(ProjectCopy.Event.Updated).pipe(
  186. Stream.take(1),
  187. Stream.runCollect,
  188. Effect.forkScoped,
  189. Effect.flatMap((fiber) =>
  190. Effect.gen(function* () {
  191. yield* Effect.yieldNow
  192. yield* copy.refresh({ projectID: input.projectID })
  193. return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
  194. }),
  195. ),
  196. )
  197. expect(event._tag).toBe("None")
  198. }),
  199. )
  200. it.live("refresh discovers and prunes an externally managed git worktree", () =>
  201. Effect.gen(function* () {
  202. const input = yield* setup()
  203. const copy = yield* ProjectCopy.Service
  204. const events = yield* EventV2.Service
  205. const target = abs(`${input.root.path}-copy-external`)
  206. yield* Effect.addFinalizer(() =>
  207. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  208. )
  209. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  210. const fiber = yield* events
  211. .subscribe(ProjectCopy.Event.Updated)
  212. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  213. yield* Effect.yieldNow
  214. yield* copy.refresh({ projectID: input.projectID })
  215. const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
  216. expect(yield* stored(input.projectID)).toEqual(
  217. [
  218. { directory: input.sourceDirectory, type: "main" as const },
  219. { directory: discovered, type: "git_worktree" as const },
  220. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  221. )
  222. expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
  223. yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
  224. yield* copy.refresh({ projectID: input.projectID })
  225. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
  226. }),
  227. )
  228. it.live("refresh ignores stale git worktree registrations", () =>
  229. Effect.gen(function* () {
  230. const input = yield* setup()
  231. const copy = yield* ProjectCopy.Service
  232. const stale = abs(`${input.root.path}-copy-stale`)
  233. const target = abs(`${input.root.path}-copy-after-stale`)
  234. yield* Effect.addFinalizer(() =>
  235. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  236. )
  237. yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
  238. yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
  239. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  240. yield* copy.refresh({ projectID: input.projectID })
  241. const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
  242. expect(yield* stored(input.projectID)).toEqual(
  243. [
  244. { directory: input.sourceDirectory, type: "main" as const },
  245. { directory: discovered, type: "git_worktree" as const },
  246. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  247. )
  248. }),
  249. )
  250. it.live("refresh with no roots is a no-op", () =>
  251. Effect.gen(function* () {
  252. const copy = yield* ProjectCopy.Service
  253. yield* copy.refresh({ projectID: Project.ID.make("missing-project") })
  254. }),
  255. )
  256. })