worktree.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import { afterEach, describe, expect } from "bun:test"
  2. import path from "path"
  3. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  4. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  5. import { Cause, Deferred, Effect, Exit, Layer } from "effect"
  6. import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
  7. import { Git } from "../../src/git"
  8. import { Worktree } from "../../src/worktree"
  9. import { disposeAllInstances, TestInstance } from "../fixture/fixture"
  10. import { testEffect } from "../lib/effect"
  11. const it = testEffect(
  12. Layer.mergeAll(Worktree.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer),
  13. )
  14. const wintest = process.platform !== "win32" ? it.instance : it.instance.skip
  15. function normalize(input: string) {
  16. return input.replace(/\\/g, "/").toLowerCase()
  17. }
  18. const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () {
  19. const ready = yield* Deferred.make<{ name: string; branch?: string }>()
  20. const on = (evt: GlobalEvent) => {
  21. if (evt.payload.type !== Worktree.Event.Ready.type) return
  22. Deferred.doneUnsafe(ready, Effect.succeed(evt.payload.properties))
  23. }
  24. GlobalBus.on("event", on)
  25. yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
  26. return Deferred.await(ready).pipe(
  27. Effect.timeoutOrElse({
  28. duration: "10 seconds",
  29. orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")),
  30. }),
  31. )
  32. })
  33. const git = Effect.fn("WorktreeTest.git")(function* (cwd: string, args: string[]) {
  34. const service = yield* Git.Service
  35. const result = yield* service.run(args, { cwd })
  36. if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`)
  37. return result.text()
  38. })
  39. const gitResult = Effect.fn("WorktreeTest.gitResult")(function* (cwd: string, args: string[]) {
  40. const service = yield* Git.Service
  41. return yield* service.run(args, { cwd })
  42. })
  43. describe("Worktree", () => {
  44. afterEach(() => disposeAllInstances())
  45. describe("makeWorktreeInfo", () => {
  46. it.instance(
  47. "returns info with name, branch, and directory",
  48. () =>
  49. Effect.gen(function* () {
  50. const svc = yield* Worktree.Service
  51. const info = yield* svc.makeWorktreeInfo()
  52. expect(info.name).toBeDefined()
  53. expect(typeof info.name).toBe("string")
  54. expect(info.branch).toBe(`opencode/${info.name}`)
  55. expect(info.directory).toContain(info.name)
  56. }),
  57. { git: true },
  58. )
  59. it.instance(
  60. "uses provided name as base",
  61. () =>
  62. Effect.gen(function* () {
  63. const svc = yield* Worktree.Service
  64. const info = yield* svc.makeWorktreeInfo({ name: "my-feature" })
  65. expect(info.name).toBe("my-feature")
  66. expect(info.branch).toBe("opencode/my-feature")
  67. }),
  68. { git: true },
  69. )
  70. it.instance(
  71. "slugifies the provided name",
  72. () =>
  73. Effect.gen(function* () {
  74. const svc = yield* Worktree.Service
  75. const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" })
  76. expect(info.name).toBe("my-feature-branch")
  77. }),
  78. { git: true },
  79. )
  80. it.instance(
  81. "omits branch for detached info",
  82. () =>
  83. Effect.gen(function* () {
  84. const test = yield* TestInstance
  85. const svc = yield* Worktree.Service
  86. yield* git(test.directory, ["branch", "opencode/my-feature"])
  87. const info = yield* svc.makeWorktreeInfo({ name: "my-feature", detached: true })
  88. expect(info.name).toBe("my-feature")
  89. expect(info.branch).toBeUndefined()
  90. }),
  91. { git: true },
  92. )
  93. it.instance("throws NotGitError for non-git directories", () =>
  94. Effect.gen(function* () {
  95. const svc = yield* Worktree.Service
  96. const exit = yield* Effect.exit(svc.makeWorktreeInfo())
  97. expect(Exit.isFailure(exit)).toBe(true)
  98. if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
  99. }),
  100. )
  101. wintest(
  102. "creates detached git worktree when info has no branch",
  103. () =>
  104. Effect.gen(function* () {
  105. const test = yield* TestInstance
  106. const svc = yield* Worktree.Service
  107. const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true })
  108. const ready = yield* waitReady()
  109. yield* svc.createFromInfo(info)
  110. const list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
  111. const normalizedList = normalize(list)
  112. const normalizedDir = normalize(info.directory)
  113. expect(normalizedList).toContain(normalizedDir)
  114. const branch = yield* gitResult(info.directory, ["symbolic-ref", "-q", "--short", "HEAD"])
  115. expect(branch.exitCode).not.toBe(0)
  116. const props = yield* ready
  117. expect(props.name).toBe(info.name)
  118. expect(props.branch).toBeUndefined()
  119. yield* svc.remove({ directory: info.directory })
  120. }),
  121. { git: true },
  122. )
  123. })
  124. describe("create + remove lifecycle", () => {
  125. it.instance(
  126. "create returns worktree info and remove cleans up",
  127. () =>
  128. Effect.gen(function* () {
  129. const svc = yield* Worktree.Service
  130. const ready = yield* waitReady()
  131. const info = yield* svc.create()
  132. expect(info.name).toBeDefined()
  133. expect(info.branch ?? "").toStartWith("opencode/")
  134. expect(info.directory).toBeDefined()
  135. yield* ready
  136. const ok = yield* svc.remove({ directory: info.directory })
  137. expect(ok).toBe(true)
  138. }),
  139. { git: true },
  140. )
  141. it.instance(
  142. "create returns after setup and fires Event.Ready after bootstrap",
  143. () =>
  144. Effect.gen(function* () {
  145. const test = yield* TestInstance
  146. const fs = yield* AppFileSystem.Service
  147. const svc = yield* Worktree.Service
  148. const ready = yield* waitReady()
  149. const info = yield* svc.create()
  150. expect(info.name).toBeDefined()
  151. expect(info.branch ?? "").toStartWith("opencode/")
  152. const text = yield* git(test.directory, ["worktree", "list", "--porcelain"])
  153. const next = yield* fs.realPath(info.directory).pipe(Effect.catch(() => Effect.succeed(info.directory)))
  154. expect(normalize(text)).toContain(normalize(next))
  155. const props = yield* ready
  156. expect(props.name).toBe(info.name)
  157. expect(props.branch).toBe(info.branch)
  158. yield* svc.remove({ directory: info.directory })
  159. }),
  160. { git: true },
  161. )
  162. it.instance(
  163. "create with custom name",
  164. () =>
  165. Effect.gen(function* () {
  166. const svc = yield* Worktree.Service
  167. const ready = yield* waitReady()
  168. const info = yield* svc.create({ name: "test-workspace" })
  169. expect(info.name).toBe("test-workspace")
  170. expect(info.branch).toBe("opencode/test-workspace")
  171. yield* ready
  172. yield* svc.remove({ directory: info.directory })
  173. }),
  174. { git: true },
  175. )
  176. })
  177. describe("createFromInfo", () => {
  178. wintest(
  179. "creates git worktree and boots asynchronously",
  180. () =>
  181. Effect.gen(function* () {
  182. const test = yield* TestInstance
  183. const svc = yield* Worktree.Service
  184. const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" })
  185. const ready = yield* waitReady()
  186. yield* svc.createFromInfo(info)
  187. const list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
  188. const normalizedList = list.replace(/\\/g, "/")
  189. const normalizedDir = info.directory.replace(/\\/g, "/")
  190. expect(normalizedList).toContain(normalizedDir)
  191. yield* ready
  192. yield* svc.remove({ directory: info.directory })
  193. }),
  194. { git: true },
  195. )
  196. })
  197. describe("list", () => {
  198. it.instance(
  199. "uses parent folder name when worktree basename matches the primary worktree",
  200. () =>
  201. Effect.gen(function* () {
  202. const test = yield* TestInstance
  203. const fs = yield* AppFileSystem.Service
  204. const svc = yield* Worktree.Service
  205. const parent = path.join(path.dirname(test.directory), `${path.basename(test.directory)}-parent`)
  206. const target = path.join(parent, path.basename(test.directory))
  207. const branch = `same-basename-list-${Date.now()}`
  208. yield* fs.ensureDir(parent)
  209. yield* git(test.directory, ["worktree", "add", "-b", branch, target])
  210. const list = yield* svc.list()
  211. const directory = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
  212. expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({
  213. name: path.basename(parent),
  214. branch,
  215. directory: normalize(directory),
  216. })
  217. yield* svc.remove({ directory: target })
  218. }),
  219. { git: true },
  220. )
  221. })
  222. describe("remove edge cases", () => {
  223. it.instance(
  224. "remove non-existent directory succeeds silently",
  225. () =>
  226. Effect.gen(function* () {
  227. const test = yield* TestInstance
  228. const svc = yield* Worktree.Service
  229. const ok = yield* svc.remove({ directory: path.join(test.directory, "does-not-exist") })
  230. expect(ok).toBe(true)
  231. }),
  232. { git: true },
  233. )
  234. it.instance("throws NotGitError for non-git directories", () =>
  235. Effect.gen(function* () {
  236. const test = yield* TestInstance
  237. const svc = yield* Worktree.Service
  238. const exit = yield* Effect.exit(svc.remove({ directory: path.join(test.directory, "fake") }))
  239. expect(Exit.isFailure(exit)).toBe(true)
  240. if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
  241. }),
  242. )
  243. })
  244. })