snapshot.test.ts 11 KB

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