project.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  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 { Project } 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(Project.node), AppNodeBuilder.build(Database.node)))
  15. describe("Project.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* Project.Service
  20. yield* db
  21. .insert(ProjectTable)
  22. .values([
  23. {
  24. id: Project.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: Project.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: Project.ID.make("newer"),
  47. canonical: abs("/newer"),
  48. time: { created: 2, updated: 2, initialized: 3 },
  49. sandboxes: [],
  50. },
  51. {
  52. id: Project.ID.make("older"),
  53. canonical: 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 Project.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("Project.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* Project.Service
  94. const result = yield* project.resolve(abs(tmp.path))
  95. expect(result.id).toBe(Project.ID.make("global"))
  96. expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
  97. expect(result.canonical).toBe(result.directory)
  98. expect(result.previous).toBeUndefined()
  99. expect(result.vcs).toBeUndefined()
  100. }),
  101. )
  102. it.live("returns git global for repo with no commits and no remote", () =>
  103. Effect.gen(function* () {
  104. const tmp = yield* Effect.acquireRelease(
  105. Effect.promise(() => tmpdir()),
  106. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  107. )
  108. yield* Effect.promise(() => initRepo(tmp.path))
  109. const project = yield* Project.Service
  110. const result = yield* project.resolve(abs(tmp.path))
  111. expect(result.id).toBe(Project.ID.make("global"))
  112. expect(result.directory).toBe(yield* real(tmp.path))
  113. expect(result.canonical).toBe(result.directory)
  114. expect(result.previous).toBeUndefined()
  115. expect(result.vcs?.type).toBe("git")
  116. }),
  117. )
  118. it.live("falls back to root commit when origin is missing", () =>
  119. Effect.gen(function* () {
  120. const tmp = yield* Effect.acquireRelease(
  121. Effect.promise(() => tmpdir()),
  122. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  123. )
  124. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  125. const project = yield* Project.Service
  126. const result = yield* project.resolve(abs(tmp.path))
  127. expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  128. expect(result.directory).toBe(yield* real(tmp.path))
  129. expect(result.canonical).toBe(result.directory)
  130. expect(result.previous).toBeUndefined()
  131. expect(result.vcs?.type).toBe("git")
  132. }),
  133. )
  134. it.live("prefers normalized origin over root commit", () =>
  135. Effect.gen(function* () {
  136. const tmp = yield* Effect.acquireRelease(
  137. Effect.promise(() => tmpdir()),
  138. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  139. )
  140. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
  141. const project = yield* Project.Service
  142. const result = yield* project.resolve(abs(tmp.path))
  143. expect(result.id).toBe(remoteID("github.com/Acme/App"))
  144. expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  145. expect(result.directory).toBe(yield* real(tmp.path))
  146. expect(result.vcs?.type).toBe("git")
  147. }),
  148. )
  149. it.live("normalizes ssh and https remotes to the same id", () =>
  150. Effect.gen(function* () {
  151. const ssh = yield* Effect.acquireRelease(
  152. Effect.promise(() => tmpdir()),
  153. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  154. )
  155. const https = yield* Effect.acquireRelease(
  156. Effect.promise(() => tmpdir()),
  157. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  158. )
  159. yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  160. yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
  161. const project = yield* Project.Service
  162. const a = yield* project.resolve(abs(ssh.path))
  163. const b = yield* project.resolve(abs(https.path))
  164. expect(a.id).toBe(remoteID("github.com/owner/repo"))
  165. expect(b.id).toBe(a.id)
  166. }),
  167. )
  168. it.live("ignores file remotes and falls back to root commit", () =>
  169. Effect.gen(function* () {
  170. const tmp = yield* Effect.acquireRelease(
  171. Effect.promise(() => tmpdir()),
  172. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  173. )
  174. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
  175. const project = yield* Project.Service
  176. const result = yield* project.resolve(abs(tmp.path))
  177. expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  178. }),
  179. )
  180. it.live("returns previous cached id 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. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  187. yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
  188. const project = yield* Project.Service
  189. const result = yield* project.resolve(abs(tmp.path))
  190. expect(result.previous).toBe(Project.ID.make("old-id"))
  191. expect(result.id).toBe(remoteID("github.com/owner/repo"))
  192. }),
  193. )
  194. it.live("does not write the cache while resolving", () =>
  195. Effect.gen(function* () {
  196. const tmp = yield* Effect.acquireRelease(
  197. Effect.promise(() => tmpdir()),
  198. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  199. )
  200. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  201. const project = yield* Project.Service
  202. yield* project.resolve(abs(tmp.path))
  203. expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
  204. }),
  205. )
  206. it.live("resolves from nested directories to repo root", () =>
  207. Effect.gen(function* () {
  208. const tmp = yield* Effect.acquireRelease(
  209. Effect.promise(() => tmpdir()),
  210. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  211. )
  212. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  213. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
  214. const project = yield* Project.Service
  215. const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
  216. expect(result.directory).toBe(yield* real(tmp.path))
  217. }),
  218. )
  219. const itHg = Bun.which("hg") ? it : { live: it.live.skip }
  220. itHg.live("detects mercurial repositories from nested directories", () =>
  221. Effect.gen(function* () {
  222. const tmp = yield* Effect.acquireRelease(
  223. Effect.promise(() => tmpdir()),
  224. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  225. )
  226. yield* Effect.promise(async () => {
  227. await $`hg init`.cwd(tmp.path).quiet()
  228. await Bun.write(path.join(tmp.path, "file.txt"), "one\n")
  229. await $`hg addremove -q`
  230. .cwd(tmp.path)
  231. .env({ ...process.env, HGPLAIN: "1" })
  232. .quiet()
  233. await $`hg commit -q -m initial -u test`
  234. .cwd(tmp.path)
  235. .env({ ...process.env, HGPLAIN: "1" })
  236. .quiet()
  237. await fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true })
  238. })
  239. const project = yield* Project.Service
  240. const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
  241. expect(result.vcs?.type).toBe("hg")
  242. expect(result.directory).toBe(abs(tmp.path))
  243. expect(result.id).not.toBe(Project.ID.make("global"))
  244. expect(result.previous).toBeUndefined()
  245. }),
  246. )
  247. it.live("prefers git when both git and mercurial metadata exist", () =>
  248. Effect.gen(function* () {
  249. const tmp = yield* Effect.acquireRelease(
  250. Effect.promise(() => tmpdir()),
  251. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  252. )
  253. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  254. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".hg")))
  255. const project = yield* Project.Service
  256. const result = yield* project.resolve(abs(tmp.path))
  257. expect(result.vcs?.type).toBe("git")
  258. }),
  259. )
  260. it.live("returns global id for unreadable mercurial metadata", () =>
  261. Effect.gen(function* () {
  262. const tmp = yield* Effect.acquireRelease(
  263. Effect.promise(() => tmpdir()),
  264. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  265. )
  266. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".hg")))
  267. const project = yield* Project.Service
  268. const result = yield* project.resolve(abs(tmp.path))
  269. expect(result.vcs?.type).toBe("hg")
  270. expect(result.id).toBe(Project.ID.make("global"))
  271. }),
  272. )
  273. it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
  274. Effect.gen(function* () {
  275. const tmp = yield* Effect.acquireRelease(
  276. Effect.promise(() => tmpdir()),
  277. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  278. )
  279. const worktree = `${tmp.path}-worktree`
  280. yield* Effect.addFinalizer(() =>
  281. Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
  282. )
  283. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  284. yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
  285. yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
  286. const project = yield* Project.Service
  287. const db = (yield* Database.Service).db
  288. const id = remoteID("github.com/owner/repo")
  289. yield* db
  290. .insert(ProjectTable)
  291. .values({
  292. id,
  293. worktree: abs("/stale-worktree"),
  294. vcs: "hg",
  295. name: "Preserved name",
  296. icon_color: "#123456",
  297. commands: { start: "bun dev" },
  298. sandboxes: [abs("/preserved-sandbox")],
  299. time_created: 1,
  300. time_updated: 1,
  301. time_initialized: 2,
  302. })
  303. .run()
  304. const result = yield* project.resolve(abs(worktree))
  305. expect(result.directory).toBe(yield* real(worktree))
  306. expect(result.canonical).toBe(yield* real(tmp.path))
  307. expect(result.previous).toBe(Project.ID.make("old-id"))
  308. expect(result.id).toBe(id)
  309. expect(result.vcs?.type).toBe("git")
  310. expect((yield* project.list()).find((item) => item.id === id)).toMatchObject({
  311. canonical: yield* real(tmp.path),
  312. vcs: "git",
  313. name: "Preserved name",
  314. icon: { color: "#123456" },
  315. commands: { start: "bun dev" },
  316. sandboxes: [abs("/preserved-sandbox")],
  317. time: { created: 1, initialized: 2 },
  318. })
  319. expect(
  320. (yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
  321. ).toEqual([
  322. { directory: yield* real(tmp.path) },
  323. { directory: yield* real(worktree), strategy: "git_worktree" },
  324. ])
  325. }),
  326. )
  327. })