project.test.ts 9.1 KB

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