project.test.ts 14 KB

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