project.test.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. import { describe, expect, test } from "bun:test"
  2. import { Bus } from "@/bus"
  3. import { Project } from "@/project/project"
  4. import * as Log from "@opencode-ai/core/util/log"
  5. import { $ } from "bun"
  6. import path from "path"
  7. import { tmpdirScoped } from "../fixture/fixture"
  8. import { GlobalBus } from "../../src/bus/global"
  9. import { ProjectID } from "../../src/project/schema"
  10. import { Cause, Effect, Exit, Layer, Stream } from "effect"
  11. import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
  12. import { NodePath } from "@effect/platform-node"
  13. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  14. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  15. import { testEffect } from "../lib/effect"
  16. void Log.init({ print: false })
  17. const encoder = new TextEncoder()
  18. const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)
  19. const it = testEffect(layer)
  20. function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
  21. return Effect.gen(function* () {
  22. const svc = yield* Project.Service
  23. return yield* fn(svc)
  24. })
  25. }
  26. function gitTmpdir() {
  27. return Effect.gen(function* () {
  28. const tmp = yield* tmpdirScoped()
  29. yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
  30. yield* Effect.promise(() => $`git config core.fsmonitor false`.cwd(tmp).quiet())
  31. yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet())
  32. yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet())
  33. yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet())
  34. yield* Effect.promise(() => $`git commit --allow-empty -m ${`root commit ${tmp}`}`.cwd(tmp).quiet())
  35. return tmp
  36. })
  37. }
  38. /**
  39. * Creates a mock ChildProcessSpawner layer that intercepts git subcommands
  40. * matching `failArg` and returns exit code 128, while delegating everything
  41. * else to the real CrossSpawnSpawner.
  42. */
  43. function mockGitFailure(failArg: string) {
  44. return Layer.effect(
  45. ChildProcessSpawner.ChildProcessSpawner,
  46. Effect.gen(function* () {
  47. const real = yield* ChildProcessSpawner.ChildProcessSpawner
  48. return ChildProcessSpawner.make(
  49. Effect.fnUntraced(function* (command) {
  50. const std = ChildProcess.isStandardCommand(command) ? command : undefined
  51. if (std?.command === "git" && std.args.some((a) => a === failArg)) {
  52. return ChildProcessSpawner.makeHandle({
  53. pid: ChildProcessSpawner.ProcessId(0),
  54. exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
  55. isRunning: Effect.succeed(false),
  56. kill: () => Effect.void,
  57. stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
  58. stdout: Stream.empty,
  59. stderr: Stream.make(encoder.encode("fatal: simulated failure\n")),
  60. all: Stream.empty,
  61. getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
  62. getOutputFd: () => Stream.empty,
  63. unref: Effect.succeed(Effect.void),
  64. })
  65. }
  66. return yield* real.spawn(command)
  67. }),
  68. )
  69. }),
  70. ).pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
  71. }
  72. function projectLayerWithFailure(failArg: string) {
  73. return Project.layer.pipe(
  74. Layer.provide(mockGitFailure(failArg)),
  75. Layer.provide(Bus.defaultLayer),
  76. Layer.provide(AppFileSystem.defaultLayer),
  77. Layer.provide(NodePath.layer),
  78. )
  79. }
  80. const failureIt = (failArg: string) =>
  81. testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer))
  82. describe("Project.fromDirectory", () => {
  83. it.live("should handle git repository with no commits", () =>
  84. Effect.gen(function* () {
  85. const tmp = yield* tmpdirScoped()
  86. yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
  87. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  88. expect(project).toBeDefined()
  89. expect(project.id).toBe(ProjectID.global)
  90. expect(project.vcs).toBe("git")
  91. expect(project.worktree).toBe(tmp)
  92. const opencodeFile = path.join(tmp, ".git", "opencode")
  93. expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false)
  94. }),
  95. )
  96. it.live("should handle git repository with commits", () =>
  97. Effect.gen(function* () {
  98. const tmp = yield* gitTmpdir()
  99. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  100. expect(project).toBeDefined()
  101. expect(project.id).not.toBe(ProjectID.global)
  102. expect(project.vcs).toBe("git")
  103. expect(project.worktree).toBe(tmp)
  104. const opencodeFile = path.join(tmp, ".git", "opencode")
  105. expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(true)
  106. }),
  107. )
  108. it.live("returns global for non-git directory", () =>
  109. Effect.gen(function* () {
  110. const tmp = yield* tmpdirScoped()
  111. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  112. expect(project.id).toBe(ProjectID.global)
  113. }),
  114. )
  115. it.live("derives stable project ID from root commit", () =>
  116. Effect.gen(function* () {
  117. const tmp = yield* gitTmpdir()
  118. const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
  119. const { project: b } = yield* run((svc) => svc.fromDirectory(tmp))
  120. expect(b.id).toBe(a.id)
  121. }),
  122. )
  123. })
  124. describe("Project.fromDirectory git failure paths", () => {
  125. it.live("keeps vcs when rev-list exits non-zero (no commits)", () =>
  126. Effect.gen(function* () {
  127. const tmp = yield* tmpdirScoped()
  128. yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
  129. // rev-list fails because HEAD doesn't exist yet: this is the natural scenario.
  130. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  131. expect(project.vcs).toBe("git")
  132. expect(project.id).toBe(ProjectID.global)
  133. expect(project.worktree).toBe(tmp)
  134. }),
  135. )
  136. failureIt("--show-toplevel").live("handles show-toplevel failure gracefully", () =>
  137. Effect.gen(function* () {
  138. const tmp = yield* gitTmpdir()
  139. const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
  140. expect(project.worktree).toBe(tmp)
  141. expect(sandbox).toBe(tmp)
  142. }),
  143. )
  144. failureIt("--git-common-dir").live("handles git-common-dir failure gracefully", () =>
  145. Effect.gen(function* () {
  146. const tmp = yield* gitTmpdir()
  147. const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
  148. expect(project.worktree).toBe(tmp)
  149. expect(sandbox).toBe(tmp)
  150. }),
  151. )
  152. })
  153. describe("Project.fromDirectory with worktrees", () => {
  154. it.live("should set worktree to root when called from root", () =>
  155. Effect.gen(function* () {
  156. const tmp = yield* gitTmpdir()
  157. const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
  158. expect(project.worktree).toBe(tmp)
  159. expect(sandbox).toBe(tmp)
  160. expect(project.sandboxes).not.toContain(tmp)
  161. }),
  162. )
  163. it.live("should set worktree to root when called from a worktree", () =>
  164. Effect.gen(function* () {
  165. const tmp = yield* gitTmpdir()
  166. const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-worktree")
  167. yield* Effect.addFinalizer(() =>
  168. Effect.promise(() =>
  169. $`git worktree remove ${worktreePath}`
  170. .cwd(tmp)
  171. .quiet()
  172. .catch(() => {}),
  173. ),
  174. )
  175. yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet())
  176. const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath))
  177. expect(project.worktree).toBe(tmp)
  178. expect(sandbox).toBe(worktreePath)
  179. expect(project.sandboxes).toContain(worktreePath)
  180. expect(project.sandboxes).not.toContain(tmp)
  181. }),
  182. )
  183. it.live("worktree should share project ID with main repo", () =>
  184. Effect.gen(function* () {
  185. const tmp = yield* gitTmpdir()
  186. const { project: main } = yield* run((svc) => svc.fromDirectory(tmp))
  187. const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared")
  188. yield* Effect.addFinalizer(() =>
  189. Effect.promise(() =>
  190. $`git worktree remove ${worktreePath}`
  191. .cwd(tmp)
  192. .quiet()
  193. .catch(() => {}),
  194. ),
  195. )
  196. yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet())
  197. const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath))
  198. expect(wt.id).toBe(main.id)
  199. // Cache should live in the common .git dir, not the worktree's .git file
  200. const cache = path.join(tmp, ".git", "opencode")
  201. const exists = yield* Effect.promise(() => Bun.file(cache).exists())
  202. expect(exists).toBe(true)
  203. }),
  204. )
  205. it.live("separate clones of the same repo should share project ID", () =>
  206. Effect.gen(function* () {
  207. const tmp = yield* gitTmpdir()
  208. // Create a bare remote, push, then clone into a second directory
  209. const bare = tmp + "-bare"
  210. const clone = tmp + "-clone"
  211. yield* Effect.addFinalizer(() =>
  212. Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore),
  213. )
  214. yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
  215. yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
  216. const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
  217. const { project: b } = yield* run((svc) => svc.fromDirectory(clone))
  218. expect(b.id).toBe(a.id)
  219. }),
  220. )
  221. it.live("should accumulate multiple worktrees in sandboxes", () =>
  222. Effect.gen(function* () {
  223. const tmp = yield* gitTmpdir()
  224. const worktree1 = path.join(tmp, "..", path.basename(tmp) + "-wt1")
  225. const worktree2 = path.join(tmp, "..", path.basename(tmp) + "-wt2")
  226. yield* Effect.addFinalizer(() =>
  227. Effect.gen(function* () {
  228. yield* Effect.promise(() =>
  229. $`git worktree remove ${worktree1}`
  230. .cwd(tmp)
  231. .quiet()
  232. .catch(() => {}),
  233. )
  234. yield* Effect.promise(() =>
  235. $`git worktree remove ${worktree2}`
  236. .cwd(tmp)
  237. .quiet()
  238. .catch(() => {}),
  239. )
  240. }),
  241. )
  242. yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet())
  243. yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet())
  244. yield* run((svc) => svc.fromDirectory(worktree1))
  245. const { project } = yield* run((svc) => svc.fromDirectory(worktree2))
  246. expect(project.worktree).toBe(tmp)
  247. expect(project.sandboxes).toContain(worktree1)
  248. expect(project.sandboxes).toContain(worktree2)
  249. expect(project.sandboxes).not.toContain(tmp)
  250. }),
  251. )
  252. })
  253. describe("Project.discover", () => {
  254. it.live("should discover favicon.png in root", () =>
  255. Effect.gen(function* () {
  256. const tmp = yield* gitTmpdir()
  257. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  258. const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
  259. yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
  260. yield* run((svc) => svc.discover(project))
  261. const updated = Project.get(project.id)
  262. expect(updated).toBeDefined()
  263. expect(updated!.icon).toBeDefined()
  264. expect(updated!.icon?.url).toStartWith("data:")
  265. expect(updated!.icon?.url).toContain("base64")
  266. expect(updated!.icon?.color).toBeUndefined()
  267. }),
  268. )
  269. it.live("should not discover non-image files", () =>
  270. Effect.gen(function* () {
  271. const tmp = yield* gitTmpdir()
  272. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  273. yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image"))
  274. yield* run((svc) => svc.discover(project))
  275. const updated = Project.get(project.id)
  276. expect(updated).toBeDefined()
  277. expect(updated!.icon).toBeUndefined()
  278. }),
  279. )
  280. it.live("should not discover favicon when override is set", () =>
  281. Effect.gen(function* () {
  282. const tmp = yield* gitTmpdir()
  283. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  284. yield* run((svc) =>
  285. svc.update({
  286. projectID: project.id,
  287. icon: { override: "data:image/png;base64,override" },
  288. }),
  289. )
  290. const updatedProject = yield* run((svc) => svc.get(project.id))
  291. if (!updatedProject) throw new Error("Project not found")
  292. const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
  293. yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
  294. yield* run((svc) => svc.discover(updatedProject))
  295. const updated = Project.get(project.id)
  296. expect(updated).toBeDefined()
  297. expect(updated!.icon?.override).toBe("data:image/png;base64,override")
  298. expect(updated!.icon?.url).toBeUndefined()
  299. }),
  300. )
  301. })
  302. describe("Project.update", () => {
  303. it.live("should update name", () =>
  304. Effect.gen(function* () {
  305. const tmp = yield* gitTmpdir()
  306. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  307. const updated = yield* run((svc) =>
  308. svc.update({
  309. projectID: project.id,
  310. name: "New Project Name",
  311. }),
  312. )
  313. expect(updated.name).toBe("New Project Name")
  314. const fromDb = Project.get(project.id)
  315. expect(fromDb?.name).toBe("New Project Name")
  316. }),
  317. )
  318. it.live("should update icon url", () =>
  319. Effect.gen(function* () {
  320. const tmp = yield* gitTmpdir()
  321. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  322. const updated = yield* run((svc) =>
  323. svc.update({
  324. projectID: project.id,
  325. icon: { url: "https://example.com/icon.png" },
  326. }),
  327. )
  328. expect(updated.icon?.url).toBe("https://example.com/icon.png")
  329. const fromDb = Project.get(project.id)
  330. expect(fromDb?.icon?.url).toBe("https://example.com/icon.png")
  331. }),
  332. )
  333. it.live("should update icon color", () =>
  334. Effect.gen(function* () {
  335. const tmp = yield* gitTmpdir()
  336. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  337. const updated = yield* run((svc) =>
  338. svc.update({
  339. projectID: project.id,
  340. icon: { color: "#ff0000" },
  341. }),
  342. )
  343. expect(updated.icon?.color).toBe("#ff0000")
  344. const fromDb = Project.get(project.id)
  345. expect(fromDb?.icon?.color).toBe("#ff0000")
  346. }),
  347. )
  348. it.live("should update icon override", () =>
  349. Effect.gen(function* () {
  350. const tmp = yield* gitTmpdir()
  351. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  352. const updated = yield* run((svc) =>
  353. svc.update({
  354. projectID: project.id,
  355. icon: { override: "data:image/png;base64,abc123" },
  356. }),
  357. )
  358. expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
  359. const fromDb = Project.get(project.id)
  360. expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123")
  361. }),
  362. )
  363. it.live("should update commands", () =>
  364. Effect.gen(function* () {
  365. const tmp = yield* gitTmpdir()
  366. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  367. const updated = yield* run((svc) =>
  368. svc.update({
  369. projectID: project.id,
  370. commands: { start: "npm run dev" },
  371. }),
  372. )
  373. expect(updated.commands?.start).toBe("npm run dev")
  374. const fromDb = Project.get(project.id)
  375. expect(fromDb?.commands?.start).toBe("npm run dev")
  376. }),
  377. )
  378. it.live("should throw error when project not found", () =>
  379. Effect.gen(function* () {
  380. const exit = yield* run((svc) =>
  381. svc.update({
  382. projectID: ProjectID.make("nonexistent-project-id"),
  383. name: "Should Fail",
  384. }),
  385. ).pipe(Effect.exit)
  386. expect(Exit.isFailure(exit)).toBe(true)
  387. if (Exit.isFailure(exit)) {
  388. const error = Cause.squash(exit.cause)
  389. expect(error instanceof Error ? error.message : String(error)).toContain(
  390. "Project not found: nonexistent-project-id",
  391. )
  392. }
  393. }),
  394. )
  395. it.live("should emit GlobalBus event on update", () =>
  396. Effect.gen(function* () {
  397. const tmp = yield* gitTmpdir()
  398. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  399. let eventPayload: any = null
  400. const on = (data: any) => {
  401. eventPayload = data
  402. }
  403. GlobalBus.on("event", on)
  404. yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
  405. yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
  406. expect(eventPayload).not.toBeNull()
  407. expect(eventPayload.payload.type).toBe("project.updated")
  408. expect(eventPayload.payload.properties.name).toBe("Updated Name")
  409. }),
  410. )
  411. it.live("should update multiple fields at once", () =>
  412. Effect.gen(function* () {
  413. const tmp = yield* gitTmpdir()
  414. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  415. const updated = yield* run((svc) =>
  416. svc.update({
  417. projectID: project.id,
  418. name: "Multi Update",
  419. icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
  420. commands: { start: "make start" },
  421. }),
  422. )
  423. expect(updated.name).toBe("Multi Update")
  424. expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
  425. expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
  426. expect(updated.icon?.color).toBe("#00ff00")
  427. expect(updated.commands?.start).toBe("make start")
  428. }),
  429. )
  430. })
  431. describe("Project.list and Project.get", () => {
  432. it.live("list returns all projects", () =>
  433. Effect.gen(function* () {
  434. const tmp = yield* gitTmpdir()
  435. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  436. const all = Project.list()
  437. expect(all.length).toBeGreaterThan(0)
  438. expect(all.find((p) => p.id === project.id)).toBeDefined()
  439. }),
  440. )
  441. it.live("get returns project by id", () =>
  442. Effect.gen(function* () {
  443. const tmp = yield* gitTmpdir()
  444. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  445. const found = Project.get(project.id)
  446. expect(found).toBeDefined()
  447. expect(found!.id).toBe(project.id)
  448. }),
  449. )
  450. test("get returns undefined for unknown id", () => {
  451. const found = Project.get(ProjectID.make("nonexistent"))
  452. expect(found).toBeUndefined()
  453. })
  454. })
  455. describe("Project.setInitialized", () => {
  456. it.live("sets time_initialized on project", () =>
  457. Effect.gen(function* () {
  458. const tmp = yield* gitTmpdir()
  459. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  460. expect(project.time.initialized).toBeUndefined()
  461. Project.setInitialized(project.id)
  462. const updated = Project.get(project.id)
  463. expect(updated?.time.initialized).toBeDefined()
  464. }),
  465. )
  466. })
  467. describe("Project.addSandbox and Project.removeSandbox", () => {
  468. it.live("addSandbox adds directory and removeSandbox removes it", () =>
  469. Effect.gen(function* () {
  470. const tmp = yield* gitTmpdir()
  471. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  472. const sandboxDir = path.join(tmp, "sandbox-test")
  473. yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
  474. let found = Project.get(project.id)
  475. expect(found?.sandboxes).toContain(sandboxDir)
  476. yield* run((svc) => svc.removeSandbox(project.id, sandboxDir))
  477. found = Project.get(project.id)
  478. expect(found?.sandboxes).not.toContain(sandboxDir)
  479. }),
  480. )
  481. it.live("addSandbox emits GlobalBus event", () =>
  482. Effect.gen(function* () {
  483. const tmp = yield* gitTmpdir()
  484. const { project } = yield* run((svc) => svc.fromDirectory(tmp))
  485. const sandboxDir = path.join(tmp, "sandbox-event")
  486. const events: any[] = []
  487. const on = (evt: any) => events.push(evt)
  488. GlobalBus.on("event", on)
  489. yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
  490. yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
  491. expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
  492. }),
  493. )
  494. })
  495. describe("Project.fromDirectory with bare repos", () => {
  496. it.live("worktree from bare repo should cache in bare repo, not parent", () =>
  497. Effect.gen(function* () {
  498. const tmp = yield* gitTmpdir()
  499. const parentDir = path.dirname(tmp)
  500. const barePath = path.join(parentDir, `bare-${Date.now()}.git`)
  501. const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
  502. yield* Effect.addFinalizer(() =>
  503. Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore),
  504. )
  505. yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
  506. yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
  507. const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
  508. expect(project.id).not.toBe(ProjectID.global)
  509. expect(project.worktree).toBe(barePath)
  510. const correctCache = path.join(barePath, "opencode")
  511. const wrongCache = path.join(parentDir, ".git", "opencode")
  512. expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
  513. expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
  514. }),
  515. )
  516. it.live("different bare repos under same parent should not share project ID", () =>
  517. Effect.gen(function* () {
  518. const tmp1 = yield* gitTmpdir()
  519. const tmp2 = yield* gitTmpdir()
  520. const parentDir = path.dirname(tmp1)
  521. const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`)
  522. const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`)
  523. const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`)
  524. const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`)
  525. yield* Effect.addFinalizer(() =>
  526. Effect.promise(() => $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow()).pipe(
  527. Effect.ignore,
  528. ),
  529. )
  530. yield* Effect.promise(() => $`git clone --bare ${tmp1} ${bareA}`.quiet())
  531. yield* Effect.promise(() => $`git clone --bare ${tmp2} ${bareB}`.quiet())
  532. yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet())
  533. yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet())
  534. const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA))
  535. const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB))
  536. expect(projA.id).not.toBe(projB.id)
  537. const cacheA = path.join(bareA, "opencode")
  538. const cacheB = path.join(bareB, "opencode")
  539. const wrongCache = path.join(parentDir, ".git", "opencode")
  540. expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true)
  541. expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true)
  542. expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
  543. }),
  544. )
  545. it.live("bare repo without .git suffix is still detected via core.bare", () =>
  546. Effect.gen(function* () {
  547. const tmp = yield* gitTmpdir()
  548. const parentDir = path.dirname(tmp)
  549. const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`)
  550. const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
  551. yield* Effect.addFinalizer(() =>
  552. Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore),
  553. )
  554. yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
  555. yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
  556. const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
  557. expect(project.id).not.toBe(ProjectID.global)
  558. expect(project.worktree).toBe(barePath)
  559. const correctCache = path.join(barePath, "opencode")
  560. expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
  561. }),
  562. )
  563. })