feat(core): add session snapshot and revert system (#33226)
This commit is contained in:
parent
25c6abc31a
commit
9bb5370205
42 changed files with 2311 additions and 365 deletions
|
|
@ -4,7 +4,7 @@ import fs from "fs/promises"
|
|||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { branch, commit, gitRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
|
@ -16,14 +16,16 @@ describe("Git", () => {
|
|||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const target = path.join(fixture.root, "checkout")
|
||||
const result = yield* git.clone({ remote: fixture.remote, target })
|
||||
const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
|
||||
const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(yield* git.origin(target)).toBe(fixture.remote)
|
||||
expect(yield* git.head(target)).toBeString()
|
||||
expect(yield* git.branch(target)).toBe("main")
|
||||
expect(yield* git.remoteHead(target)).toBe("origin/main")
|
||||
expect(yield* git.remote.get(repository)).toBe(fixture.remote)
|
||||
expect(yield* git.history.head(repository)).toBeString()
|
||||
expect(yield* git.history.branch(repository)).toBe("main")
|
||||
expect(yield* git.history.defaultRemoteBranch(repository)).toBe("main")
|
||||
expect(repository.worktree).toBe(target)
|
||||
expect(repository.gitDirectory).toBe(AbsolutePath.make(path.join(target, ".git")))
|
||||
expect(repository.commonDirectory).toBe(repository.gitDirectory)
|
||||
expect(yield* read(path.join(target, "README.md"))).toBe("one\n")
|
||||
}),
|
||||
),
|
||||
|
|
@ -33,19 +35,19 @@ describe("Git", () => {
|
|||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const target = path.join(fixture.root, "checkout")
|
||||
yield* git.clone({ remote: fixture.remote, target })
|
||||
const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
|
||||
const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
|
||||
|
||||
yield* Effect.promise(() => commit(fixture.source, "two\n", "second"))
|
||||
expect((yield* git.fetch(target)).exitCode).toBe(0)
|
||||
expect((yield* git.reset(target, "origin/main")).exitCode).toBe(0)
|
||||
yield* git.sync.fetchRemotes(repository)
|
||||
yield* git.sync.resetHard(repository, "origin/main")
|
||||
expect(yield* read(path.join(target, "README.md"))).toBe("two\n")
|
||||
|
||||
yield* Effect.promise(() => branch(fixture.source, "feature/docs", "feature\n"))
|
||||
expect((yield* git.fetchBranch(target, "feature/docs")).exitCode).toBe(0)
|
||||
expect((yield* git.checkout(target, "feature/docs")).exitCode).toBe(0)
|
||||
expect((yield* git.reset(target, "origin/feature/docs")).exitCode).toBe(0)
|
||||
expect(yield* git.branch(target)).toBe("feature/docs")
|
||||
yield* git.sync.fetchBranch(repository, { branch: "feature/docs" })
|
||||
yield* git.sync.checkoutRemoteBranch(repository, { branch: "feature/docs" })
|
||||
yield* git.sync.resetHard(repository, "origin/feature/docs")
|
||||
expect(yield* git.history.branch(repository)).toBe("feature/docs")
|
||||
expect(yield* read(path.join(target, "README.md"))).toBe("feature\n")
|
||||
}),
|
||||
),
|
||||
|
|
@ -90,17 +92,72 @@ describe("Git worktrees", () => {
|
|||
Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const git = yield* Git.Service
|
||||
const repo = { directory, store: AbsolutePath.make(path.join(directory, ".git")) }
|
||||
const repo = yield* git.repo.discover(directory)
|
||||
if (!repo) throw new Error("Repository not found")
|
||||
|
||||
yield* git.worktreeCreate({ repo, directory: worktree })
|
||||
yield* git.worktree.create({ repository: repo, directory: worktree })
|
||||
|
||||
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true)
|
||||
const linked = yield* git.find(worktree)
|
||||
expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
|
||||
expect(linked?.store).toBe(repo.store)
|
||||
expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(true)
|
||||
const linked = yield* git.repo.discover(worktree)
|
||||
expect(linked?.worktree).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
|
||||
expect(linked?.commonDirectory).toBe(repo.commonDirectory)
|
||||
expect(linked?.gitDirectory).not.toBe(repo.gitDirectory)
|
||||
if (!linked) throw new Error("Linked worktree not found")
|
||||
yield* git.worktreeRemove({ repo: linked, directory: worktree, force: false })
|
||||
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
|
||||
yield* git.worktree.remove({ repository: linked, directory: worktree, force: false })
|
||||
expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Git trees", () => {
|
||||
it.live("captures, compares, previews, and restores scoped trees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(root.path)
|
||||
await fs.mkdir(path.join(root.path, "scope"))
|
||||
await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "one\n")
|
||||
await fs.writeFile(path.join(root.path, "outside.txt"), "outside\n")
|
||||
await $`git add .`.cwd(root.path).quiet()
|
||||
await $`git commit -m initial`.cwd(root.path).quiet()
|
||||
})
|
||||
const git = yield* Git.Service
|
||||
const source = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!source) throw new Error("Repository not found")
|
||||
const storage = AbsolutePath.make(path.join(root.path, ".snapshot"))
|
||||
const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
|
||||
yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
|
||||
const before = yield* git.tree.write(repository)
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "two\n")
|
||||
await fs.writeFile(path.join(root.path, "scope", "added.txt"), "added\n")
|
||||
await fs.writeFile(path.join(root.path, "outside.txt"), "changed outside\n")
|
||||
})
|
||||
yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
|
||||
const after = yield* git.tree.write(repository)
|
||||
|
||||
expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([
|
||||
RelativePath.make("scope/added.txt"),
|
||||
RelativePath.make("scope/tracked.txt"),
|
||||
])
|
||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 })
|
||||
expect(diffs.map((item) => [item.path, item.status])).toEqual([
|
||||
[RelativePath.make("scope/added.txt"), "added"],
|
||||
[RelativePath.make("scope/tracked.txt"), "modified"],
|
||||
])
|
||||
|
||||
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
yield* git.tree.restore({ repository, files })
|
||||
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
||||
expect(yield* read(path.join(root.path, "outside.txt"))).toBe("changed outside\n")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
|
|
@ -28,6 +29,7 @@ const project = Project.layer.pipe(
|
|||
Layer.provide(ProjectDirectories.defaultLayer),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(project),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Project } from "@opencode-ai/core/project"
|
|||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
|
|
@ -23,6 +24,7 @@ const current = Layer.succeed(
|
|||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
|
|
@ -34,6 +35,7 @@ const projects = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
|
|
@ -20,6 +21,7 @@ import { SessionInput } from "@opencode-ai/core/session/input"
|
|||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer))
|
||||
const sessionID = SessionV2.ID.make("ses_projector_test")
|
||||
|
|
@ -41,6 +43,24 @@ const assistantRow = (
|
|||
}
|
||||
|
||||
describe("SessionProjector", () => {
|
||||
it.effect("projects staged, cleared, and committed reverts", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db.insert(ProjectTable).values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }).run()
|
||||
yield* db.insert(SessionTable).values({ id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test" }).run()
|
||||
const boundary = SessionMessage.ID.make("msg_boundary")
|
||||
yield* db.insert(SessionMessageTable).values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)]).run()
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, timestamp: DateTime.makeUnsafe(1), revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] } })
|
||||
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({ messageID: boundary, snapshot: "tree", files: [] })
|
||||
yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID, timestamp: DateTime.makeUnsafe(2) })
|
||||
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull()
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, timestamp: DateTime.makeUnsafe(3), revert: { messageID: boundary, files: [] } })
|
||||
yield* events.publish(SessionEvent.RevertEvent.Committed, { sessionID, messageID: boundary, timestamp: DateTime.makeUnsafe(4) })
|
||||
expect((yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id)).toEqual([boundary])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders projected messages and context by durable aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
|
@ -110,6 +130,7 @@ describe("SessionProjector", () => {
|
|||
}).pipe(
|
||||
Effect.provide(
|
||||
SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Project } from "@opencode-ai/core/project"
|
|||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
|
|
@ -39,6 +40,7 @@ const execution = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { Project } from "@opencode-ai/core/project"
|
|||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
|
|
@ -71,6 +73,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc
|
|||
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
Layer.provide(Snapshot.noopLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
|
|
@ -99,6 +102,7 @@ const execution = Layer.effect(
|
|||
}),
|
||||
).pipe(Layer.provide(runner))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -125,3 +125,12 @@ test("old success event data containing result still decodes", () => {
|
|||
})
|
||||
expect(decoded.result).toMatchObject({ type: "content" })
|
||||
})
|
||||
|
||||
test("step finish records settlement without publishing step ended", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
|
||||
|
||||
expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false)
|
||||
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
|
|
@ -230,6 +232,7 @@ const config = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
const runner = SessionRunnerLLM.layer.pipe(
|
||||
Layer.provide(Snapshot.noopLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
|
|
@ -258,6 +261,7 @@ const execution = Layer.effect(
|
|||
}),
|
||||
).pipe(Layer.provide(runner))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
|
|
|
|||
185
packages/core/test/snapshot.test.ts
Normal file
185
packages/core/test/snapshot.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
describe("Snapshot", () => {
|
||||
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 $`git init`.cwd(project).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(project).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(project).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(project).quiet()
|
||||
await $`git config user.name Test`.cwd(project).quiet()
|
||||
await $`git add .`.cwd(project).quiet()
|
||||
await $`git commit -m initial`.cwd(project).quiet()
|
||||
})
|
||||
|
||||
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]?.path).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 $`git init`.cwd(project).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(project).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(project).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(project).quiet()
|
||||
await $`git config user.name Test`.cwd(project).quiet()
|
||||
await $`git add .`.cwd(project).quiet()
|
||||
await $`git commit -m initial`.cwd(project).quiet()
|
||||
await $`git 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(
|
||||
Location.layer(Location.Ref.make({ directory: AbsolutePath.make(project) })).pipe(
|
||||
Layer.provide(Project.defaultLayer),
|
||||
),
|
||||
),
|
||||
)
|
||||
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 $`git init`.cwd(project).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(project).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(project).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(project).quiet()
|
||||
await $`git config user.name Test`.cwd(project).quiet()
|
||||
await $`git add .`.cwd(project).quiet()
|
||||
await $`git commit -m initial`.cwd(project).quiet()
|
||||
})
|
||||
|
||||
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) {
|
||||
const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe(
|
||||
Layer.provide(Project.defaultLayer),
|
||||
)
|
||||
return Snapshot.layer.pipe(
|
||||
Layer.provide(location),
|
||||
Layer.provide(Config.locationLayer.pipe(Layer.provide(location))),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(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")))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue