snapshot.test.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  7. import { Global } from "@opencode-ai/core/global"
  8. import { Location } from "@opencode-ai/core/location"
  9. import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
  10. import { Snapshot } from "@opencode-ai/core/snapshot"
  11. import { Hash } from "@opencode-ai/core/util/hash"
  12. import { tmpdir } from "./fixture/tmpdir"
  13. import { testEffect } from "./lib/effect"
  14. describe("Snapshot", () => {
  15. testEffect(Layer.empty).live("captures and restores Location-scoped changes", () =>
  16. Effect.acquireUseRelease(
  17. Effect.promise(() => tmpdir()),
  18. (tmp) =>
  19. Effect.gen(function* () {
  20. const project = path.join(tmp.path, "project")
  21. const location = path.join(project, "scope")
  22. yield* Effect.promise(async () => {
  23. await fs.mkdir(location, { recursive: true })
  24. await fs.writeFile(path.join(location, "tracked.txt"), "one\n")
  25. await fs.writeFile(path.join(project, "outside.txt"), "outside\n")
  26. await $`git init`.cwd(project).quiet()
  27. await $`git config core.fsmonitor false`.cwd(project).quiet()
  28. await $`git config commit.gpgsign false`.cwd(project).quiet()
  29. await $`git config user.email test@opencode.test`.cwd(project).quiet()
  30. await $`git config user.name Test`.cwd(project).quiet()
  31. await $`git add .`.cwd(project).quiet()
  32. await $`git commit -m initial`.cwd(project).quiet()
  33. })
  34. const layer = snapshotLayer(tmp.path, location)
  35. yield* Effect.gen(function* () {
  36. const snapshot = yield* Snapshot.Service
  37. const before = yield* snapshot.capture()
  38. expect(before).toBeDefined()
  39. if (!before) return
  40. yield* Effect.promise(async () => {
  41. await fs.writeFile(path.join(location, "tracked.txt"), "two\n")
  42. await fs.writeFile(path.join(location, "added.txt"), "added\n")
  43. await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n")
  44. })
  45. const after = yield* snapshot.capture()
  46. expect(after).toBeDefined()
  47. if (!after) return
  48. expect(yield* snapshot.files({ from: before, to: after })).toEqual([
  49. RelativePath.make("scope/added.txt"),
  50. RelativePath.make("scope/tracked.txt"),
  51. ])
  52. const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
  53. const preview = yield* snapshot.preview({ files: plan, context: 1 })
  54. expect(preview).toHaveLength(1)
  55. expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
  56. yield* snapshot.restore({ files: plan })
  57. expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
  58. expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
  59. expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n")
  60. }).pipe(Effect.provide(layer))
  61. }),
  62. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  63. ),
  64. )
  65. testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
  66. Effect.acquireUseRelease(
  67. Effect.promise(() => tmpdir()),
  68. (tmp) =>
  69. Effect.gen(function* () {
  70. expect(
  71. yield* Effect.gen(function* () {
  72. const snapshot = yield* Snapshot.Service
  73. return yield* snapshot.capture()
  74. }).pipe(Effect.provide(snapshotLayer(tmp.path, tmp.path))),
  75. ).toBeUndefined()
  76. }),
  77. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  78. ),
  79. )
  80. testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
  81. Effect.acquireUseRelease(
  82. Effect.promise(() => tmpdir()),
  83. (tmp) =>
  84. Effect.gen(function* () {
  85. const project = path.join(tmp.path, "project")
  86. const linked = path.join(tmp.path, "linked")
  87. yield* Effect.promise(async () => {
  88. await fs.mkdir(project)
  89. await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
  90. await $`git init`.cwd(project).quiet()
  91. await $`git config core.fsmonitor false`.cwd(project).quiet()
  92. await $`git config commit.gpgsign false`.cwd(project).quiet()
  93. await $`git config user.email test@opencode.test`.cwd(project).quiet()
  94. await $`git config user.name Test`.cwd(project).quiet()
  95. await $`git add .`.cwd(project).quiet()
  96. await $`git commit -m initial`.cwd(project).quiet()
  97. await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet()
  98. })
  99. const capture = (directory: string) =>
  100. Effect.gen(function* () {
  101. const snapshot = yield* Snapshot.Service
  102. return yield* snapshot.capture()
  103. }).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
  104. expect(yield* capture(project)).toBeDefined()
  105. expect(yield* capture(linked)).toBeDefined()
  106. const projectID = yield* Effect.gen(function* () {
  107. return (yield* Location.Service).project.id
  108. }).pipe(
  109. Effect.provide(
  110. AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
  111. ),
  112. )
  113. expect(
  114. yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
  115. ).toBeDefined()
  116. expect(
  117. yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
  118. ).toBeDefined()
  119. }),
  120. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  121. ),
  122. )
  123. testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () =>
  124. Effect.acquireUseRelease(
  125. Effect.promise(() => tmpdir()),
  126. (tmp) =>
  127. Effect.gen(function* () {
  128. const project = path.join(tmp.path, "project")
  129. yield* Effect.promise(async () => {
  130. await fs.mkdir(project)
  131. await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
  132. await $`git init`.cwd(project).quiet()
  133. await $`git config core.fsmonitor false`.cwd(project).quiet()
  134. await $`git config commit.gpgsign false`.cwd(project).quiet()
  135. await $`git config user.email test@opencode.test`.cwd(project).quiet()
  136. await $`git config user.name Test`.cwd(project).quiet()
  137. await $`git add .`.cwd(project).quiet()
  138. await $`git commit -m initial`.cwd(project).quiet()
  139. })
  140. yield* Effect.gen(function* () {
  141. const snapshot = yield* Snapshot.Service
  142. const before = yield* snapshot.capture()
  143. expect(before).toBeDefined()
  144. if (!before) return
  145. yield* Effect.promise(async () => {
  146. await fs.writeFile(path.join(project, "tracked.txt"), "two\n")
  147. await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n")
  148. })
  149. yield* snapshot.checkout(before)
  150. expect(yield* read(path.join(project, "tracked.txt"))).toBe("one\n")
  151. expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n")
  152. }).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
  153. }),
  154. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  155. ),
  156. )
  157. })
  158. function snapshotLayer(data: string, directory: string) {
  159. return AppNodeBuilder.build(Snapshot.node, [
  160. [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))],
  161. [Global.node, Global.layerWith({ data, config: path.join(data, "config") })],
  162. ])
  163. }
  164. function read(file: string) {
  165. return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
  166. }