project-directory.test.ts 7.8 KB

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