project-directory.test.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. import { describe, expect } from "bun:test"
  2. import { $ } from "bun"
  3. import path from "path"
  4. import { eq } from "drizzle-orm"
  5. import { Effect, Layer } from "effect"
  6. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  7. import { Hash } from "@opencode-ai/core/util/hash"
  8. import { AbsolutePath } from "@opencode-ai/core/schema"
  9. import { Database } from "@opencode-ai/core/database/database"
  10. import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
  11. import { ProjectV2 } from "@opencode-ai/core/project"
  12. import { Project } from "@/project/project"
  13. import { tmpdirScoped } from "../fixture/fixture"
  14. import { testEffect } from "../lib/effect"
  15. const it = testEffect(Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer))
  16. function directories(projectID: ProjectV2.ID) {
  17. return Database.Service.use(({ db }) =>
  18. db
  19. .select()
  20. .from(ProjectDirectoryTable)
  21. .where(eq(ProjectDirectoryTable.project_id, projectID))
  22. .all()
  23. .pipe(
  24. Effect.orDie,
  25. Effect.map((rows) =>
  26. rows
  27. .map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
  28. .toSorted((a, b) => a.directory.localeCompare(b.directory)),
  29. ),
  30. ),
  31. )
  32. }
  33. describe("Project directory persistence", () => {
  34. it.live("stores the first opened checkout directory", () =>
  35. Effect.gen(function* () {
  36. const tmp = yield* tmpdirScoped({ git: true })
  37. const project = yield* Project.Service
  38. const result = yield* project.fromDirectory(tmp)
  39. expect(yield* directories(result.project.id)).toEqual([
  40. { directory: AbsolutePath.make(tmp), strategy: undefined },
  41. ])
  42. }),
  43. )
  44. it.live("stores a repeatedly opened checkout directory only once", () =>
  45. Effect.gen(function* () {
  46. const tmp = yield* tmpdirScoped({ git: true })
  47. const project = yield* Project.Service
  48. const result = yield* project.fromDirectory(tmp)
  49. const next = yield* project.fromDirectory(tmp)
  50. expect(next.project.id).toBe(result.project.id)
  51. expect(yield* directories(result.project.id)).toEqual([
  52. { directory: AbsolutePath.make(tmp), strategy: undefined },
  53. ])
  54. }),
  55. )
  56. it.live("stores an opened linked worktree directory", () =>
  57. Effect.gen(function* () {
  58. const tmp = yield* tmpdirScoped({ git: true })
  59. const project = yield* Project.Service
  60. const main = yield* project.fromDirectory(tmp)
  61. const worktree = path.join(tmp, "..", path.basename(tmp) + "-project-directory-worktree")
  62. yield* Effect.addFinalizer(() =>
  63. Effect.promise(() => $`git worktree remove ${worktree}`.cwd(tmp).quiet().nothrow()).pipe(Effect.ignore),
  64. )
  65. yield* Effect.promise(() => $`git worktree add ${worktree} -b project-directory-${Date.now()}`.cwd(tmp).quiet())
  66. yield* project.fromDirectory(worktree)
  67. expect(yield* directories(main.project.id)).toEqual(
  68. [
  69. { directory: AbsolutePath.make(tmp), strategy: undefined },
  70. { directory: AbsolutePath.make(worktree), strategy: undefined },
  71. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  72. )
  73. }),
  74. )
  75. it.live("stores only the linked copy when first opened from an external linked worktree", () =>
  76. Effect.gen(function* () {
  77. const tmp = yield* tmpdirScoped({ git: true })
  78. const worktree = path.join(tmp, "..", path.basename(tmp) + "-project-directory-first-worktree")
  79. yield* Effect.addFinalizer(() =>
  80. Effect.promise(() => $`git worktree remove ${worktree}`.cwd(tmp).quiet().nothrow()).pipe(Effect.ignore),
  81. )
  82. yield* Effect.promise(() => $`git worktree add --detach ${worktree} HEAD`.cwd(tmp).quiet())
  83. const project = yield* Project.Service
  84. const result = yield* project.fromDirectory(worktree)
  85. expect(yield* directories(result.project.id)).toEqual([
  86. { directory: AbsolutePath.make(worktree), strategy: undefined },
  87. ])
  88. }),
  89. )
  90. it.live("stores a separately opened clone as a secondary directory", () =>
  91. Effect.gen(function* () {
  92. const tmp = yield* tmpdirScoped({ git: true })
  93. const bare = tmp + "-project-directory-bare"
  94. const clone = tmp + "-project-directory-clone"
  95. yield* Effect.addFinalizer(() =>
  96. Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore),
  97. )
  98. yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
  99. yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
  100. const project = yield* Project.Service
  101. const main = yield* project.fromDirectory(tmp)
  102. yield* project.fromDirectory(clone)
  103. expect(yield* directories(main.project.id)).toEqual(
  104. [
  105. { directory: AbsolutePath.make(tmp), strategy: undefined },
  106. { directory: AbsolutePath.make(clone), strategy: undefined },
  107. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  108. )
  109. }),
  110. )
  111. it.live("stores only the materialized worktree for a bare repository", () =>
  112. Effect.gen(function* () {
  113. const tmp = yield* tmpdirScoped({ git: true })
  114. const bare = tmp + "-project-directory-bare-store.git"
  115. const worktree = tmp + "-project-directory-bare-worktree"
  116. yield* Effect.addFinalizer(() =>
  117. Effect.promise(() => $`rm -rf ${bare} ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
  118. )
  119. yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
  120. yield* Effect.promise(() => $`git worktree add ${worktree} HEAD`.cwd(bare).quiet())
  121. const project = yield* Project.Service
  122. const result = yield* project.fromDirectory(worktree)
  123. expect(yield* directories(result.project.id)).toEqual([
  124. { directory: AbsolutePath.make(worktree), strategy: undefined },
  125. ])
  126. }),
  127. )
  128. it.live("records the active directory under its newly resolved project id", () =>
  129. Effect.gen(function* () {
  130. const tmp = yield* tmpdirScoped({ git: true })
  131. const project = yield* Project.Service
  132. yield* project.fromDirectory(tmp)
  133. const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/collision"))
  134. const { db } = yield* Database.Service
  135. yield* db
  136. .insert(ProjectTable)
  137. .values({
  138. id: remoteID,
  139. worktree: AbsolutePath.make("/tmp/existing"),
  140. vcs: "git",
  141. time_created: Date.now(),
  142. time_updated: Date.now(),
  143. sandboxes: [],
  144. })
  145. .run()
  146. .pipe(Effect.orDie)
  147. yield* Effect.promise(() =>
  148. $`git remote add origin git@github.com:project-directory-test/collision.git`.cwd(tmp).quiet(),
  149. )
  150. yield* project.fromDirectory(tmp)
  151. expect(yield* directories(remoteID)).toEqual([{ directory: AbsolutePath.make(tmp), strategy: undefined }])
  152. }),
  153. )
  154. it.live("clears stale directories when the project id changes", () =>
  155. Effect.gen(function* () {
  156. const tmp = yield* tmpdirScoped({ git: true })
  157. const project = yield* Project.Service
  158. const original = yield* project.fromDirectory(tmp)
  159. const stale = AbsolutePath.make(tmp + "-stale-checkout")
  160. const { db } = yield* Database.Service
  161. yield* db
  162. .insert(ProjectDirectoryTable)
  163. .values({ project_id: original.project.id, directory: stale })
  164. .run()
  165. .pipe(Effect.orDie)
  166. const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/migration"))
  167. yield* Effect.promise(() =>
  168. $`git remote add origin git@github.com:project-directory-test/migration.git`.cwd(tmp).quiet(),
  169. )
  170. yield* project.fromDirectory(tmp)
  171. expect(yield* directories(original.project.id)).toEqual([])
  172. expect(yield* directories(remoteID)).toEqual([{ directory: AbsolutePath.make(tmp), strategy: undefined }])
  173. }),
  174. )
  175. })