project.test.ts 29 KB

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