vcs-hg.test.ts 8.0 KB

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