worktree.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import { afterEach, describe, expect } from "bun:test"
  2. import path from "path"
  3. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  4. import { FSUtil } from "@kirincode-ai/core/fs-util"
  5. import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
  6. import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
  7. import { Git } from "../../src/git"
  8. import { InstanceBootstrap } from "../../src/project/bootstrap"
  9. import { InstanceStore } from "../../src/project/instance-store"
  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. LayerNode.compile(LayerNode.group([Worktree.node, FSUtil.node, Git.node]), [
  15. [InstanceStore.bootstrapNode, InstanceBootstrap.node],
  16. ]),
  17. )
  18. const wintest = process.platform !== "win32" ? it.instance : it.instance.skip
  19. function normalize(input: string) {
  20. return input.replace(/\\/g, "/").toLowerCase()
  21. }
  22. const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () {
  23. const ready = yield* Deferred.make<{ name: string; branch?: string }>()
  24. const on = (evt: GlobalEvent) => {
  25. if (evt.payload.type !== Worktree.Event.Ready.type) return
  26. Deferred.doneUnsafe(ready, Effect.succeed(evt.payload.properties))
  27. }
  28. GlobalBus.on("event", on)
  29. yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
  30. return yield* Deferred.await(ready).pipe(
  31. Effect.timeoutOrElse({
  32. duration: "10 seconds",
  33. orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")),
  34. }),
  35. )
  36. })
  37. const removeCreatedWorktree = (directory: string) =>
  38. Effect.gen(function* () {
  39. const svc = yield* Worktree.Service
  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("fails with 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)) {
  124. const error = Cause.squash(exit.cause)
  125. expect(error).toBeInstanceOf(Worktree.NotGitError)
  126. if (error instanceof Worktree.NotGitError) expect(error._tag).toBe("WorktreeNotGitError")
  127. }
  128. }),
  129. )
  130. wintest(
  131. "creates detached git worktree when info has no branch",
  132. () =>
  133. Effect.gen(function* () {
  134. const test = yield* TestInstance
  135. const svc = yield* Worktree.Service
  136. const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true })
  137. const ready = yield* waitReady().pipe(Effect.forkScoped)
  138. yield* svc.createFromInfo(info)
  139. const list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
  140. const normalizedList = normalize(list)
  141. const normalizedDir = normalize(info.directory)
  142. expect(normalizedList).toContain(normalizedDir)
  143. const branch = yield* gitResult(info.directory, ["symbolic-ref", "-q", "--short", "HEAD"])
  144. expect(branch.exitCode).not.toBe(0)
  145. const props = yield* Fiber.join(ready)
  146. expect(props.name).toBe(info.name)
  147. expect(props.branch).toBeUndefined()
  148. yield* svc.remove({ directory: info.directory })
  149. }),
  150. { git: true },
  151. )
  152. })
  153. describe("create + remove lifecycle", () => {
  154. it.instance(
  155. "create returns worktree info and remove cleans up",
  156. () =>
  157. withCreatedWorktree(undefined, ({ info }) =>
  158. Effect.gen(function* () {
  159. expect(info.name).toBeDefined()
  160. expect(info.branch ?? "").toStartWith("opencode/")
  161. expect(info.directory).toBeDefined()
  162. }),
  163. ),
  164. { git: true },
  165. )
  166. it.instance(
  167. "create returns after setup and fires Event.Ready after bootstrap",
  168. () =>
  169. withCreatedWorktree(undefined, ({ info, ready }) =>
  170. Effect.gen(function* () {
  171. const svc = yield* Worktree.Service
  172. expect(info.name).toBeDefined()
  173. expect(info.branch ?? "").toStartWith("opencode/")
  174. expect(ready.name).toBe(info.name)
  175. expect(ready.branch).toBe(info.branch)
  176. const list = yield* svc.list()
  177. expect(list).toContainEqual(expect.objectContaining({ name: info.name, branch: info.branch }))
  178. }),
  179. ),
  180. { git: true },
  181. )
  182. it.instance(
  183. "lists the active linked worktree but not the project checkout",
  184. () =>
  185. withCreatedWorktree(undefined, ({ info }) =>
  186. Effect.gen(function* () {
  187. const test = yield* TestInstance
  188. const svc = yield* Worktree.Service
  189. const list = yield* svc.list().pipe(provideInstance(info.directory))
  190. expect(list.map((item) => item.name)).toContain(info.name)
  191. expect(list.map((item) => item.name)).not.toContain(path.basename(test.directory).toLowerCase())
  192. }),
  193. ),
  194. { git: true },
  195. )
  196. it.instance(
  197. "create with custom name",
  198. () =>
  199. withCreatedWorktree({ name: "test-workspace" }, ({ info }) =>
  200. Effect.gen(function* () {
  201. expect(info.name).toBe("test-workspace")
  202. expect(info.branch).toBe("opencode/test-workspace")
  203. }),
  204. ),
  205. { git: true },
  206. )
  207. })
  208. describe("createFromInfo", () => {
  209. wintest(
  210. "creates git worktree and boots asynchronously",
  211. () =>
  212. Effect.gen(function* () {
  213. const test = yield* TestInstance
  214. const svc = yield* Worktree.Service
  215. const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" })
  216. const ready = yield* waitReady().pipe(Effect.forkScoped)
  217. yield* svc.createFromInfo(info)
  218. const list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
  219. const normalizedList = list.replace(/\\/g, "/")
  220. const normalizedDir = info.directory.replace(/\\/g, "/")
  221. expect(normalizedList).toContain(normalizedDir)
  222. yield* Fiber.join(ready)
  223. yield* removeCreatedWorktree(info.directory)
  224. }),
  225. { git: true },
  226. )
  227. })
  228. describe("list", () => {
  229. it.instance(
  230. "uses parent folder name when worktree basename matches the primary worktree",
  231. () =>
  232. Effect.gen(function* () {
  233. const test = yield* TestInstance
  234. const fs = yield* FSUtil.Service
  235. const svc = yield* Worktree.Service
  236. const parent = path.join(path.dirname(test.directory), `${path.basename(test.directory)}-parent`)
  237. const target = path.join(parent, path.basename(test.directory))
  238. const branch = `same-basename-list-${Date.now()}`
  239. yield* fs.ensureDir(parent)
  240. yield* git(test.directory, ["worktree", "add", "-b", branch, target])
  241. const list = yield* svc.list()
  242. const directory = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
  243. expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({
  244. name: path.basename(parent),
  245. branch,
  246. directory: normalize(directory),
  247. })
  248. yield* svc.remove({ directory: target })
  249. }),
  250. { git: true },
  251. )
  252. })
  253. describe("remove edge cases", () => {
  254. it.instance(
  255. "remove non-existent directory succeeds silently",
  256. () =>
  257. Effect.gen(function* () {
  258. const test = yield* TestInstance
  259. const svc = yield* Worktree.Service
  260. const ok = yield* svc.remove({ directory: path.join(test.directory, "does-not-exist") })
  261. expect(ok).toBe(true)
  262. }),
  263. { git: true },
  264. )
  265. it.instance("fails with NotGitError for non-git directories", () =>
  266. Effect.gen(function* () {
  267. const test = yield* TestInstance
  268. const svc = yield* Worktree.Service
  269. const exit = yield* Effect.exit(svc.remove({ directory: path.join(test.directory, "fake") }))
  270. expect(Exit.isFailure(exit)).toBe(true)
  271. if (Exit.isFailure(exit)) {
  272. const error = Cause.squash(exit.cause)
  273. expect(error).toBeInstanceOf(Worktree.NotGitError)
  274. if (error instanceof Worktree.NotGitError) expect(error._tag).toBe("WorktreeNotGitError")
  275. }
  276. }),
  277. )
  278. })
  279. })