project.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import { describe, expect } from "bun:test"
  2. import { $ } from "bun"
  3. import fs from "fs/promises"
  4. import path from "path"
  5. import { Effect, Layer, Schema } from "effect"
  6. import { ProjectV2 } from "@opencode-ai/core/project"
  7. import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
  8. import { Database } from "@opencode-ai/core/database/database"
  9. import { FSUtil } from "@opencode-ai/core/fs-util"
  10. import { Git } from "@opencode-ai/core/git"
  11. import { AbsolutePath } from "@opencode-ai/core/schema"
  12. import { Hash } from "@opencode-ai/core/util/hash"
  13. import { tmpdir } from "./fixture/tmpdir"
  14. import { testEffect } from "./lib/effect"
  15. const databaseLayer = Database.layerFromPath(":memory:")
  16. const it = testEffect(
  17. Layer.mergeAll(
  18. ProjectV2.layer.pipe(
  19. Layer.provide(databaseLayer),
  20. Layer.provide(FSUtil.defaultLayer),
  21. Layer.provide(Git.defaultLayer),
  22. ),
  23. databaseLayer,
  24. ),
  25. )
  26. function remoteID(remote: string) {
  27. return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
  28. }
  29. function abs(value: string) {
  30. return AbsolutePath.make(value)
  31. }
  32. function real(value: string) {
  33. return Effect.promise(() => fs.realpath(value)).pipe(Effect.map((value) => AbsolutePath.make(value)))
  34. }
  35. async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) {
  36. await $`git init`.cwd(dir).quiet()
  37. await $`git config core.fsmonitor false`.cwd(dir).quiet()
  38. await $`git config commit.gpgsign false`.cwd(dir).quiet()
  39. await $`git config user.email test@opencode.test`.cwd(dir).quiet()
  40. await $`git config user.name Test`.cwd(dir).quiet()
  41. if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet()
  42. if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
  43. }
  44. async function rootCommit(dir: string) {
  45. return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
  46. }
  47. describe("Project directories schemas", () => {
  48. it.effect("decodes project directory input and inline directory results", () =>
  49. Effect.sync(() => {
  50. expect(Schema.decodeUnknownSync(ProjectV2.DirectoriesInput)({ projectID: ProjectV2.ID.make("project") })).toEqual(
  51. {
  52. projectID: ProjectV2.ID.make("project"),
  53. },
  54. )
  55. expect(
  56. Schema.decodeUnknownSync(ProjectV2.Directories)([
  57. { directory: AbsolutePath.make("/tmp/project"), type: "main" },
  58. ]),
  59. ).toEqual([{ directory: AbsolutePath.make("/tmp/project"), type: "main" }])
  60. }),
  61. )
  62. it.effect("lists stored project directories newest first for the requested project", () =>
  63. Effect.gen(function* () {
  64. const project = yield* ProjectV2.Service
  65. const { db } = yield* Database.Service
  66. const projectID = ProjectV2.ID.make("directories-project")
  67. const otherID = ProjectV2.ID.make("directories-other")
  68. yield* db
  69. .insert(ProjectTable)
  70. .values([
  71. { id: projectID, worktree: AbsolutePath.make("/repo"), sandboxes: [], time_created: 1, time_updated: 1 },
  72. { id: otherID, worktree: AbsolutePath.make("/other"), sandboxes: [], time_created: 1, time_updated: 1 },
  73. ])
  74. .run()
  75. .pipe(Effect.orDie)
  76. yield* db
  77. .insert(ProjectDirectoryTable)
  78. .values([
  79. { project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root", time_created: 2 },
  80. { project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main", time_created: 1 },
  81. { project_id: otherID, directory: AbsolutePath.make("/other"), type: "main", time_created: 3 },
  82. ])
  83. .run()
  84. .pipe(Effect.orDie)
  85. expect(yield* project.directories({ projectID })).toEqual([
  86. { directory: AbsolutePath.make("/repo/z"), type: "root" },
  87. { directory: AbsolutePath.make("/repo/a"), type: "main" },
  88. ])
  89. }),
  90. )
  91. })
  92. describe("ProjectV2.resolve", () => {
  93. it.live("returns global for non-git directory", () =>
  94. Effect.gen(function* () {
  95. const tmp = yield* Effect.acquireRelease(
  96. Effect.promise(() => tmpdir()),
  97. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  98. )
  99. const project = yield* ProjectV2.Service
  100. const result = yield* project.resolve(abs(tmp.path))
  101. expect(result.id).toBe(ProjectV2.ID.make("global"))
  102. expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
  103. expect(result.previous).toBeUndefined()
  104. expect(result.vcs).toBeUndefined()
  105. }),
  106. )
  107. it.live("returns git global for repo with no commits and no remote", () =>
  108. Effect.gen(function* () {
  109. const tmp = yield* Effect.acquireRelease(
  110. Effect.promise(() => tmpdir()),
  111. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  112. )
  113. yield* Effect.promise(() => initRepo(tmp.path))
  114. const project = yield* ProjectV2.Service
  115. const result = yield* project.resolve(abs(tmp.path))
  116. expect(result.id).toBe(ProjectV2.ID.make("global"))
  117. expect(result.directory).toBe(yield* real(tmp.path))
  118. expect(result.previous).toBeUndefined()
  119. expect(result.vcs?.type).toBe("git")
  120. }),
  121. )
  122. it.live("falls back to root commit when origin is missing", () =>
  123. Effect.gen(function* () {
  124. const tmp = yield* Effect.acquireRelease(
  125. Effect.promise(() => tmpdir()),
  126. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  127. )
  128. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  129. const project = yield* ProjectV2.Service
  130. const result = yield* project.resolve(abs(tmp.path))
  131. expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  132. expect(result.directory).toBe(yield* real(tmp.path))
  133. expect(result.previous).toBeUndefined()
  134. expect(result.vcs?.type).toBe("git")
  135. }),
  136. )
  137. it.live("prefers normalized origin over root commit", () =>
  138. Effect.gen(function* () {
  139. const tmp = yield* Effect.acquireRelease(
  140. Effect.promise(() => tmpdir()),
  141. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  142. )
  143. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
  144. const project = yield* ProjectV2.Service
  145. const result = yield* project.resolve(abs(tmp.path))
  146. expect(result.id).toBe(remoteID("github.com/Acme/App"))
  147. expect(result.id).not.toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  148. expect(result.directory).toBe(yield* real(tmp.path))
  149. expect(result.vcs?.type).toBe("git")
  150. }),
  151. )
  152. it.live("normalizes ssh and https remotes to the same id", () =>
  153. Effect.gen(function* () {
  154. const ssh = yield* Effect.acquireRelease(
  155. Effect.promise(() => tmpdir()),
  156. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  157. )
  158. const https = yield* Effect.acquireRelease(
  159. Effect.promise(() => tmpdir()),
  160. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  161. )
  162. yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  163. yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
  164. const project = yield* ProjectV2.Service
  165. const a = yield* project.resolve(abs(ssh.path))
  166. const b = yield* project.resolve(abs(https.path))
  167. expect(a.id).toBe(remoteID("github.com/owner/repo"))
  168. expect(b.id).toBe(a.id)
  169. }),
  170. )
  171. it.live("ignores file remotes and falls back to root commit", () =>
  172. Effect.gen(function* () {
  173. const tmp = yield* Effect.acquireRelease(
  174. Effect.promise(() => tmpdir()),
  175. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  176. )
  177. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
  178. const project = yield* ProjectV2.Service
  179. const result = yield* project.resolve(abs(tmp.path))
  180. expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  181. }),
  182. )
  183. it.live("returns previous cached id from common dir", () =>
  184. Effect.gen(function* () {
  185. const tmp = yield* Effect.acquireRelease(
  186. Effect.promise(() => tmpdir()),
  187. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  188. )
  189. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  190. yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
  191. const project = yield* ProjectV2.Service
  192. const result = yield* project.resolve(abs(tmp.path))
  193. expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
  194. expect(result.id).toBe(remoteID("github.com/owner/repo"))
  195. }),
  196. )
  197. it.live("does not write the cache while resolving", () =>
  198. Effect.gen(function* () {
  199. const tmp = yield* Effect.acquireRelease(
  200. Effect.promise(() => tmpdir()),
  201. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  202. )
  203. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  204. const project = yield* ProjectV2.Service
  205. yield* project.resolve(abs(tmp.path))
  206. expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
  207. }),
  208. )
  209. it.live("resolves from nested directories to repo root", () =>
  210. Effect.gen(function* () {
  211. const tmp = yield* Effect.acquireRelease(
  212. Effect.promise(() => tmpdir()),
  213. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  214. )
  215. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  216. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
  217. const project = yield* ProjectV2.Service
  218. const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
  219. expect(result.directory).toBe(yield* real(tmp.path))
  220. }),
  221. )
  222. it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
  223. Effect.gen(function* () {
  224. const tmp = yield* Effect.acquireRelease(
  225. Effect.promise(() => tmpdir()),
  226. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  227. )
  228. const worktree = `${tmp.path}-worktree`
  229. yield* Effect.addFinalizer(() =>
  230. Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
  231. )
  232. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  233. yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
  234. yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
  235. const project = yield* ProjectV2.Service
  236. const result = yield* project.resolve(abs(worktree))
  237. expect(result.directory).toBe(yield* real(worktree))
  238. expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
  239. expect(result.id).toBe(remoteID("github.com/owner/repo"))
  240. expect(result.vcs?.type).toBe("git")
  241. }),
  242. )
  243. })