snapshot.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  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. yield* snapshot.restore({ files: plan })
  112. expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
  113. expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
  114. expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n")
  115. }).pipe(Effect.provide(layer))
  116. }),
  117. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  118. ),
  119. )
  120. testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
  121. Effect.acquireUseRelease(
  122. Effect.promise(() => tmpdir()),
  123. (tmp) =>
  124. Effect.gen(function* () {
  125. expect(
  126. yield* Effect.gen(function* () {
  127. const snapshot = yield* Snapshot.Service
  128. return yield* snapshot.capture()
  129. }).pipe(Effect.provide(snapshotLayer(tmp.path, tmp.path))),
  130. ).toBeUndefined()
  131. }),
  132. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  133. ),
  134. )
  135. testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
  136. Effect.acquireUseRelease(
  137. Effect.promise(() => tmpdir()),
  138. (tmp) =>
  139. Effect.gen(function* () {
  140. const project = path.join(tmp.path, "project")
  141. const linked = path.join(tmp.path, "linked")
  142. yield* Effect.promise(async () => {
  143. await fs.mkdir(project)
  144. await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
  145. await initGit(project, true)
  146. await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
  147. })
  148. const capture = (directory: string) =>
  149. Effect.gen(function* () {
  150. const snapshot = yield* Snapshot.Service
  151. return yield* snapshot.capture()
  152. }).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
  153. expect(yield* capture(project)).toBeDefined()
  154. expect(yield* capture(linked)).toBeDefined()
  155. const projectID = yield* Effect.gen(function* () {
  156. return (yield* Location.Service).project.id
  157. }).pipe(
  158. Effect.provide(
  159. AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
  160. ),
  161. )
  162. expect(
  163. yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
  164. ).toBeDefined()
  165. expect(
  166. yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
  167. ).toBeDefined()
  168. }),
  169. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  170. ),
  171. )
  172. })
  173. function snapshotLayer(data: string, directory: string) {
  174. return AppNodeBuilder.build(Snapshot.node, [
  175. [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))],
  176. [Global.node, Global.layerWith({ data, config: path.join(data, "config") })],
  177. ])
  178. }
  179. function read(file: string) {
  180. return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
  181. }
  182. async function initGit(directory: string, commit = false) {
  183. await $`git init`.cwd(directory).quiet()
  184. await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
  185. if (!commit) return
  186. await $`git -c user.email=test@opencode.test -c user.name=Test commit --no-gpg-sign -m initial`
  187. .cwd(directory)
  188. .quiet()
  189. }