project.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  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(Schema.decodeUnknownSync(ProjectV2.Directories)([AbsolutePath.make("/tmp/project")])).toEqual([
  56. AbsolutePath.make("/tmp/project"),
  57. ])
  58. }),
  59. )
  60. it.effect("lists stored project directories only for the requested project", () =>
  61. Effect.gen(function* () {
  62. const project = yield* ProjectV2.Service
  63. const { db } = yield* Database.Service
  64. const projectID = ProjectV2.ID.make("directories-project")
  65. const otherID = ProjectV2.ID.make("directories-other")
  66. yield* db
  67. .insert(ProjectTable)
  68. .values([
  69. { id: projectID, worktree: AbsolutePath.make("/repo"), sandboxes: [], time_created: 1, time_updated: 1 },
  70. { id: otherID, worktree: AbsolutePath.make("/other"), sandboxes: [], time_created: 1, time_updated: 1 },
  71. ])
  72. .run()
  73. .pipe(Effect.orDie)
  74. yield* db
  75. .insert(ProjectDirectoryTable)
  76. .values([
  77. { project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root" },
  78. { project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main" },
  79. { project_id: otherID, directory: AbsolutePath.make("/other"), type: "main" },
  80. ])
  81. .run()
  82. .pipe(Effect.orDie)
  83. expect(yield* project.directories({ projectID })).toEqual([
  84. AbsolutePath.make("/repo/a"),
  85. AbsolutePath.make("/repo/z"),
  86. ])
  87. }),
  88. )
  89. })
  90. describe("ProjectV2.resolve", () => {
  91. it.live("returns global for non-git directory", () =>
  92. Effect.gen(function* () {
  93. const tmp = yield* Effect.acquireRelease(
  94. Effect.promise(() => tmpdir()),
  95. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  96. )
  97. const project = yield* ProjectV2.Service
  98. const result = yield* project.resolve(abs(tmp.path))
  99. expect(result.id).toBe(ProjectV2.ID.make("global"))
  100. expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
  101. expect(result.previous).toBeUndefined()
  102. expect(result.vcs).toBeUndefined()
  103. }),
  104. )
  105. it.live("returns git global for repo with no commits and no remote", () =>
  106. Effect.gen(function* () {
  107. const tmp = yield* Effect.acquireRelease(
  108. Effect.promise(() => tmpdir()),
  109. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  110. )
  111. yield* Effect.promise(() => initRepo(tmp.path))
  112. const project = yield* ProjectV2.Service
  113. const result = yield* project.resolve(abs(tmp.path))
  114. expect(result.id).toBe(ProjectV2.ID.make("global"))
  115. expect(result.directory).toBe(yield* real(tmp.path))
  116. expect(result.previous).toBeUndefined()
  117. expect(result.vcs?.type).toBe("git")
  118. }),
  119. )
  120. it.live("falls back to root commit when origin is missing", () =>
  121. Effect.gen(function* () {
  122. const tmp = yield* Effect.acquireRelease(
  123. Effect.promise(() => tmpdir()),
  124. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  125. )
  126. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  127. const project = yield* ProjectV2.Service
  128. const result = yield* project.resolve(abs(tmp.path))
  129. expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  130. expect(result.directory).toBe(yield* real(tmp.path))
  131. expect(result.previous).toBeUndefined()
  132. expect(result.vcs?.type).toBe("git")
  133. }),
  134. )
  135. it.live("prefers normalized origin over root commit", () =>
  136. Effect.gen(function* () {
  137. const tmp = yield* Effect.acquireRelease(
  138. Effect.promise(() => tmpdir()),
  139. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  140. )
  141. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
  142. const project = yield* ProjectV2.Service
  143. const result = yield* project.resolve(abs(tmp.path))
  144. expect(result.id).toBe(remoteID("github.com/Acme/App"))
  145. expect(result.id).not.toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  146. expect(result.directory).toBe(yield* real(tmp.path))
  147. expect(result.vcs?.type).toBe("git")
  148. }),
  149. )
  150. it.live("normalizes ssh and https remotes to the same id", () =>
  151. Effect.gen(function* () {
  152. const ssh = yield* Effect.acquireRelease(
  153. Effect.promise(() => tmpdir()),
  154. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  155. )
  156. const https = yield* Effect.acquireRelease(
  157. Effect.promise(() => tmpdir()),
  158. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  159. )
  160. yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  161. yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
  162. const project = yield* ProjectV2.Service
  163. const a = yield* project.resolve(abs(ssh.path))
  164. const b = yield* project.resolve(abs(https.path))
  165. expect(a.id).toBe(remoteID("github.com/owner/repo"))
  166. expect(b.id).toBe(a.id)
  167. }),
  168. )
  169. it.live("ignores file remotes and falls back to root commit", () =>
  170. Effect.gen(function* () {
  171. const tmp = yield* Effect.acquireRelease(
  172. Effect.promise(() => tmpdir()),
  173. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  174. )
  175. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
  176. const project = yield* ProjectV2.Service
  177. const result = yield* project.resolve(abs(tmp.path))
  178. expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  179. }),
  180. )
  181. it.live("returns previous cached id from common dir", () =>
  182. Effect.gen(function* () {
  183. const tmp = yield* Effect.acquireRelease(
  184. Effect.promise(() => tmpdir()),
  185. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  186. )
  187. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  188. yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
  189. const project = yield* ProjectV2.Service
  190. const result = yield* project.resolve(abs(tmp.path))
  191. expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
  192. expect(result.id).toBe(remoteID("github.com/owner/repo"))
  193. }),
  194. )
  195. it.live("does not write the cache while resolving", () =>
  196. Effect.gen(function* () {
  197. const tmp = yield* Effect.acquireRelease(
  198. Effect.promise(() => tmpdir()),
  199. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  200. )
  201. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  202. const project = yield* ProjectV2.Service
  203. yield* project.resolve(abs(tmp.path))
  204. expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
  205. }),
  206. )
  207. it.live("resolves from nested directories to repo root", () =>
  208. Effect.gen(function* () {
  209. const tmp = yield* Effect.acquireRelease(
  210. Effect.promise(() => tmpdir()),
  211. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  212. )
  213. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  214. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
  215. const project = yield* ProjectV2.Service
  216. const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
  217. expect(result.directory).toBe(yield* real(tmp.path))
  218. }),
  219. )
  220. it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
  221. Effect.gen(function* () {
  222. const tmp = yield* Effect.acquireRelease(
  223. Effect.promise(() => tmpdir()),
  224. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  225. )
  226. const worktree = `${tmp.path}-worktree`
  227. yield* Effect.addFinalizer(() =>
  228. Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
  229. )
  230. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  231. yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
  232. yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
  233. const project = yield* ProjectV2.Service
  234. const result = yield* project.resolve(abs(worktree))
  235. expect(result.directory).toBe(yield* real(worktree))
  236. expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
  237. expect(result.id).toBe(remoteID("github.com/owner/repo"))
  238. expect(result.vcs?.type).toBe("git")
  239. }),
  240. )
  241. })