worktree.test.ts 10 KB

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