worktree.test.ts 11 KB

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