snapshot.test.ts 8.4 KB

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