| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- import { $ } from "bun"
- import { describe, expect } from "bun:test"
- import fs from "fs/promises"
- import path from "path"
- import { Deferred, Effect, Fiber, Layer } from "effect"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { Git } from "@opencode-ai/core/git"
- import { Global } from "@opencode-ai/util/global"
- import { Location } from "@opencode-ai/core/location"
- import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
- import { Snapshot } from "@opencode-ai/core/snapshot"
- import { Hash } from "@opencode-ai/util/hash"
- import { tmpdir } from "./fixture/tmpdir"
- import { testEffect } from "./lib/effect"
- describe("Snapshot", () => {
- testEffect(Layer.empty).live("keeps lazy repository discovery after the first caller is interrupted", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.gen(function* () {
- const project = path.join(tmp.path, "project")
- yield* Effect.promise(async () => {
- await fs.mkdir(project)
- await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
- await initGit(project)
- })
- const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
- const location = yield* Location.Service.pipe(
- Effect.provide(
- AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
- ),
- )
- const started = yield* Deferred.make<void>()
- const release = yield* Deferred.make<void>()
- let discoveries = 0
- let creations = 0
- const instrumented = Git.Service.of({
- ...git,
- repo: {
- ...git.repo,
- discover: (input) => {
- discoveries++
- return git.repo.discover(input)
- },
- create: (input) =>
- Effect.gen(function* () {
- creations++
- yield* Deferred.succeed(started, undefined)
- yield* Deferred.await(release)
- return yield* git.repo.create(input)
- }),
- },
- })
- const layer = AppNodeBuilder.build(Snapshot.node, [
- [Location.node, Layer.succeed(Location.Service, location)],
- [Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
- [Git.node, Layer.succeed(Git.Service, instrumented)],
- ])
- yield* Effect.gen(function* () {
- const snapshot = yield* Snapshot.Service
- expect(discoveries).toBe(0)
- const interrupted = yield* snapshot.capture().pipe(Effect.forkChild)
- yield* Deferred.await(started)
- expect(discoveries).toBe(1)
- expect(creations).toBe(1)
- yield* Fiber.interrupt(interrupted)
- const capture = yield* snapshot.capture().pipe(Effect.forkChild)
- expect(discoveries).toBe(1)
- expect(creations).toBe(1)
- yield* Deferred.succeed(release, undefined)
- expect(yield* Fiber.join(capture)).toBeDefined()
- expect(discoveries).toBe(1)
- expect(creations).toBe(1)
- }).pipe(Effect.provide(layer))
- }),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- testEffect(Layer.empty).live("captures and restores Location-scoped changes", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.gen(function* () {
- const project = path.join(tmp.path, "project")
- const location = path.join(project, "scope")
- yield* Effect.promise(async () => {
- await fs.mkdir(location, { recursive: true })
- await fs.writeFile(path.join(location, "tracked.txt"), "one\n")
- await fs.writeFile(path.join(project, "outside.txt"), "outside\n")
- await initGit(project)
- })
- const layer = snapshotLayer(tmp.path, location)
- yield* Effect.gen(function* () {
- const snapshot = yield* Snapshot.Service
- const before = yield* snapshot.capture()
- expect(before).toBeDefined()
- if (!before) return
- yield* Effect.promise(async () => {
- await fs.writeFile(path.join(location, "tracked.txt"), "two\n")
- await fs.writeFile(path.join(location, "added.txt"), "added\n")
- await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n")
- })
- const after = yield* snapshot.capture()
- expect(after).toBeDefined()
- if (!after) return
- expect(yield* snapshot.files({ from: before, to: after })).toEqual([
- RelativePath.make("scope/added.txt"),
- RelativePath.make("scope/tracked.txt"),
- ])
- const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
- const preview = yield* snapshot.preview({ files: plan, context: 1 })
- expect(preview).toHaveLength(1)
- expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
- yield* snapshot.restore({ files: plan })
- expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
- expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
- expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n")
- }).pipe(Effect.provide(layer))
- }),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.gen(function* () {
- expect(
- yield* Effect.gen(function* () {
- const snapshot = yield* Snapshot.Service
- return yield* snapshot.capture()
- }).pipe(Effect.provide(snapshotLayer(tmp.path, tmp.path))),
- ).toBeUndefined()
- }),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.gen(function* () {
- const project = path.join(tmp.path, "project")
- const linked = path.join(tmp.path, "linked")
- yield* Effect.promise(async () => {
- await fs.mkdir(project)
- await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
- await initGit(project, true)
- await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
- })
- const capture = (directory: string) =>
- Effect.gen(function* () {
- const snapshot = yield* Snapshot.Service
- return yield* snapshot.capture()
- }).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
- expect(yield* capture(project)).toBeDefined()
- expect(yield* capture(linked)).toBeDefined()
- const projectID = yield* Effect.gen(function* () {
- return (yield* Location.Service).project.id
- }).pipe(
- Effect.provide(
- AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
- ),
- )
- expect(
- yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
- ).toBeDefined()
- expect(
- yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
- ).toBeDefined()
- }),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.gen(function* () {
- const project = path.join(tmp.path, "project")
- yield* Effect.promise(async () => {
- await fs.mkdir(project)
- await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
- await initGit(project)
- })
- yield* Effect.gen(function* () {
- const snapshot = yield* Snapshot.Service
- const before = yield* snapshot.capture()
- expect(before).toBeDefined()
- if (!before) return
- yield* Effect.promise(async () => {
- await fs.writeFile(path.join(project, "tracked.txt"), "two\n")
- await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n")
- })
- yield* snapshot.checkout(before)
- expect(yield* read(path.join(project, "tracked.txt"))).toBe("one\n")
- expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n")
- }).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
- }),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- })
- function snapshotLayer(data: string, directory: string) {
- return AppNodeBuilder.build(Snapshot.node, [
- [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))],
- [Global.node, Global.layerWith({ data, config: path.join(data, "config") })],
- ])
- }
- function read(file: string) {
- return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
- }
- async function initGit(directory: string, commit = false) {
- await $`git init`.cwd(directory).quiet()
- await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
- if (!commit) return
- await $`git -c user.email=test@opencode.test -c user.name=Test commit --no-gpg-sign -m initial`
- .cwd(directory)
- .quiet()
- }
|