git.test.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import { $ } from "bun"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { describe, expect } from "bun:test"
  4. import fs from "fs/promises"
  5. import path from "path"
  6. import { Effect } from "effect"
  7. import { Git } from "../../src/git"
  8. import { tmpdir } from "../fixture/fixture"
  9. import { testEffect } from "../lib/effect"
  10. const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
  11. const it = testEffect(LayerNode.compile(LayerNode.group([Git.node])))
  12. const scopedTmpdir = (options?: Parameters<typeof tmpdir>[0]) =>
  13. Effect.acquireRelease(
  14. Effect.promise(() => tmpdir(options)),
  15. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  16. )
  17. describe("Git", () => {
  18. it.live("branch() returns current branch name", () =>
  19. Effect.gen(function* () {
  20. const tmp = yield* scopedTmpdir({ git: true })
  21. const git = yield* Git.Service
  22. const branch = yield* git.branch(tmp.path)
  23. expect(branch).toBeDefined()
  24. expect(typeof branch).toBe("string")
  25. }),
  26. )
  27. it.live("branch() returns undefined for non-git directories", () =>
  28. Effect.gen(function* () {
  29. const tmp = yield* scopedTmpdir()
  30. const git = yield* Git.Service
  31. const branch = yield* git.branch(tmp.path)
  32. expect(branch).toBeUndefined()
  33. }),
  34. )
  35. it.live("branch() returns undefined for detached HEAD", () =>
  36. Effect.gen(function* () {
  37. const tmp = yield* scopedTmpdir({ git: true })
  38. const hash = (yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(tmp.path).quiet().text())).trim()
  39. yield* Effect.promise(() => $`git checkout --detach ${hash}`.cwd(tmp.path).quiet())
  40. const git = yield* Git.Service
  41. const branch = yield* git.branch(tmp.path)
  42. expect(branch).toBeUndefined()
  43. }),
  44. )
  45. it.live("defaultBranch() uses init.defaultBranch when available", () =>
  46. Effect.gen(function* () {
  47. const tmp = yield* scopedTmpdir({ git: true })
  48. yield* Effect.promise(() => $`git branch -M trunk`.cwd(tmp.path).quiet())
  49. yield* Effect.promise(() => $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet())
  50. const git = yield* Git.Service
  51. const branch = yield* git.defaultBranch(tmp.path)
  52. expect(branch?.name).toBe("trunk")
  53. expect(branch?.ref).toBe("trunk")
  54. }),
  55. )
  56. it.live("status() handles special filenames", () =>
  57. Effect.gen(function* () {
  58. const tmp = yield* scopedTmpdir({ git: true })
  59. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8"))
  60. const git = yield* Git.Service
  61. const status = yield* git.status(tmp.path)
  62. expect(status).toEqual(
  63. expect.arrayContaining([
  64. expect.objectContaining({
  65. file: weird,
  66. status: "added",
  67. }),
  68. ]),
  69. )
  70. }),
  71. )
  72. it.live("diff(), stats(), and mergeBase() parse tracked changes", () =>
  73. Effect.gen(function* () {
  74. const tmp = yield* scopedTmpdir({ git: true })
  75. yield* Effect.promise(() => $`git branch -M main`.cwd(tmp.path).quiet())
  76. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8"))
  77. yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet())
  78. yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet())
  79. yield* Effect.promise(() => $`git checkout -b feature/test`.cwd(tmp.path).quiet())
  80. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8"))
  81. const git = yield* Git.Service
  82. const [base, diff, stats] = yield* Effect.all([
  83. git.mergeBase(tmp.path, "main"),
  84. git.diff(tmp.path, "HEAD"),
  85. git.stats(tmp.path, "HEAD"),
  86. ])
  87. expect(base).toBeTruthy()
  88. expect(diff).toEqual(
  89. expect.arrayContaining([
  90. expect.objectContaining({
  91. file: weird,
  92. status: "modified",
  93. }),
  94. ]),
  95. )
  96. expect(stats).toEqual(
  97. expect.arrayContaining([
  98. expect.objectContaining({
  99. file: weird,
  100. additions: 1,
  101. deletions: 1,
  102. }),
  103. ]),
  104. )
  105. }),
  106. )
  107. it.live("patch() returns capped native patch output", () =>
  108. Effect.gen(function* () {
  109. const tmp = yield* scopedTmpdir({ git: true })
  110. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8"))
  111. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8"))
  112. yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet())
  113. yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet())
  114. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8"))
  115. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8"))
  116. const git = yield* Git.Service
  117. const [patch, all, capped] = yield* Effect.all([
  118. git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }),
  119. git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }),
  120. git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }),
  121. ])
  122. expect(patch.truncated).toBe(false)
  123. expect(patch.text).toContain("diff --git")
  124. expect(patch.text).toContain("-before")
  125. expect(patch.text).toContain("+after")
  126. expect(all.truncated).toBe(false)
  127. expect(all.text).toContain("diff --git")
  128. expect(all.text).toContain("other.txt")
  129. expect(all.text).toContain("+new")
  130. expect(capped.truncated).toBe(true)
  131. expect(capped.text).toBe("")
  132. }),
  133. )
  134. it.live("patchUntracked() and statUntracked() handle added files", () =>
  135. Effect.gen(function* () {
  136. const tmp = yield* scopedTmpdir({ git: true })
  137. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8"))
  138. const git = yield* Git.Service
  139. const [patch, stat] = yield* Effect.all([
  140. git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }),
  141. git.statUntracked(tmp.path, weird),
  142. ])
  143. expect(patch.truncated).toBe(false)
  144. expect(patch.text).toContain("diff --git")
  145. expect(patch.text).toContain("+one")
  146. expect(patch.text).toContain("+two")
  147. expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 }))
  148. }),
  149. )
  150. it.live("show() returns empty text for binary blobs", () =>
  151. Effect.gen(function* () {
  152. const tmp = yield* scopedTmpdir({ git: true })
  153. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3])))
  154. yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet())
  155. yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet())
  156. const git = yield* Git.Service
  157. const text = yield* git.show(tmp.path, "HEAD", "bin.dat")
  158. expect(text).toBe("")
  159. }),
  160. )
  161. })