vcs.test.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import { $ } from "bun"
  2. import { describe, expect } from "bun:test"
  3. import fs from "fs/promises"
  4. import path from "path"
  5. import { Effect, Fiber, Layer, Stream } from "effect"
  6. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  7. import { Bus } from "@opencode-ai/core/bus"
  8. import { Location } from "@opencode-ai/core/location"
  9. import { AbsolutePath } from "@opencode-ai/core/schema"
  10. import { Vcs } from "@opencode-ai/core/vcs"
  11. import { FileSystem } from "@opencode-ai/schema/filesystem"
  12. import { VcsEvent } from "@opencode-ai/schema/vcs-event"
  13. import { location } from "./fixture/location"
  14. import { tmpdir } from "./fixture/tmpdir"
  15. import { it } from "./lib/effect"
  16. const provide = (directory: string, input: { git?: boolean } = {}) =>
  17. Effect.provide(
  18. LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
  19. [
  20. Location.node,
  21. Layer.succeed(
  22. Location.Service,
  23. Location.Service.of(
  24. location(
  25. { directory: AbsolutePath.make(directory) },
  26. input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
  27. ),
  28. ),
  29. ),
  30. ],
  31. ]),
  32. )
  33. const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
  34. Effect.acquireRelease(
  35. Effect.promise(() => tmpdir()),
  36. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  37. ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
  38. const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
  39. withTmp((directory) =>
  40. Effect.promise(() => initRepo(directory)).pipe(
  41. Effect.andThen(f(directory).pipe(provide(directory, { git: true }))),
  42. ),
  43. )
  44. async function initRepo(directory: string) {
  45. await $`git init -b main`.cwd(directory).quiet()
  46. await $`git config core.fsmonitor false`.cwd(directory).quiet()
  47. await $`git config commit.gpgsign false`.cwd(directory).quiet()
  48. await $`git config user.email test@opencode.test`.cwd(directory).quiet()
  49. await $`git config user.name Test`.cwd(directory).quiet()
  50. }
  51. async function commitAll(directory: string, message: string) {
  52. await $`git add -A`.cwd(directory).quiet()
  53. await $`git commit -m ${message}`.cwd(directory).quiet()
  54. }
  55. describe("Vcs", () => {
  56. it.live("returns empty results outside version control", () =>
  57. withTmp((directory) =>
  58. Effect.gen(function* () {
  59. const vcs = yield* Vcs.Service
  60. expect(yield* vcs.info()).toEqual({ branch: {} })
  61. expect(yield* vcs.status()).toEqual([])
  62. expect(yield* vcs.diff("working")).toEqual([])
  63. expect(yield* vcs.diff("branch")).toEqual([])
  64. }).pipe(provide(directory)),
  65. ),
  66. )
  67. it.live("reports modified, deleted, and untracked files", () =>
  68. withGit((directory) =>
  69. Effect.gen(function* () {
  70. yield* Effect.promise(async () => {
  71. await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
  72. await fs.writeFile(path.join(directory, "gone.txt"), "bye\n")
  73. await commitAll(directory, "initial")
  74. await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n")
  75. await fs.rm(path.join(directory, "gone.txt"))
  76. await fs.writeFile(path.join(directory, "new.txt"), "hello\nworld\n")
  77. })
  78. const vcs = yield* Vcs.Service
  79. const status = yield* vcs.status()
  80. expect(status).toEqual([
  81. { file: "gone.txt", additions: 0, deletions: 1, status: "deleted" },
  82. { file: "keep.txt", additions: 1, deletions: 1, status: "modified" },
  83. { file: "new.txt", additions: 2, deletions: 0, status: "added" },
  84. ])
  85. }),
  86. ),
  87. )
  88. it.live("caches branch info and publishes HEAD changes", () =>
  89. withGit((directory) =>
  90. Effect.gen(function* () {
  91. yield* Effect.promise(async () => {
  92. await fs.writeFile(path.join(directory, "file.txt"), "one\n")
  93. await commitAll(directory, "initial")
  94. })
  95. const vcs = yield* Vcs.Service
  96. const bus = yield* Bus.Service
  97. expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
  98. const updated = yield* bus
  99. .subscribe(VcsEvent.BranchUpdated)
  100. .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
  101. yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
  102. yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
  103. expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
  104. yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
  105. expect(yield* Fiber.join(updated)).toMatchObject({
  106. _tag: "Some",
  107. value: { location: { directory }, data: { branch: "feature" } },
  108. })
  109. expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
  110. }),
  111. ),
  112. )
  113. it.live("diffs the working copy against HEAD with patches", () =>
  114. withGit((directory) =>
  115. Effect.gen(function* () {
  116. yield* Effect.promise(async () => {
  117. await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
  118. await commitAll(directory, "initial")
  119. await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n")
  120. await fs.writeFile(path.join(directory, "spaced name.txt"), "hello\n")
  121. })
  122. const vcs = yield* Vcs.Service
  123. const diff = yield* vcs.diff("working")
  124. expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
  125. { file: "keep.txt", status: "modified" },
  126. { file: "spaced name.txt", status: "added" },
  127. ])
  128. expect(diff[0].patch).toContain("-two")
  129. expect(diff[0].patch).toContain("+three")
  130. expect(diff[0].additions).toBe(1)
  131. expect(diff[0].deletions).toBe(1)
  132. expect(diff[1].patch).toContain("+hello")
  133. expect(diff[1].additions).toBe(1)
  134. }),
  135. ),
  136. )
  137. it.live("respects the context option", () =>
  138. withGit((directory) =>
  139. Effect.gen(function* () {
  140. const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n"
  141. yield* Effect.promise(async () => {
  142. await fs.writeFile(path.join(directory, "file.txt"), body)
  143. await commitAll(directory, "initial")
  144. await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed"))
  145. })
  146. const vcs = yield* Vcs.Service
  147. const full = yield* vcs.diff("working")
  148. expect(full[0].patch).toContain("line-0")
  149. expect(full[0].patch).toContain("line-19")
  150. const tight = yield* vcs.diff("working", { context: 1 })
  151. expect(tight[0].patch).toContain("line-9")
  152. expect(tight[0].patch).not.toContain("line-0")
  153. }),
  154. ),
  155. )
  156. it.live("diffs before the first commit", () =>
  157. withGit((directory) =>
  158. Effect.gen(function* () {
  159. yield* Effect.promise(async () => {
  160. await fs.writeFile(path.join(directory, "new.txt"), "hello\n")
  161. })
  162. const vcs = yield* Vcs.Service
  163. expect(yield* vcs.status()).toEqual([{ file: "new.txt", additions: 1, deletions: 0, status: "added" }])
  164. const diff = yield* vcs.diff("working")
  165. expect(diff).toHaveLength(1)
  166. expect(diff[0].patch).toContain("+hello")
  167. }),
  168. ),
  169. )
  170. it.live("diffs a feature branch against the default branch", () =>
  171. withGit((directory) =>
  172. Effect.gen(function* () {
  173. yield* Effect.promise(async () => {
  174. await fs.writeFile(path.join(directory, "file.txt"), "one\n")
  175. await commitAll(directory, "initial")
  176. })
  177. const vcs = yield* Vcs.Service
  178. expect(yield* vcs.diff("branch")).toEqual([])
  179. yield* Effect.promise(async () => {
  180. await $`git checkout -q -b feature`.cwd(directory).quiet()
  181. await fs.writeFile(path.join(directory, "file.txt"), "one\ntwo\n")
  182. await commitAll(directory, "feature change")
  183. })
  184. const diff = yield* vcs.diff("branch")
  185. expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
  186. { file: "file.txt", status: "modified" },
  187. ])
  188. expect(diff[0].patch).toContain("+two")
  189. }),
  190. ),
  191. )
  192. })