project.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  7. import { Database } from "@opencode-ai/core/database/database"
  8. import { ProjectV2 } from "@opencode-ai/core/project"
  9. import { ProjectTable } from "@opencode-ai/core/project/sql"
  10. import { AbsolutePath } from "@opencode-ai/core/schema"
  11. import { Hash } from "@opencode-ai/util/hash"
  12. import { tmpdir } from "./fixture/tmpdir"
  13. import { testEffect } from "./lib/effect"
  14. const it = testEffect(Layer.merge(AppNodeBuilder.build(ProjectV2.node), AppNodeBuilder.build(Database.node)))
  15. describe("ProjectV2.list", () => {
  16. it.effect("returns complete projects ordered by recent update", () =>
  17. Effect.gen(function* () {
  18. const db = (yield* Database.Service).db
  19. const project = yield* ProjectV2.Service
  20. yield* db
  21. .insert(ProjectTable)
  22. .values([
  23. {
  24. id: ProjectV2.ID.make("older"),
  25. worktree: abs("/older"),
  26. vcs: "git",
  27. name: "Older",
  28. icon_color: "#000000",
  29. commands: { start: "bun dev" },
  30. sandboxes: [abs("/older/sandbox")],
  31. time_created: 1,
  32. time_updated: 1,
  33. },
  34. {
  35. id: ProjectV2.ID.make("newer"),
  36. worktree: abs("/newer"),
  37. sandboxes: [],
  38. time_created: 2,
  39. time_updated: 2,
  40. time_initialized: 3,
  41. },
  42. ])
  43. .run()
  44. expect(yield* project.list()).toEqual([
  45. {
  46. id: ProjectV2.ID.make("newer"),
  47. worktree: abs("/newer"),
  48. time: { created: 2, updated: 2, initialized: 3 },
  49. sandboxes: [],
  50. },
  51. {
  52. id: ProjectV2.ID.make("older"),
  53. worktree: abs("/older"),
  54. vcs: "git",
  55. name: "Older",
  56. icon: { color: "#000000" },
  57. commands: { start: "bun dev" },
  58. time: { created: 1, updated: 1 },
  59. sandboxes: [abs("/older/sandbox")],
  60. },
  61. ])
  62. }),
  63. )
  64. })
  65. function remoteID(remote: string) {
  66. return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
  67. }
  68. function abs(value: string) {
  69. return AbsolutePath.make(value)
  70. }
  71. function real(value: string) {
  72. return Effect.promise(() => fs.realpath(value)).pipe(Effect.map((value) => AbsolutePath.make(value)))
  73. }
  74. async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) {
  75. await $`git init`.cwd(dir).quiet()
  76. await $`git config core.fsmonitor false`.cwd(dir).quiet()
  77. await $`git config commit.gpgsign false`.cwd(dir).quiet()
  78. await $`git config user.email test@opencode.test`.cwd(dir).quiet()
  79. await $`git config user.name Test`.cwd(dir).quiet()
  80. if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet()
  81. if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
  82. }
  83. async function rootCommit(dir: string) {
  84. return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
  85. }
  86. describe("ProjectV2.resolve", () => {
  87. it.live("returns global for non-git directory", () =>
  88. Effect.gen(function* () {
  89. const tmp = yield* Effect.acquireRelease(
  90. Effect.promise(() => tmpdir()),
  91. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  92. )
  93. const project = yield* ProjectV2.Service
  94. const result = yield* project.resolve(abs(tmp.path))
  95. expect(result.id).toBe(ProjectV2.ID.make("global"))
  96. expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
  97. expect(result.previous).toBeUndefined()
  98. expect(result.vcs).toBeUndefined()
  99. }),
  100. )
  101. it.live("returns git global for repo with no commits and no remote", () =>
  102. Effect.gen(function* () {
  103. const tmp = yield* Effect.acquireRelease(
  104. Effect.promise(() => tmpdir()),
  105. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  106. )
  107. yield* Effect.promise(() => initRepo(tmp.path))
  108. const project = yield* ProjectV2.Service
  109. const result = yield* project.resolve(abs(tmp.path))
  110. expect(result.id).toBe(ProjectV2.ID.make("global"))
  111. expect(result.directory).toBe(yield* real(tmp.path))
  112. expect(result.previous).toBeUndefined()
  113. expect(result.vcs?.type).toBe("git")
  114. }),
  115. )
  116. it.live("falls back to root commit when origin is missing", () =>
  117. Effect.gen(function* () {
  118. const tmp = yield* Effect.acquireRelease(
  119. Effect.promise(() => tmpdir()),
  120. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  121. )
  122. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  123. const project = yield* ProjectV2.Service
  124. const result = yield* project.resolve(abs(tmp.path))
  125. expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  126. expect(result.directory).toBe(yield* real(tmp.path))
  127. expect(result.previous).toBeUndefined()
  128. expect(result.vcs?.type).toBe("git")
  129. }),
  130. )
  131. it.live("prefers normalized origin over root commit", () =>
  132. Effect.gen(function* () {
  133. const tmp = yield* Effect.acquireRelease(
  134. Effect.promise(() => tmpdir()),
  135. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  136. )
  137. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
  138. const project = yield* ProjectV2.Service
  139. const result = yield* project.resolve(abs(tmp.path))
  140. expect(result.id).toBe(remoteID("github.com/Acme/App"))
  141. expect(result.id).not.toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  142. expect(result.directory).toBe(yield* real(tmp.path))
  143. expect(result.vcs?.type).toBe("git")
  144. }),
  145. )
  146. it.live("normalizes ssh and https remotes to the same id", () =>
  147. Effect.gen(function* () {
  148. const ssh = yield* Effect.acquireRelease(
  149. Effect.promise(() => tmpdir()),
  150. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  151. )
  152. const https = yield* Effect.acquireRelease(
  153. Effect.promise(() => tmpdir()),
  154. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  155. )
  156. yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  157. yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
  158. const project = yield* ProjectV2.Service
  159. const a = yield* project.resolve(abs(ssh.path))
  160. const b = yield* project.resolve(abs(https.path))
  161. expect(a.id).toBe(remoteID("github.com/owner/repo"))
  162. expect(b.id).toBe(a.id)
  163. }),
  164. )
  165. it.live("ignores file remotes and falls back to root commit", () =>
  166. Effect.gen(function* () {
  167. const tmp = yield* Effect.acquireRelease(
  168. Effect.promise(() => tmpdir()),
  169. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  170. )
  171. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
  172. const project = yield* ProjectV2.Service
  173. const result = yield* project.resolve(abs(tmp.path))
  174. expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  175. }),
  176. )
  177. it.live("returns previous cached id from common dir", () =>
  178. Effect.gen(function* () {
  179. const tmp = yield* Effect.acquireRelease(
  180. Effect.promise(() => tmpdir()),
  181. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  182. )
  183. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  184. yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
  185. const project = yield* ProjectV2.Service
  186. const result = yield* project.resolve(abs(tmp.path))
  187. expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
  188. expect(result.id).toBe(remoteID("github.com/owner/repo"))
  189. }),
  190. )
  191. it.live("does not write the cache while resolving", () =>
  192. Effect.gen(function* () {
  193. const tmp = yield* Effect.acquireRelease(
  194. Effect.promise(() => tmpdir()),
  195. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  196. )
  197. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  198. const project = yield* ProjectV2.Service
  199. yield* project.resolve(abs(tmp.path))
  200. expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
  201. }),
  202. )
  203. it.live("resolves from nested directories to repo root", () =>
  204. Effect.gen(function* () {
  205. const tmp = yield* Effect.acquireRelease(
  206. Effect.promise(() => tmpdir()),
  207. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  208. )
  209. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  210. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
  211. const project = yield* ProjectV2.Service
  212. const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
  213. expect(result.directory).toBe(yield* real(tmp.path))
  214. }),
  215. )
  216. const itHg = Bun.which("hg") ? it : { live: it.live.skip }
  217. itHg.live("detects mercurial repositories from nested directories", () =>
  218. Effect.gen(function* () {
  219. const tmp = yield* Effect.acquireRelease(
  220. Effect.promise(() => tmpdir()),
  221. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  222. )
  223. yield* Effect.promise(async () => {
  224. await $`hg init`.cwd(tmp.path).quiet()
  225. await Bun.write(path.join(tmp.path, "file.txt"), "one\n")
  226. await $`hg addremove -q`
  227. .cwd(tmp.path)
  228. .env({ ...process.env, HGPLAIN: "1" })
  229. .quiet()
  230. await $`hg commit -q -m initial -u test`
  231. .cwd(tmp.path)
  232. .env({ ...process.env, HGPLAIN: "1" })
  233. .quiet()
  234. await fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true })
  235. })
  236. const project = yield* ProjectV2.Service
  237. const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
  238. expect(result.vcs?.type).toBe("hg")
  239. expect(result.directory).toBe(abs(tmp.path))
  240. expect(result.id).not.toBe(ProjectV2.ID.make("global"))
  241. expect(result.previous).toBeUndefined()
  242. }),
  243. )
  244. it.live("prefers git when both git and mercurial metadata exist", () =>
  245. Effect.gen(function* () {
  246. const tmp = yield* Effect.acquireRelease(
  247. Effect.promise(() => tmpdir()),
  248. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  249. )
  250. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  251. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".hg")))
  252. const project = yield* ProjectV2.Service
  253. const result = yield* project.resolve(abs(tmp.path))
  254. expect(result.vcs?.type).toBe("git")
  255. }),
  256. )
  257. it.live("returns global id for unreadable mercurial metadata", () =>
  258. Effect.gen(function* () {
  259. const tmp = yield* Effect.acquireRelease(
  260. Effect.promise(() => tmpdir()),
  261. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  262. )
  263. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".hg")))
  264. const project = yield* ProjectV2.Service
  265. const result = yield* project.resolve(abs(tmp.path))
  266. expect(result.vcs?.type).toBe("hg")
  267. expect(result.id).toBe(ProjectV2.ID.make("global"))
  268. }),
  269. )
  270. it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
  271. Effect.gen(function* () {
  272. const tmp = yield* Effect.acquireRelease(
  273. Effect.promise(() => tmpdir()),
  274. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  275. )
  276. const worktree = `${tmp.path}-worktree`
  277. yield* Effect.addFinalizer(() =>
  278. Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
  279. )
  280. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  281. yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
  282. yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
  283. const project = yield* ProjectV2.Service
  284. const result = yield* project.resolve(abs(worktree))
  285. expect(result.directory).toBe(yield* real(worktree))
  286. expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
  287. expect(result.id).toBe(remoteID("github.com/owner/repo"))
  288. expect(result.vcs?.type).toBe("git")
  289. }),
  290. )
  291. })