project.test.ts 25 KB

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