vcs.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import { $ } from "bun"
  2. import { afterEach, describe, expect, test } from "bun:test"
  3. import { Effect } from "effect"
  4. import fs from "fs/promises"
  5. import path from "path"
  6. import { disposeAllInstances, tmpdir } from "../fixture/fixture"
  7. import { AppRuntime } from "../../src/effect/app-runtime"
  8. import { FileWatcher } from "../../src/file/watcher"
  9. import { Instance } from "../../src/project/instance"
  10. import { WithInstance } from "../../src/project/with-instance"
  11. import { GlobalBus } from "../../src/bus/global"
  12. import { Vcs } from "@/project/vcs"
  13. // Skip in CI — native @parcel/watcher binding needed
  14. const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
  15. // ---------------------------------------------------------------------------
  16. // Helpers
  17. // ---------------------------------------------------------------------------
  18. async function withVcs(directory: string, body: () => Promise<void>) {
  19. return WithInstance.provide({
  20. directory,
  21. fn: async () => {
  22. await AppRuntime.runPromise(
  23. Effect.gen(function* () {
  24. const watcher = yield* FileWatcher.Service
  25. const vcs = yield* Vcs.Service
  26. yield* watcher.init()
  27. yield* vcs.init()
  28. }),
  29. )
  30. await Bun.sleep(500)
  31. await body()
  32. },
  33. })
  34. }
  35. function withVcsOnly(directory: string, body: () => Promise<void>) {
  36. return WithInstance.provide({
  37. directory,
  38. fn: async () => {
  39. await AppRuntime.runPromise(
  40. Effect.gen(function* () {
  41. const vcs = yield* Vcs.Service
  42. yield* vcs.init()
  43. }),
  44. )
  45. await body()
  46. },
  47. })
  48. }
  49. type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
  50. const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
  51. /** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */
  52. function nextBranchUpdate(directory: string, timeout = 10_000) {
  53. return new Promise<string | undefined>((resolve, reject) => {
  54. let settled = false
  55. const timer = setTimeout(() => {
  56. if (settled) return
  57. settled = true
  58. GlobalBus.off("event", on)
  59. reject(new Error("timed out waiting for BranchUpdated event"))
  60. }, timeout)
  61. function on(evt: BranchEvent) {
  62. if (evt.directory !== directory) return
  63. if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return
  64. if (settled) return
  65. settled = true
  66. clearTimeout(timer)
  67. GlobalBus.off("event", on)
  68. resolve(evt.payload.properties.branch)
  69. }
  70. GlobalBus.on("event", on)
  71. })
  72. }
  73. // ---------------------------------------------------------------------------
  74. // Tests
  75. // ---------------------------------------------------------------------------
  76. describeVcs("Vcs", () => {
  77. afterEach(async () => {
  78. await disposeAllInstances()
  79. })
  80. test("branch() returns current branch name", async () => {
  81. await using tmp = await tmpdir({ git: true })
  82. await withVcs(tmp.path, async () => {
  83. const branch = await AppRuntime.runPromise(
  84. Effect.gen(function* () {
  85. const vcs = yield* Vcs.Service
  86. return yield* vcs.branch()
  87. }),
  88. )
  89. expect(branch).toBeDefined()
  90. expect(typeof branch).toBe("string")
  91. })
  92. })
  93. test("branch() returns undefined for non-git directories", async () => {
  94. await using tmp = await tmpdir()
  95. await withVcs(tmp.path, async () => {
  96. const branch = await AppRuntime.runPromise(
  97. Effect.gen(function* () {
  98. const vcs = yield* Vcs.Service
  99. return yield* vcs.branch()
  100. }),
  101. )
  102. expect(branch).toBeUndefined()
  103. })
  104. })
  105. test("publishes BranchUpdated when .git/HEAD changes", async () => {
  106. await using tmp = await tmpdir({ git: true })
  107. const branch = `test-${Math.random().toString(36).slice(2)}`
  108. await $`git branch ${branch}`.cwd(tmp.path).quiet()
  109. await withVcs(tmp.path, async () => {
  110. const pending = nextBranchUpdate(tmp.path)
  111. const head = path.join(tmp.path, ".git", "HEAD")
  112. await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
  113. const updated = await pending
  114. expect(updated).toBe(branch)
  115. })
  116. })
  117. test("branch() reflects the new branch after HEAD change", async () => {
  118. await using tmp = await tmpdir({ git: true })
  119. const branch = `test-${Math.random().toString(36).slice(2)}`
  120. await $`git branch ${branch}`.cwd(tmp.path).quiet()
  121. await withVcs(tmp.path, async () => {
  122. const pending = nextBranchUpdate(tmp.path)
  123. const head = path.join(tmp.path, ".git", "HEAD")
  124. await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
  125. await pending
  126. const current = await AppRuntime.runPromise(
  127. Effect.gen(function* () {
  128. const vcs = yield* Vcs.Service
  129. return yield* vcs.branch()
  130. }),
  131. )
  132. expect(current).toBe(branch)
  133. })
  134. })
  135. })
  136. describe("Vcs diff", () => {
  137. afterEach(async () => {
  138. await disposeAllInstances()
  139. })
  140. test("defaultBranch() falls back to main", async () => {
  141. await using tmp = await tmpdir({ git: true })
  142. await $`git branch -M main`.cwd(tmp.path).quiet()
  143. await withVcsOnly(tmp.path, async () => {
  144. const branch = await AppRuntime.runPromise(
  145. Effect.gen(function* () {
  146. const vcs = yield* Vcs.Service
  147. return yield* vcs.defaultBranch()
  148. }),
  149. )
  150. expect(branch).toBe("main")
  151. })
  152. })
  153. test("defaultBranch() uses init.defaultBranch when available", async () => {
  154. await using tmp = await tmpdir({ git: true })
  155. await $`git branch -M trunk`.cwd(tmp.path).quiet()
  156. await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
  157. await withVcsOnly(tmp.path, async () => {
  158. const branch = await AppRuntime.runPromise(
  159. Effect.gen(function* () {
  160. const vcs = yield* Vcs.Service
  161. return yield* vcs.defaultBranch()
  162. }),
  163. )
  164. expect(branch).toBe("trunk")
  165. })
  166. })
  167. test("detects current branch from the active worktree", async () => {
  168. await using tmp = await tmpdir({ git: true })
  169. await using wt = await tmpdir()
  170. await $`git branch -M main`.cwd(tmp.path).quiet()
  171. const dir = path.join(wt.path, "feature")
  172. await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
  173. await withVcsOnly(dir, async () => {
  174. const [branch, base] = await AppRuntime.runPromise(
  175. Effect.gen(function* () {
  176. const vcs = yield* Vcs.Service
  177. return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
  178. }),
  179. )
  180. expect(branch).toBe("feature/test")
  181. expect(base).toBe("main")
  182. })
  183. })
  184. test("diff('git') returns uncommitted changes", async () => {
  185. await using tmp = await tmpdir({ git: true })
  186. await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8")
  187. await $`git add .`.cwd(tmp.path).quiet()
  188. await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
  189. await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
  190. await withVcsOnly(tmp.path, async () => {
  191. const diff = await AppRuntime.runPromise(
  192. Effect.gen(function* () {
  193. const vcs = yield* Vcs.Service
  194. return yield* vcs.diff("git")
  195. }),
  196. )
  197. expect(diff).toEqual(
  198. expect.arrayContaining([
  199. expect.objectContaining({
  200. file: "file.txt",
  201. status: "modified",
  202. }),
  203. ]),
  204. )
  205. expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git")
  206. })
  207. })
  208. test("diff('git') handles special filenames", async () => {
  209. await using tmp = await tmpdir({ git: true })
  210. await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
  211. await withVcsOnly(tmp.path, async () => {
  212. const diff = await AppRuntime.runPromise(
  213. Effect.gen(function* () {
  214. const vcs = yield* Vcs.Service
  215. return yield* vcs.diff("git")
  216. }),
  217. )
  218. expect(diff).toEqual(
  219. expect.arrayContaining([
  220. expect.objectContaining({
  221. file: weird,
  222. status: "added",
  223. }),
  224. ]),
  225. )
  226. })
  227. })
  228. test("diff('git') keeps batched patches aligned for type changes", async () => {
  229. if (process.platform === "win32") return
  230. await using tmp = await tmpdir({ git: true })
  231. await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8")
  232. await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8")
  233. await $`git add .`.cwd(tmp.path).quiet()
  234. await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet()
  235. await fs.unlink(path.join(tmp.path, "a.txt"))
  236. await fs.symlink("target", path.join(tmp.path, "a.txt"))
  237. await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8")
  238. await withVcsOnly(tmp.path, async () => {
  239. const diff = await AppRuntime.runPromise(
  240. Effect.gen(function* () {
  241. const vcs = yield* Vcs.Service
  242. return yield* vcs.diff("git")
  243. }),
  244. )
  245. const a = diff.find((item) => item.file === "a.txt")
  246. const b = diff.find((item) => item.file === "b.txt")
  247. expect(a?.patch).toContain("deleted file mode")
  248. expect(a?.patch).toContain("new file mode")
  249. expect(b?.patch).toContain("+new")
  250. })
  251. })
  252. test("diff('branch') returns changes against default branch", async () => {
  253. await using tmp = await tmpdir({ git: true })
  254. await $`git branch -M main`.cwd(tmp.path).quiet()
  255. await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
  256. await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8")
  257. await $`git add .`.cwd(tmp.path).quiet()
  258. await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
  259. await withVcsOnly(tmp.path, async () => {
  260. const diff = await AppRuntime.runPromise(
  261. Effect.gen(function* () {
  262. const vcs = yield* Vcs.Service
  263. return yield* vcs.diff("branch")
  264. }),
  265. )
  266. expect(diff).toEqual(
  267. expect.arrayContaining([
  268. expect.objectContaining({
  269. file: "branch.txt",
  270. status: "added",
  271. }),
  272. ]),
  273. )
  274. })
  275. })
  276. })