snapshot.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import { $ } from "bun"
  2. import { describe, expect } from "bun:test"
  3. import fs from "fs/promises"
  4. import path from "path"
  5. import { Deferred, Effect, Fiber, Layer } from "effect"
  6. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  7. import { Git } from "@opencode-ai/core/git"
  8. import { Global } from "@opencode-ai/util/global"
  9. import { Location } from "@opencode-ai/core/location"
  10. import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
  11. import { Snapshot } from "@opencode-ai/core/snapshot"
  12. import { Hash } from "@opencode-ai/util/hash"
  13. import { tmpdir } from "./fixture/tmpdir"
  14. import { testEffect } from "./lib/effect"
  15. describe("Snapshot", () => {
  16. testEffect(Layer.empty).live("keeps lazy repository discovery after the first caller is interrupted", () =>
  17. Effect.acquireUseRelease(
  18. Effect.promise(() => tmpdir()),
  19. (tmp) =>
  20. Effect.gen(function* () {
  21. const project = path.join(tmp.path, "project")
  22. yield* Effect.promise(async () => {
  23. await fs.mkdir(project)
  24. await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
  25. await initGit(project)
  26. })
  27. const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
  28. const location = yield* Location.Service.pipe(
  29. Effect.provide(
  30. AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
  31. ),
  32. )
  33. const started = yield* Deferred.make<void>()
  34. const release = yield* Deferred.make<void>()
  35. let discoveries = 0
  36. let creations = 0
  37. const instrumented = Git.Service.of({
  38. ...git,
  39. repo: {
  40. ...git.repo,
  41. discover: (input) => {
  42. discoveries++
  43. return git.repo.discover(input)
  44. },
  45. create: (input) =>
  46. Effect.gen(function* () {
  47. creations++
  48. yield* Deferred.succeed(started, undefined)
  49. yield* Deferred.await(release)
  50. return yield* git.repo.create(input)
  51. }),
  52. },
  53. })
  54. const layer = AppNodeBuilder.build(Snapshot.node, [
  55. [Location.node, Layer.succeed(Location.Service, location)],
  56. [Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
  57. [Git.node, Layer.succeed(Git.Service, instrumented)],
  58. ])
  59. yield* Effect.gen(function* () {
  60. const snapshot = yield* Snapshot.Service
  61. expect(discoveries).toBe(0)
  62. const interrupted = yield* snapshot.capture().pipe(Effect.forkChild)
  63. yield* Deferred.await(started)
  64. expect(discoveries).toBe(1)
  65. expect(creations).toBe(1)
  66. yield* Fiber.interrupt(interrupted)
  67. const capture = yield* snapshot.capture().pipe(Effect.forkChild)
  68. expect(discoveries).toBe(1)
  69. expect(creations).toBe(1)
  70. yield* Deferred.succeed(release, undefined)
  71. expect(yield* Fiber.join(capture)).toBeDefined()
  72. expect(discoveries).toBe(1)
  73. expect(creations).toBe(1)
  74. }).pipe(Effect.provide(layer))
  75. }),
  76. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  77. ),
  78. )
  79. testEffect(Layer.empty).live("captures and restores Location-scoped changes", () =>
  80. Effect.acquireUseRelease(
  81. Effect.promise(() => tmpdir()),
  82. (tmp) =>
  83. Effect.gen(function* () {
  84. const project = path.join(tmp.path, "project")
  85. const location = path.join(project, "scope")
  86. yield* Effect.promise(async () => {
  87. await fs.mkdir(location, { recursive: true })
  88. await fs.writeFile(path.join(location, "tracked.txt"), "one\n")
  89. await fs.writeFile(path.join(project, "outside.txt"), "outside\n")
  90. await initGit(project)
  91. })
  92. const layer = snapshotLayer(tmp.path, location)
  93. yield* Effect.gen(function* () {
  94. const snapshot = yield* Snapshot.Service
  95. const before = yield* snapshot.capture()
  96. expect(before).toBeDefined()
  97. if (!before) return
  98. yield* Effect.promise(async () => {
  99. await fs.writeFile(path.join(location, "tracked.txt"), "two\n")
  100. await fs.writeFile(path.join(location, "added.txt"), "added\n")
  101. await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n")
  102. })
  103. const after = yield* snapshot.capture()
  104. expect(after).toBeDefined()
  105. if (!after) return
  106. expect(yield* snapshot.files({ from: before, to: after })).toEqual([
  107. RelativePath.make("scope/added.txt"),
  108. RelativePath.make("scope/tracked.txt"),
  109. ])
  110. const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
  111. const preview = yield* snapshot.preview({ files: plan, context: 1 })
  112. expect(preview).toHaveLength(1)
  113. expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
  114. yield* snapshot.restore({ files: plan })
  115. expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
  116. expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
  117. expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n")
  118. }).pipe(Effect.provide(layer))
  119. }),
  120. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  121. ),
  122. )
  123. testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
  124. Effect.acquireUseRelease(
  125. Effect.promise(() => tmpdir()),
  126. (tmp) =>
  127. Effect.gen(function* () {
  128. expect(
  129. yield* Effect.gen(function* () {
  130. const snapshot = yield* Snapshot.Service
  131. return yield* snapshot.capture()
  132. }).pipe(Effect.provide(snapshotLayer(tmp.path, tmp.path))),
  133. ).toBeUndefined()
  134. }),
  135. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  136. ),
  137. )
  138. testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
  139. Effect.acquireUseRelease(
  140. Effect.promise(() => tmpdir()),
  141. (tmp) =>
  142. Effect.gen(function* () {
  143. const project = path.join(tmp.path, "project")
  144. const linked = path.join(tmp.path, "linked")
  145. yield* Effect.promise(async () => {
  146. await fs.mkdir(project)
  147. await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
  148. await initGit(project, true)
  149. await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
  150. })
  151. const capture = (directory: string) =>
  152. Effect.gen(function* () {
  153. const snapshot = yield* Snapshot.Service
  154. return yield* snapshot.capture()
  155. }).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
  156. expect(yield* capture(project)).toBeDefined()
  157. expect(yield* capture(linked)).toBeDefined()
  158. const projectID = yield* Effect.gen(function* () {
  159. return (yield* Location.Service).project.id
  160. }).pipe(
  161. Effect.provide(
  162. AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
  163. ),
  164. )
  165. expect(
  166. yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
  167. ).toBeDefined()
  168. expect(
  169. yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
  170. ).toBeDefined()
  171. }),
  172. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  173. ),
  174. )
  175. testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () =>
  176. Effect.acquireUseRelease(
  177. Effect.promise(() => tmpdir()),
  178. (tmp) =>
  179. Effect.gen(function* () {
  180. const project = path.join(tmp.path, "project")
  181. yield* Effect.promise(async () => {
  182. await fs.mkdir(project)
  183. await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
  184. await initGit(project)
  185. })
  186. yield* Effect.gen(function* () {
  187. const snapshot = yield* Snapshot.Service
  188. const before = yield* snapshot.capture()
  189. expect(before).toBeDefined()
  190. if (!before) return
  191. yield* Effect.promise(async () => {
  192. await fs.writeFile(path.join(project, "tracked.txt"), "two\n")
  193. await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n")
  194. })
  195. yield* snapshot.checkout(before)
  196. expect(yield* read(path.join(project, "tracked.txt"))).toBe("one\n")
  197. expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n")
  198. }).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
  199. }),
  200. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  201. ),
  202. )
  203. })
  204. function snapshotLayer(data: string, directory: string) {
  205. return AppNodeBuilder.build(Snapshot.node, [
  206. [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))],
  207. [Global.node, Global.layerWith({ data, config: path.join(data, "config") })],
  208. ])
  209. }
  210. function read(file: string) {
  211. return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
  212. }
  213. async function initGit(directory: string, commit = false) {
  214. await $`git init`.cwd(directory).quiet()
  215. await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
  216. if (!commit) return
  217. await $`git -c user.email=test@opencode.test -c user.name=Test commit --no-gpg-sign -m initial`
  218. .cwd(directory)
  219. .quiet()
  220. }