project-copy.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  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 target = abs(`${input.root.path}-copy-created`)
  93. yield* Effect.addFinalizer(() =>
  94. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  95. )
  96. const fiber = yield* events
  97. .subscribe(ProjectCopy.Event.Updated)
  98. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  99. yield* Effect.yieldNow
  100. const created = yield* copy.create({
  101. projectID: input.projectID,
  102. strategy: "git_worktree",
  103. sourceDirectory: input.sourceDirectory,
  104. directory: target,
  105. })
  106. expect(yield* stored(input.projectID)).toEqual(
  107. [
  108. { directory: input.sourceDirectory, type: "main" as const },
  109. { directory: created.directory, type: "git_worktree" as const },
  110. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  111. )
  112. expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
  113. yield* copy.remove({ projectID: input.projectID, directory: created.directory })
  114. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
  115. expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
  116. }),
  117. )
  118. it.live("does not publish an event when refresh finds no directory changes", () =>
  119. Effect.gen(function* () {
  120. const input = yield* setup()
  121. const copy = yield* ProjectCopy.Service
  122. const events = yield* EventV2.Service
  123. const event = yield* events.subscribe(ProjectCopy.Event.Updated).pipe(
  124. Stream.take(1),
  125. Stream.runCollect,
  126. Effect.forkScoped,
  127. Effect.flatMap((fiber) =>
  128. Effect.gen(function* () {
  129. yield* Effect.yieldNow
  130. yield* copy.refresh({ projectID: input.projectID })
  131. return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
  132. }),
  133. ),
  134. )
  135. expect(event._tag).toBe("None")
  136. }),
  137. )
  138. it.live("refresh discovers and prunes an externally managed git worktree", () =>
  139. Effect.gen(function* () {
  140. const input = yield* setup()
  141. const copy = yield* ProjectCopy.Service
  142. const events = yield* EventV2.Service
  143. const target = abs(`${input.root.path}-copy-external`)
  144. yield* Effect.addFinalizer(() =>
  145. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  146. )
  147. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  148. const fiber = yield* events
  149. .subscribe(ProjectCopy.Event.Updated)
  150. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  151. yield* Effect.yieldNow
  152. yield* copy.refresh({ projectID: input.projectID })
  153. const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
  154. expect(yield* stored(input.projectID)).toEqual(
  155. [
  156. { directory: input.sourceDirectory, type: "main" as const },
  157. { directory: discovered, type: "git_worktree" as const },
  158. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  159. )
  160. expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
  161. yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
  162. yield* copy.refresh({ projectID: input.projectID })
  163. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
  164. }),
  165. )
  166. it.live("refresh with no roots is a no-op", () =>
  167. Effect.gen(function* () {
  168. const copy = yield* ProjectCopy.Service
  169. yield* copy.refresh({ projectID: Project.ID.make("missing-project") })
  170. }),
  171. )
  172. })