project-copy.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  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, force: false })
  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("requires force to remove a dirty git worktree", () =>
  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-dirty"))
  128. yield* Effect.addFinalizer(() =>
  129. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  130. )
  131. const created = yield* copy.create({
  132. projectID: input.projectID,
  133. strategy: "git_worktree",
  134. sourceDirectory: input.sourceDirectory,
  135. directory: parent,
  136. name: "copy",
  137. })
  138. yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
  139. const error = yield* copy
  140. .remove({ projectID: input.projectID, directory: created.directory, force: false })
  141. .pipe(Effect.flip)
  142. expect(error).toBeInstanceOf(Git.WorktreeError)
  143. if (error instanceof Git.WorktreeError) {
  144. expect(error.operation).toBe("remove")
  145. expect(error.forceRequired).toBe(true)
  146. }
  147. expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, type: "git_worktree" })
  148. expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
  149. yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
  150. expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
  151. }),
  152. )
  153. it.live("adds a numeric suffix when a copy directory already exists", () =>
  154. Effect.gen(function* () {
  155. const input = yield* setup()
  156. const copy = yield* ProjectCopy.Service
  157. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  158. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-suffix"))
  159. const target = abs(path.join(parent, "copy-3"))
  160. yield* Effect.addFinalizer(() =>
  161. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  162. )
  163. yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true }))
  164. yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2")))
  165. const created = yield* copy.create({
  166. projectID: input.projectID,
  167. strategy: "git_worktree",
  168. sourceDirectory: input.sourceDirectory,
  169. directory: parent,
  170. name: "copy",
  171. })
  172. expect(created.directory).toBe(target)
  173. expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy")).then((item) => item.isDirectory()))).toBe(
  174. true,
  175. )
  176. expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy-2")).then((item) => item.isDirectory()))).toBe(
  177. true,
  178. )
  179. yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
  180. }),
  181. )
  182. it.live("fails after ten copy directory conflicts", () =>
  183. Effect.gen(function* () {
  184. const input = yield* setup()
  185. const copy = yield* ProjectCopy.Service
  186. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  187. const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-conflicts"))
  188. yield* Effect.addFinalizer(() =>
  189. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  190. )
  191. yield* Effect.promise(() =>
  192. Promise.all(
  193. Array.from({ length: 10 }, (_, index) =>
  194. fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }),
  195. ),
  196. ),
  197. )
  198. const error = yield* copy
  199. .create({
  200. projectID: input.projectID,
  201. strategy: "git_worktree",
  202. sourceDirectory: input.sourceDirectory,
  203. directory: parent,
  204. name: "copy",
  205. })
  206. .pipe(Effect.flip)
  207. expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
  208. expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
  209. }),
  210. )
  211. it.live("does not publish an event when refresh finds no directory changes", () =>
  212. Effect.gen(function* () {
  213. const input = yield* setup()
  214. const copy = yield* ProjectCopy.Service
  215. const events = yield* EventV2.Service
  216. const event = yield* events.subscribe(ProjectCopy.Event.Updated).pipe(
  217. Stream.take(1),
  218. Stream.runCollect,
  219. Effect.forkScoped,
  220. Effect.flatMap((fiber) =>
  221. Effect.gen(function* () {
  222. yield* Effect.yieldNow
  223. yield* copy.refresh({ projectID: input.projectID })
  224. return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
  225. }),
  226. ),
  227. )
  228. expect(event._tag).toBe("None")
  229. }),
  230. )
  231. it.live("refresh discovers and prunes an externally managed git worktree", () =>
  232. Effect.gen(function* () {
  233. const input = yield* setup()
  234. const copy = yield* ProjectCopy.Service
  235. const events = yield* EventV2.Service
  236. const target = abs(`${input.root.path}-copy-external`)
  237. yield* Effect.addFinalizer(() =>
  238. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  239. )
  240. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  241. const fiber = yield* events
  242. .subscribe(ProjectCopy.Event.Updated)
  243. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  244. yield* Effect.yieldNow
  245. yield* copy.refresh({ projectID: input.projectID })
  246. const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
  247. expect(yield* stored(input.projectID)).toEqual(
  248. [
  249. { directory: input.sourceDirectory, type: "main" as const },
  250. { directory: discovered, type: "git_worktree" as const },
  251. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  252. )
  253. expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
  254. yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
  255. yield* copy.refresh({ projectID: input.projectID })
  256. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
  257. }),
  258. )
  259. it.live("refresh ignores stale git worktree registrations", () =>
  260. Effect.gen(function* () {
  261. const input = yield* setup()
  262. const copy = yield* ProjectCopy.Service
  263. const stale = abs(`${input.root.path}-copy-stale`)
  264. const target = abs(`${input.root.path}-copy-after-stale`)
  265. yield* Effect.addFinalizer(() =>
  266. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  267. )
  268. yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
  269. yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
  270. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  271. yield* copy.refresh({ projectID: input.projectID })
  272. const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
  273. expect(yield* stored(input.projectID)).toEqual(
  274. [
  275. { directory: input.sourceDirectory, type: "main" as const },
  276. { directory: discovered, type: "git_worktree" as const },
  277. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  278. )
  279. }),
  280. )
  281. it.live("refresh with no roots is a no-op", () =>
  282. Effect.gen(function* () {
  283. const copy = yield* ProjectCopy.Service
  284. yield* copy.refresh({ projectID: Project.ID.make("missing-project") })
  285. }),
  286. )
  287. })