vcs.test.ts 8.8 KB

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