worktree.test.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import { $ } from "bun"
  2. import { afterEach, describe, expect } from "bun:test"
  3. import * as fs from "fs/promises"
  4. import path from "path"
  5. import { Cause, Effect, Exit, Layer } from "effect"
  6. import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
  7. import { Instance } from "../../src/project/instance"
  8. import { Worktree } from "../../src/worktree"
  9. import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
  10. import { testEffect } from "../lib/effect"
  11. const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
  12. const wintest = process.platform !== "win32" ? it.live : it.live.skip
  13. function normalize(input: string) {
  14. return input.replace(/\\/g, "/").toLowerCase()
  15. }
  16. async function waitReady() {
  17. const { GlobalBus } = await import("../../src/bus/global")
  18. return await new Promise<{ name: string; branch: string }>((resolve, reject) => {
  19. const timer = setTimeout(() => {
  20. GlobalBus.off("event", on)
  21. reject(new Error("timed out waiting for worktree.ready"))
  22. }, 10_000)
  23. function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch: string } } }) {
  24. if (evt.payload.type !== Worktree.Event.Ready.type) return
  25. clearTimeout(timer)
  26. GlobalBus.off("event", on)
  27. resolve(evt.payload.properties)
  28. }
  29. GlobalBus.on("event", on)
  30. })
  31. }
  32. describe("Worktree", () => {
  33. afterEach(() => Instance.disposeAll())
  34. describe("makeWorktreeInfo", () => {
  35. it.live("returns info with name, branch, and directory", () =>
  36. provideTmpdirInstance(
  37. () =>
  38. Effect.gen(function* () {
  39. const svc = yield* Worktree.Service
  40. const info = yield* svc.makeWorktreeInfo()
  41. expect(info.name).toBeDefined()
  42. expect(typeof info.name).toBe("string")
  43. expect(info.branch).toBe(`opencode/${info.name}`)
  44. expect(info.directory).toContain(info.name)
  45. }),
  46. { git: true },
  47. ),
  48. )
  49. it.live("uses provided name as base", () =>
  50. provideTmpdirInstance(
  51. () =>
  52. Effect.gen(function* () {
  53. const svc = yield* Worktree.Service
  54. const info = yield* svc.makeWorktreeInfo("my-feature")
  55. expect(info.name).toBe("my-feature")
  56. expect(info.branch).toBe("opencode/my-feature")
  57. }),
  58. { git: true },
  59. ),
  60. )
  61. it.live("slugifies the provided name", () =>
  62. provideTmpdirInstance(
  63. () =>
  64. Effect.gen(function* () {
  65. const svc = yield* Worktree.Service
  66. const info = yield* svc.makeWorktreeInfo("My Feature Branch!")
  67. expect(info.name).toBe("my-feature-branch")
  68. }),
  69. { git: true },
  70. ),
  71. )
  72. it.live("throws NotGitError for non-git directories", () =>
  73. provideTmpdirInstance(() =>
  74. Effect.gen(function* () {
  75. const svc = yield* Worktree.Service
  76. const exit = yield* Effect.exit(svc.makeWorktreeInfo())
  77. expect(Exit.isFailure(exit)).toBe(true)
  78. if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
  79. }),
  80. ),
  81. )
  82. })
  83. describe("create + remove lifecycle", () => {
  84. it.live("create returns worktree info and remove cleans up", () =>
  85. provideTmpdirInstance(
  86. () =>
  87. Effect.gen(function* () {
  88. const svc = yield* Worktree.Service
  89. const info = yield* svc.create()
  90. expect(info.name).toBeDefined()
  91. expect(info.branch).toStartWith("opencode/")
  92. expect(info.directory).toBeDefined()
  93. yield* Effect.promise(() => Bun.sleep(1000))
  94. const ok = yield* svc.remove({ directory: info.directory })
  95. expect(ok).toBe(true)
  96. }),
  97. { git: true },
  98. ),
  99. )
  100. it.live("create returns after setup and fires Event.Ready after bootstrap", () =>
  101. provideTmpdirInstance(
  102. (dir) =>
  103. Effect.gen(function* () {
  104. const svc = yield* Worktree.Service
  105. const ready = waitReady()
  106. const info = yield* svc.create()
  107. expect(info.name).toBeDefined()
  108. expect(info.branch).toStartWith("opencode/")
  109. const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
  110. const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory))
  111. expect(normalize(text)).toContain(normalize(next))
  112. const props = yield* Effect.promise(() => ready)
  113. expect(props.name).toBe(info.name)
  114. expect(props.branch).toBe(info.branch)
  115. yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
  116. yield* Effect.promise(() => Bun.sleep(100))
  117. yield* svc.remove({ directory: info.directory })
  118. }),
  119. { git: true },
  120. ),
  121. )
  122. it.live("create with custom name", () =>
  123. provideTmpdirInstance(
  124. () =>
  125. Effect.gen(function* () {
  126. const svc = yield* Worktree.Service
  127. const ready = waitReady()
  128. const info = yield* svc.create({ name: "test-workspace" })
  129. expect(info.name).toBe("test-workspace")
  130. expect(info.branch).toBe("opencode/test-workspace")
  131. yield* Effect.promise(() => ready)
  132. yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
  133. yield* Effect.promise(() => Bun.sleep(100))
  134. yield* svc.remove({ directory: info.directory })
  135. }),
  136. { git: true },
  137. ),
  138. )
  139. })
  140. describe("createFromInfo", () => {
  141. wintest("creates and bootstraps git worktree", () =>
  142. provideTmpdirInstance(
  143. (dir) =>
  144. Effect.gen(function* () {
  145. const svc = yield* Worktree.Service
  146. const info = yield* svc.makeWorktreeInfo("from-info-test")
  147. yield* svc.createFromInfo(info)
  148. const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
  149. const normalizedList = list.replace(/\\/g, "/")
  150. const normalizedDir = info.directory.replace(/\\/g, "/")
  151. expect(normalizedList).toContain(normalizedDir)
  152. yield* svc.remove({ directory: info.directory })
  153. }),
  154. { git: true },
  155. ),
  156. )
  157. })
  158. describe("remove edge cases", () => {
  159. it.live("remove non-existent directory succeeds silently", () =>
  160. provideTmpdirInstance(
  161. (dir) =>
  162. Effect.gen(function* () {
  163. const svc = yield* Worktree.Service
  164. const ok = yield* svc.remove({ directory: path.join(dir, "does-not-exist") })
  165. expect(ok).toBe(true)
  166. }),
  167. { git: true },
  168. ),
  169. )
  170. it.live("throws NotGitError for non-git directories", () =>
  171. provideTmpdirInstance(() =>
  172. Effect.gen(function* () {
  173. const svc = yield* Worktree.Service
  174. const exit = yield* Effect.exit(svc.remove({ directory: "/tmp/fake" }))
  175. expect(Exit.isFailure(exit)).toBe(true)
  176. if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
  177. }),
  178. ),
  179. )
  180. })
  181. })