vcs.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { FSUtil } from "@kirincode-ai/core/fs-util"
  4. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  5. import { parsePatch } from "diff"
  6. import { Deferred, Effect, Layer } from "effect"
  7. import fs from "fs/promises"
  8. import path from "path"
  9. import {
  10. disposeAllInstances,
  11. provideInstance,
  12. testInstanceStoreLayer,
  13. TestInstance,
  14. tmpdirScoped,
  15. } from "../fixture/fixture"
  16. import { EventV2Bridge } from "../../src/event-v2-bridge"
  17. import { Watcher } from "@kirincode-ai/core/filesystem/watcher"
  18. import { Git } from "../../src/git"
  19. import { Vcs } from "@/project/vcs"
  20. import { testEffect } from "../lib/effect"
  21. // ---------------------------------------------------------------------------
  22. // Helpers
  23. // ---------------------------------------------------------------------------
  24. const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
  25. const layer = LayerNode.compile(
  26. LayerNode.group([Vcs.node, Git.node, EventV2Bridge.node, FSUtil.node, CrossSpawnSpawner.node]),
  27. )
  28. const it = testEffect(layer)
  29. const worktreeIt = testEffect(Layer.mergeAll(layer, testInstanceStoreLayer))
  30. const git = Effect.fn("VcsTest.git")(function* (cwd: string, args: string[]) {
  31. const result = yield* Git.Service.use((git) => git.run(args, { cwd }))
  32. if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`)
  33. })
  34. const write = Effect.fn("VcsTest.write")(function* (file: string, content: string) {
  35. yield* FSUtil.Service.use((fs) => fs.writeWithDirs(file, content))
  36. })
  37. const remove = Effect.fn("VcsTest.remove")(function* (file: string) {
  38. yield* FSUtil.Service.use((fs) => fs.remove(file))
  39. })
  40. const symlink = (target: string, file: string) => Effect.promise(() => fs.symlink(target, file))
  41. const init = Effect.fn("VcsTest.init")(function* () {
  42. const vcs = yield* Vcs.Service
  43. yield* vcs.init()
  44. return vcs
  45. })
  46. const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () {
  47. const events = yield* EventV2Bridge.Service
  48. const updated = yield* Deferred.make<string | undefined>()
  49. const off = yield* events.listen((event) => {
  50. if (event.type === Vcs.Event.BranchUpdated.type)
  51. Deferred.doneUnsafe(updated, Effect.succeed((event.data as typeof Vcs.Event.BranchUpdated.data.Type).branch))
  52. return Effect.void
  53. })
  54. yield* Effect.addFinalizer(() => off)
  55. return updated
  56. })
  57. const publishHeadChangeUntil = Effect.fn("VcsTest.publishHeadChangeUntil")(function* (
  58. pending: Deferred.Deferred<string | undefined>,
  59. head: string,
  60. ) {
  61. const events = yield* EventV2Bridge.Service
  62. for (let i = 0; i < 50; i++) {
  63. yield* events.publish(Watcher.Event.Updated, { file: head, event: "change" })
  64. if (yield* Deferred.isDone(pending)) return
  65. yield* Effect.sleep("10 millis")
  66. }
  67. })
  68. // ---------------------------------------------------------------------------
  69. // Tests
  70. // ---------------------------------------------------------------------------
  71. describe("Vcs", () => {
  72. afterEach(async () => {
  73. await disposeAllInstances()
  74. })
  75. it.instance(
  76. "branch() returns current branch name",
  77. () =>
  78. Effect.gen(function* () {
  79. const vcs = yield* init()
  80. const branch = yield* vcs.branch()
  81. expect(branch).toBeDefined()
  82. expect(typeof branch).toBe("string")
  83. }),
  84. { git: true },
  85. )
  86. it.instance("branch() returns undefined for non-git directories", () =>
  87. Effect.gen(function* () {
  88. const vcs = yield* init()
  89. const branch = yield* vcs.branch()
  90. expect(branch).toBeUndefined()
  91. }),
  92. )
  93. it.instance(
  94. "publishes BranchUpdated when .git/HEAD changes",
  95. () =>
  96. Effect.gen(function* () {
  97. const test = yield* TestInstance
  98. const branch = `test-${Math.random().toString(36).slice(2)}`
  99. yield* git(test.directory, ["branch", branch])
  100. const vcs = yield* init()
  101. yield* vcs.branch()
  102. const pending = yield* nextBranchUpdate()
  103. const head = path.join(test.directory, ".git", "HEAD")
  104. yield* write(head, `ref: refs/heads/${branch}\n`)
  105. yield* publishHeadChangeUntil(pending, head)
  106. const updated = yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds"))
  107. expect(updated).toBe(branch)
  108. }),
  109. { git: true },
  110. )
  111. it.instance(
  112. "branch() reflects the new branch after HEAD change",
  113. () =>
  114. Effect.gen(function* () {
  115. const test = yield* TestInstance
  116. const branch = `test-${Math.random().toString(36).slice(2)}`
  117. yield* git(test.directory, ["branch", branch])
  118. const vcs = yield* init()
  119. yield* vcs.branch()
  120. const pending = yield* nextBranchUpdate()
  121. const head = path.join(test.directory, ".git", "HEAD")
  122. yield* write(head, `ref: refs/heads/${branch}\n`)
  123. yield* publishHeadChangeUntil(pending, head)
  124. yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds"))
  125. const current = yield* vcs.branch()
  126. expect(current).toBe(branch)
  127. }),
  128. { git: true },
  129. )
  130. })
  131. describe("Vcs diff", () => {
  132. afterEach(async () => {
  133. await disposeAllInstances()
  134. })
  135. it.instance(
  136. "defaultBranch() falls back to main",
  137. () =>
  138. Effect.gen(function* () {
  139. const test = yield* TestInstance
  140. yield* git(test.directory, ["branch", "-M", "main"])
  141. const vcs = yield* init()
  142. const branch = yield* vcs.defaultBranch()
  143. expect(branch).toBe("main")
  144. }),
  145. { git: true },
  146. )
  147. it.instance(
  148. "defaultBranch() uses init.defaultBranch when available",
  149. () =>
  150. Effect.gen(function* () {
  151. const test = yield* TestInstance
  152. yield* git(test.directory, ["branch", "-M", "trunk"])
  153. yield* git(test.directory, ["config", "init.defaultBranch", "trunk"])
  154. const vcs = yield* init()
  155. const branch = yield* vcs.defaultBranch()
  156. expect(branch).toBe("trunk")
  157. }),
  158. { git: true },
  159. )
  160. worktreeIt.live("detects current branch from the active worktree", () =>
  161. Effect.gen(function* () {
  162. const tmp = yield* tmpdirScoped({ git: true })
  163. const wt = yield* tmpdirScoped()
  164. yield* git(tmp, ["branch", "-M", "main"])
  165. const dir = path.join(wt, "feature")
  166. yield* git(tmp, ["worktree", "add", "-b", "feature/test", dir, "HEAD"])
  167. const [branch, base] = yield* Effect.gen(function* () {
  168. const vcs = yield* init()
  169. return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
  170. }).pipe(provideInstance(dir))
  171. expect(branch).toBeDefined()
  172. expect(branch).toBe("feature/test")
  173. expect(base).toBe("main")
  174. }),
  175. )
  176. it.instance(
  177. "diff('git') returns uncommitted changes",
  178. () =>
  179. Effect.gen(function* () {
  180. const test = yield* TestInstance
  181. yield* write(path.join(test.directory, "file.txt"), "original\n")
  182. yield* git(test.directory, ["add", "."])
  183. yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"])
  184. yield* write(path.join(test.directory, "file.txt"), "changed\n")
  185. const vcs = yield* init()
  186. const diff = yield* vcs.diff("git")
  187. expect(diff).toEqual(
  188. expect.arrayContaining([
  189. expect.objectContaining({
  190. file: "file.txt",
  191. status: "modified",
  192. }),
  193. ]),
  194. )
  195. expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git")
  196. }),
  197. { git: true },
  198. )
  199. it.instance(
  200. "diff('git') handles special filenames",
  201. () =>
  202. Effect.gen(function* () {
  203. const test = yield* TestInstance
  204. yield* write(path.join(test.directory, weird), "hello\n")
  205. const vcs = yield* init()
  206. const diff = yield* vcs.diff("git")
  207. expect(diff).toEqual(
  208. expect.arrayContaining([
  209. expect.objectContaining({
  210. file: weird,
  211. status: "added",
  212. }),
  213. ]),
  214. )
  215. }),
  216. { git: true },
  217. )
  218. it.instance(
  219. "diff('git') keeps batched patches aligned for type changes",
  220. () =>
  221. Effect.gen(function* () {
  222. if (process.platform === "win32") return
  223. const test = yield* TestInstance
  224. yield* write(path.join(test.directory, "a.txt"), "old\n")
  225. yield* write(path.join(test.directory, "b.txt"), "old\n")
  226. yield* git(test.directory, ["add", "."])
  227. yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add files"])
  228. yield* remove(path.join(test.directory, "a.txt"))
  229. yield* symlink("target", path.join(test.directory, "a.txt"))
  230. yield* write(path.join(test.directory, "b.txt"), "new\n")
  231. const vcs = yield* init()
  232. const diff = yield* vcs.diff("git")
  233. const a = diff.find((item) => item.file === "a.txt")
  234. const b = diff.find((item) => item.file === "b.txt")
  235. expect(a?.patch).toContain("deleted file mode")
  236. expect(a?.patch).toContain("new file mode")
  237. expect(b?.patch).toContain("+new")
  238. }),
  239. { git: true },
  240. )
  241. it.instance(
  242. "diff('git') keeps carriage returns inside patch hunks",
  243. () =>
  244. Effect.gen(function* () {
  245. const test = yield* TestInstance
  246. yield* write(path.join(test.directory, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n")
  247. yield* git(test.directory, ["add", "."])
  248. yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"])
  249. yield* write(path.join(test.directory, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n")
  250. const vcs = yield* init()
  251. const diff = yield* vcs.diff("git")
  252. const file = diff.find((item) => item.file === "file.txt")
  253. expect(file?.patch).toContain(" same\rdiff --git inside")
  254. expect(file?.patch).toContain("-delete")
  255. expect(() => parsePatch(file?.patch ?? "")).not.toThrow()
  256. }),
  257. { git: true },
  258. 20_000,
  259. )
  260. it.instance(
  261. "diff('branch') returns changes against default branch",
  262. () =>
  263. Effect.gen(function* () {
  264. const test = yield* TestInstance
  265. yield* git(test.directory, ["branch", "-M", "main"])
  266. yield* git(test.directory, ["checkout", "-b", "feature/test"])
  267. yield* write(path.join(test.directory, "branch.txt"), "hello\n")
  268. yield* git(test.directory, ["add", "."])
  269. yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "branch file"])
  270. const vcs = yield* init()
  271. const diff = yield* vcs.diff("branch")
  272. expect(diff).toEqual(
  273. expect.arrayContaining([
  274. expect.objectContaining({
  275. file: "branch.txt",
  276. status: "added",
  277. }),
  278. ]),
  279. )
  280. }),
  281. { git: true },
  282. )
  283. })