vcs.test.ts 6.7 KB

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