mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 18:06:25 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79fa67df51 | ||
|
|
cc12d7b07d | ||
|
|
f8bd499de5 | ||
|
|
0002d1df81 | ||
|
|
24a226ead0 | ||
|
|
f07fb81a6e |
+114
-23
@@ -14,6 +14,8 @@ export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
gitDirectory: AbsolutePath,
|
||||
commonDirectory: AbsolutePath,
|
||||
/** Additional read-only object databases; index and worktree remain local. */
|
||||
objectDirectories: Schema.optional(Schema.Array(AbsolutePath)),
|
||||
}) {}
|
||||
|
||||
// Included from $GIT_DIR/config via include.path (git >= 1.7.10); OpenCode owns
|
||||
@@ -50,6 +52,7 @@ export class OperationError extends Schema.TaggedError<OperationError>()("Git.Op
|
||||
"list_files",
|
||||
"diff",
|
||||
"restore",
|
||||
"retain",
|
||||
]),
|
||||
message: Schema.String,
|
||||
directory: Schema.optional(AbsolutePath),
|
||||
@@ -132,6 +135,12 @@ export interface Interface {
|
||||
}) => Effect.Effect<ReadonlySet<RelativePath>, OperationError>
|
||||
}
|
||||
readonly tree: {
|
||||
readonly exists: (repository: Repository, tree: TreeID) => Effect.Effect<boolean>
|
||||
/** Retain a tree's borrowed objects in this repository, independent of its alternates. */
|
||||
readonly retain: (input: {
|
||||
repository: Repository
|
||||
trees: readonly TreeID[]
|
||||
}) => Effect.Effect<void, OperationError>
|
||||
readonly capture: (input: {
|
||||
repository: Repository
|
||||
scopes: readonly RelativePath[]
|
||||
@@ -314,7 +323,16 @@ const layer = Layer.effect(
|
||||
.run(
|
||||
ChildProcess.make("git", repositoryArgs(repository, args), {
|
||||
cwd: repository.worktree,
|
||||
env: options?.env,
|
||||
env: {
|
||||
...(repository.objectDirectories?.length
|
||||
? {
|
||||
GIT_ALTERNATE_OBJECT_DIRECTORIES: repository.objectDirectories
|
||||
.map((directory) => JSON.stringify(directory))
|
||||
.join(path.delimiter),
|
||||
}
|
||||
: {}),
|
||||
...options?.env,
|
||||
},
|
||||
extendEnv: true,
|
||||
}),
|
||||
{ stdin: options?.stdin },
|
||||
@@ -477,6 +495,46 @@ const layer = Layer.effect(
|
||||
return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim())
|
||||
})
|
||||
|
||||
const treeExists = Effect.fn("Git.tree.exists")((repository: Repository, tree: TreeID) =>
|
||||
repositoryOperation("list_files", repository, ["cat-file", "-e", `${tree}^{tree}`]).pipe(
|
||||
Effect.as(true),
|
||||
Effect.orElseSucceed(() => false),
|
||||
),
|
||||
)
|
||||
|
||||
const retain = Effect.fn("Git.tree.retain")((input: { repository: Repository; trees: readonly TreeID[] }) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.gen(function* () {
|
||||
const retained = (yield* repositoryOperation("retain", input.repository, [
|
||||
"for-each-ref",
|
||||
"--format=%(objectname)",
|
||||
"refs/opencode/snapshots/",
|
||||
])).text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
const missing = Array.from(new Set(input.trees)).filter((tree) => !retained.includes(tree))
|
||||
if (!missing.length) return
|
||||
// Only these refs certify locally retained objects. Ordinary refs or alternates do not.
|
||||
yield* repositoryOperation(
|
||||
"retain",
|
||||
input.repository,
|
||||
[
|
||||
"pack-objects",
|
||||
"--revs",
|
||||
"--non-empty",
|
||||
path.join(input.repository.gitDirectory, "objects", "pack", "pack"),
|
||||
],
|
||||
{ stdin: [...missing, ...retained.map((tree) => `^${tree}`)].join("\n") + "\n" },
|
||||
)
|
||||
yield* repositoryOperation("retain", input.repository, ["update-ref", "--stdin"], {
|
||||
stdin: missing.map((tree) => `update refs/opencode/snapshots/${tree} ${tree}\n`).join(""),
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const captureTree = Effect.fn("Git.tree.capture")(
|
||||
(input: {
|
||||
repository: Repository
|
||||
@@ -586,28 +644,59 @@ const layer = Layer.effect(
|
||||
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.forEach(
|
||||
input.files,
|
||||
([file, tree]) =>
|
||||
Effect.gen(function* () {
|
||||
if (yield* hasEntry(input.repository, tree, file)) {
|
||||
yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file])
|
||||
return
|
||||
}
|
||||
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: "restore",
|
||||
directory: input.repository.worktree,
|
||||
message: `Failed to remove ${file}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* Effect.forEach(input.files, ([file, tree]) =>
|
||||
hasEntry(input.repository, tree, file).pipe(
|
||||
Effect.map((present) => ({ file, tree, present, depth: file.split("/").length })),
|
||||
),
|
||||
)
|
||||
// Remove descendants before any restoration can replace their ancestor with a symlink or file.
|
||||
yield* Effect.forEach(
|
||||
entries.toSorted(
|
||||
(a, b) => Number(a.present) - Number(b.present) || (a.present ? a.depth - b.depth : b.depth - a.depth),
|
||||
),
|
||||
({ file, tree, present }) =>
|
||||
Effect.gen(function* () {
|
||||
if (present) {
|
||||
yield* repositoryOperation("restore", input.repository, [
|
||||
"--literal-pathspecs",
|
||||
"restore",
|
||||
`--source=${tree}`,
|
||||
...(input.repository.objectDirectories?.length ? [] : ["--staged"]),
|
||||
"--worktree",
|
||||
"--",
|
||||
file,
|
||||
])
|
||||
// Re-index foreign content without alternates so future local captures can read it.
|
||||
if (input.repository.objectDirectories?.length)
|
||||
yield* repositoryOperation(
|
||||
"restore",
|
||||
new Repository({ ...input.repository, objectDirectories: undefined }),
|
||||
["--literal-pathspecs", "add", "--force", "--sparse", "--", file],
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: "restore",
|
||||
directory: input.repository.worktree,
|
||||
message: `Failed to remove ${file}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* repositoryOperation("restore", input.repository, [
|
||||
"update-index",
|
||||
"--force-remove",
|
||||
"--",
|
||||
file,
|
||||
])
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -690,6 +779,8 @@ const layer = Layer.effect(
|
||||
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
|
||||
index: { refresh, ignored },
|
||||
tree: {
|
||||
exists: treeExists,
|
||||
retain,
|
||||
capture: captureTree,
|
||||
write: writeTree,
|
||||
files: treeFiles,
|
||||
|
||||
+114
-17
@@ -42,9 +42,9 @@ export type Draft = {
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
/**
|
||||
* Capture the current Location-scoped filesystem state as a content-addressed
|
||||
* tree. Returns `undefined` when snapshots are disabled, unsupported, or the
|
||||
* best-effort capture fails.
|
||||
* Capture the current Location-scoped filesystem state as an opaque reference
|
||||
* to a content-addressed tree and its storage. Returns `undefined` when
|
||||
* snapshots are disabled, unsupported, or the best-effort capture fails.
|
||||
*/
|
||||
readonly capture: () => Effect.Effect<ID | undefined>
|
||||
|
||||
@@ -100,7 +100,18 @@ const layer = Layer.effect(
|
||||
: yield* git.repo
|
||||
.create({ worktree, gitDirectory, seed: source })
|
||||
.pipe(Effect.mapError((cause) => failure("capture", cause)))
|
||||
return { source, worktree, snapshotRepository }
|
||||
return {
|
||||
source,
|
||||
worktree,
|
||||
snapshotRepository,
|
||||
foreignRepository: (directory: AbsolutePath) =>
|
||||
new Git.Repository({
|
||||
worktree,
|
||||
gitDirectory: directory,
|
||||
commonDirectory: directory,
|
||||
objectDirectories: [AbsolutePath.make(path.join(source.commonDirectory, "objects"))],
|
||||
}),
|
||||
}
|
||||
}).pipe(Effect.forkIn(lifetime)),
|
||||
)
|
||||
const repository = repositoryFiber.pipe(Effect.uninterruptible, Effect.flatMap(Fiber.join))
|
||||
@@ -114,18 +125,80 @@ const layer = Layer.effect(
|
||||
|
||||
const enabled = () => location.vcs?.type === "git" && state.get().enabled
|
||||
|
||||
const resolved = new Map<ID, { directory: AbsolutePath; tree: Git.TreeID }>()
|
||||
const resolve = Effect.fnUntraced(function* (id: ID) {
|
||||
const cached = resolved.get(id)
|
||||
if (cached) return cached
|
||||
const qualified = /^snapshot:([a-zA-Z0-9_-]+)\/([a-f0-9]{40})\/([a-f0-9]{40}|[a-f0-9]{64})$/.exec(id)
|
||||
if (qualified)
|
||||
return {
|
||||
directory: AbsolutePath.make(path.join(global.data, "snapshot", qualified[1], qualified[2])),
|
||||
tree: Git.TreeID.make(qualified[3]),
|
||||
}
|
||||
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(id))
|
||||
return yield* new Error({ operation: "restore", message: "Invalid snapshot reference" })
|
||||
const repo = yield* repository
|
||||
const tree = Git.TreeID.make(id)
|
||||
if (yield* git.tree.exists(repo.snapshotRepository, tree))
|
||||
return { directory: repo.snapshotRepository.gitDirectory, tree }
|
||||
// Persisted hash-only IDs predate storage-qualified references. Search only on a local miss.
|
||||
const root = path.join(global.data, "snapshot")
|
||||
const projects = yield* fs.readDirectoryEntries(root).pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
for (const project of projects.filter(
|
||||
(entry) => entry.type === "directory" && /^[a-zA-Z0-9_-]+$/.test(entry.name),
|
||||
)) {
|
||||
const stores = yield* fs
|
||||
.readDirectoryEntries(path.join(root, project.name))
|
||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
for (const store of stores.filter((entry) => entry.type === "directory" && /^[a-f0-9]{40}$/.test(entry.name))) {
|
||||
const directory = AbsolutePath.make(path.join(root, project.name, store.name))
|
||||
const candidate = repo.foreignRepository(directory)
|
||||
if (!(yield* git.tree.exists(candidate, tree))) continue
|
||||
// Identical legacy trees can exist in several stores; skip incomplete copies.
|
||||
if (
|
||||
!(yield* git.tree.retain({ repository: candidate, trees: [tree] }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.orElseSucceed(() => false),
|
||||
))
|
||||
)
|
||||
continue
|
||||
const result = { directory, tree }
|
||||
resolved.set(id, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
return yield* new Error({ operation: "restore", message: `Snapshot tree not found: ${id}` })
|
||||
})
|
||||
|
||||
const read = Effect.fnUntraced(function* (ids: readonly ID[]) {
|
||||
const repo = yield* repository
|
||||
const stores = new Map<AbsolutePath, Git.TreeID[]>()
|
||||
for (const id of new Set(ids)) {
|
||||
const ref = yield* resolve(id)
|
||||
if (ref.directory === repo.snapshotRepository.gitDirectory) continue
|
||||
stores.set(ref.directory, [...(stores.get(ref.directory) ?? []), ref.tree])
|
||||
}
|
||||
for (const [directory, trees] of stores) {
|
||||
// A renamed checkout can supply the old store's borrowed objects at its new path.
|
||||
yield* git.tree.retain({ repository: repo.foreignRepository(directory), trees })
|
||||
}
|
||||
return new Git.Repository({
|
||||
...repo.snapshotRepository,
|
||||
objectDirectories: Array.from(stores.keys(), (directory) => AbsolutePath.make(path.join(directory, "objects"))),
|
||||
})
|
||||
})
|
||||
|
||||
const capture = Effect.fn("Snapshot.capture")(function* () {
|
||||
if (!enabled()) return undefined
|
||||
return yield* Effect.gen(function* () {
|
||||
const repo = yield* repository
|
||||
return ID.make(
|
||||
yield* git.tree.capture({
|
||||
repository: repo.snapshotRepository,
|
||||
scopes: [yield* scope(repo.worktree)],
|
||||
ignores: repo.source,
|
||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||
}),
|
||||
)
|
||||
const tree = yield* git.tree.capture({
|
||||
repository: repo.snapshotRepository,
|
||||
scopes: [yield* scope(repo.worktree)],
|
||||
ignores: repo.source,
|
||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||
})
|
||||
return ID.make(`snapshot:${location.project.id}/${Hash.fast(repo.worktree)}/${tree}`)
|
||||
}).pipe(
|
||||
Effect.catch((cause) => Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined))),
|
||||
)
|
||||
@@ -133,10 +206,11 @@ const layer = Layer.effect(
|
||||
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const snapshots = yield* read([input.from, input.to]).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const comparison = {
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
repository: snapshots,
|
||||
from: treeID(input.from),
|
||||
to: treeID(input.to),
|
||||
}
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
@@ -171,7 +245,23 @@ const layer = Layer.effect(
|
||||
const absolute = path.resolve(worktree, file)
|
||||
if (!FSUtil.contains(worktree, absolute))
|
||||
return yield* new Error({ operation: "restore", message: `Path escapes the project: ${file}` })
|
||||
files.set(file, Git.TreeID.make(snapshot))
|
||||
// Check ancestors, not the leaf: removing a symlink itself must not follow its target.
|
||||
for (let parent = path.dirname(absolute); parent !== worktree; parent = path.dirname(parent)) {
|
||||
const canonical = yield* fs.realPath(parent).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
|
||||
Effect.catchReason("PlatformError", "BadResource", (reason, error) =>
|
||||
Schema.is(Schema.Struct({ code: Schema.Literal("ENOTDIR") }))(reason.cause)
|
||||
? Effect.undefined
|
||||
: Effect.fail(error),
|
||||
),
|
||||
Effect.mapError((cause) => failure("restore", cause)),
|
||||
)
|
||||
if (canonical === undefined) continue
|
||||
if (!FSUtil.contains(worktree, canonical))
|
||||
return yield* new Error({ operation: "restore", message: `Path escapes the project: ${file}` })
|
||||
break
|
||||
}
|
||||
files.set(file, treeID(snapshot))
|
||||
}
|
||||
return files
|
||||
})
|
||||
@@ -179,8 +269,11 @@ const layer = Layer.effect(
|
||||
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
||||
if (!enabled()) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
const snapshots = yield* read(Array.from(input.files.values())).pipe(
|
||||
Effect.mapError((cause) => failure("restore", cause)),
|
||||
)
|
||||
yield* git.tree
|
||||
.restore({ repository: repo.snapshotRepository, files: yield* plan(repo.worktree, input) })
|
||||
.restore({ repository: snapshots, files: yield* plan(repo.worktree, input) })
|
||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
})
|
||||
|
||||
@@ -206,6 +299,10 @@ export const noopLayer = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
function treeID(id: ID) {
|
||||
return Git.TreeID.make(id.slice(id.lastIndexOf("/") + 1))
|
||||
}
|
||||
|
||||
function failure(operation: Error["operation"], cause: unknown) {
|
||||
if (cause instanceof Error && cause.operation === operation) return cause
|
||||
return new Error({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(LayerNode.compile(Git.node))
|
||||
@@ -105,6 +105,45 @@ describe("Git worktrees", () => {
|
||||
})
|
||||
|
||||
describe("Git trees", () => {
|
||||
it.live("retains borrowed tree objects once for restoration without the source repository", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* tmpdirScoped()
|
||||
const source = AbsolutePath.make(path.join(root.path, "source"))
|
||||
const destination = AbsolutePath.make(path.join(root.path, "destination"))
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(source)
|
||||
await fs.mkdir(destination)
|
||||
await initRepo(source)
|
||||
await Bun.write(path.join(source, "file.txt"), "Borrowed committed content.\n")
|
||||
await $`git add .`.cwd(source).quiet()
|
||||
await $`git commit -qm initial`.cwd(source).quiet()
|
||||
})
|
||||
const git = yield* Git.Service
|
||||
const seed = yield* git.repo.discover(source)
|
||||
if (!seed) throw new Error("Repository not found")
|
||||
const repository = yield* git.repo.create({
|
||||
worktree: source,
|
||||
gitDirectory: AbsolutePath.make(path.join(root.path, "snapshot storage")),
|
||||
seed,
|
||||
})
|
||||
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
expect(yield* git.tree.exists(repository, before)).toBe(true)
|
||||
expect(yield* git.tree.exists(repository, Git.TreeID.make("0".repeat(40)))).toBe(false)
|
||||
yield* git.tree.retain({ repository, trees: [before, before] })
|
||||
yield* Effect.promise(() => Bun.write(path.join(source, "file.txt"), "Snapshot-only content.\n"))
|
||||
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
yield* git.tree.retain({ repository, trees: [before, after] })
|
||||
const packs = yield* Effect.promise(() => fs.readdir(path.join(repository.gitDirectory, "objects/pack")))
|
||||
yield* git.tree.retain({ repository, trees: [before, after] })
|
||||
expect(yield* Effect.promise(() => fs.readdir(path.join(repository.gitDirectory, "objects/pack")))).toEqual(packs)
|
||||
yield* Effect.promise(() => fs.rm(source, { recursive: true }))
|
||||
const moved = new Git.Repository({ ...repository, worktree: destination, objectDirectories: [] })
|
||||
yield* git.tree.restore({ repository: moved, files: new Map([[RelativePath.make("file.txt"), before]]) })
|
||||
expect(yield* read(path.join(destination, "file.txt"))).toBe("Borrowed committed content.\n")
|
||||
yield* git.tree.restore({ repository: moved, files: new Map([[RelativePath.make("file.txt"), after]]) })
|
||||
expect(yield* read(path.join(destination, "file.txt"))).toBe("Snapshot-only content.\n")
|
||||
}),
|
||||
)
|
||||
;[0, 1, 128].forEach((exitCode) => {
|
||||
it.live(`refresh handles check-ignore exit ${exitCode}`, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1374,7 +1374,9 @@ function buildExecution(
|
||||
Layer.provide(Layer.succeed(Job.Service, jobs)),
|
||||
// Do not reuse the outer harness's selector with its already-captured Location map.
|
||||
Layer.provide(
|
||||
LayerNode.compile(Instance.byLocationNode, [[LocationServiceMap.node, locations]]).pipe(Layer.fresh),
|
||||
LayerNode.compile(Instance.byLocationNode, {
|
||||
replacements: [LocationServiceMap.node.replace(locations)],
|
||||
}).pipe(Layer.fresh),
|
||||
),
|
||||
),
|
||||
scope,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
@@ -24,99 +25,284 @@ import { Money } from "@opencode-ai/schema/money"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { initRepo, read } from "./fixture/git"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
[
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
// These tests move directories explicitly; native watchers can hold them open on Windows.
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("Session.revert files", () => {
|
||||
for (const encoding of ["qualified", "legacy"] as const) {
|
||||
const stored = (id: Snapshot.ID) =>
|
||||
encoding === "legacy" ? Snapshot.ID.make(id.slice(id.lastIndexOf("/") + 1)) : id
|
||||
for (const move of [
|
||||
"repository rename",
|
||||
"another worktree",
|
||||
"another project",
|
||||
"same worktree subdirectory",
|
||||
] as const) {
|
||||
it.live(
|
||||
`restores ${encoding} staged file changes after moving to ${move}`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const destination = AbsolutePath.make(
|
||||
move === "same worktree subdirectory"
|
||||
? path.join(directory, "nested")
|
||||
: path.join(tmp.path, "destination"),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await Bun.write(path.join(directory, "file.txt"), "Before assistant edit.\n")
|
||||
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated source content.\n")
|
||||
await initRepo(directory)
|
||||
await $`git add .`.cwd(directory).quiet()
|
||||
await $`git commit -qm initial`.cwd(directory).quiet()
|
||||
if (move === "another worktree")
|
||||
await $`git worktree add --detach ${destination} HEAD`.cwd(directory).quiet()
|
||||
if (move === "another project") {
|
||||
await fs.mkdir(destination)
|
||||
await Bun.write(path.join(destination, "file.txt"), "Destination content.\n")
|
||||
await $`git init -q`.cwd(destination).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(destination).quiet()
|
||||
}
|
||||
if (move === "same worktree subdirectory") await fs.mkdir(destination)
|
||||
})
|
||||
|
||||
const bus = yield* Bus.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const { session, created, prompt } = yield* recordSnapshotStep(
|
||||
directory,
|
||||
() => Bun.write(path.join(directory, "file.txt"), "After assistant edit.\n"),
|
||||
stored,
|
||||
)
|
||||
|
||||
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
|
||||
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
|
||||
if (encoding === "legacy")
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
|
||||
expect(reverted.snapshot).toBeDefined()
|
||||
expect(reverted.files?.map((file) => file.file)).toEqual(["file.txt"])
|
||||
expect(yield* read(path.join(directory, "file.txt"))).toBe("Before assistant edit.\n")
|
||||
|
||||
if (move === "repository rename") yield* Effect.promise(() => fs.rename(directory, destination))
|
||||
const root = move === "same worktree subdirectory" ? directory : destination
|
||||
yield* Effect.promise(() => Bun.write(path.join(root, "unrelated.txt"), "Keep this destination edit.\n"))
|
||||
const indexPath = yield* Effect.promise(async () =>
|
||||
path.resolve(root, (await $`git rev-parse --git-path index`.cwd(root).quiet().text()).trim()),
|
||||
)
|
||||
const index = yield* Effect.promise(() => Bun.file(indexPath).arrayBuffer())
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
yield* execution.awaitIdle(created.id)
|
||||
const moved = yield* session.get(created.id)
|
||||
expect(moved.location.directory).toBe(destination)
|
||||
expect(moved.projectID === created.projectID).toBe(move !== "another project")
|
||||
expect(moved.revert).toEqual(reverted)
|
||||
|
||||
yield* session.revert.clear(created.id)
|
||||
expect(yield* read(path.join(root, "file.txt"))).toBe("After assistant edit.\n")
|
||||
expect((yield* session.get(created.id)).revert).toBeUndefined()
|
||||
expect(yield* read(path.join(root, "unrelated.txt"))).toBe("Keep this destination edit.\n")
|
||||
if (move === "another worktree" || move === "another project")
|
||||
expect(yield* read(path.join(directory, "file.txt"))).toBe("Before assistant edit.\n")
|
||||
yield* execution.awaitIdle(created.id)
|
||||
yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
|
||||
expect(yield* read(path.join(root, "file.txt"))).toBe("Before assistant edit.\n")
|
||||
yield* session.revert.clear(created.id)
|
||||
expect(yield* read(path.join(root, "file.txt"))).toBe("After assistant edit.\n")
|
||||
expect(yield* Effect.promise(() => Bun.file(indexPath).arrayBuffer())).toEqual(index)
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
it.live(
|
||||
`rejects ${encoding} redo through a destination symlink ancestor`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const source = yield* tmpdirScoped()
|
||||
const destination = yield* tmpdirScoped()
|
||||
const file = path.join(source.path, "assets/logo.svg")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(file))
|
||||
await Bun.write(file, "Before assistant edit.\n")
|
||||
await initRepo(source.path)
|
||||
await fs.symlink(path.dirname(file), path.join(destination.path, "assets"), "dir")
|
||||
await $`git init -q`.cwd(destination.path).quiet()
|
||||
})
|
||||
const bus = yield* Bus.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const { session, created, prompt } = yield* recordSnapshotStep(source.path, () => fs.rm(file), stored)
|
||||
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
|
||||
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
|
||||
yield* session.move({ sessionID: created.id, directory: AbsolutePath.make(destination.path) })
|
||||
yield* execution.awaitIdle(created.id)
|
||||
expect((yield* session.get(created.id)).projectID).not.toBe(created.projectID)
|
||||
|
||||
const error = yield* session.revert.clear(created.id).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Snapshot.Error)
|
||||
expect(error).toMatchObject({
|
||||
operation: "restore",
|
||||
message: expect.stringContaining("Path escapes the project"),
|
||||
})
|
||||
expect(yield* read(file)).toBe("Before assistant edit.\n")
|
||||
expect((yield* session.get(created.id)).revert).toEqual(reverted)
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
`recovers ${encoding} redo after missing snapshot data returns`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const source = yield* tmpdirScoped()
|
||||
const destination = yield* tmpdirScoped()
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(source.path)
|
||||
await Bun.write(path.join(source.path, "file.txt"), "Before assistant edit.\n")
|
||||
await Bun.write(path.join(source.path, "unrelated.txt"), "Borrowed committed content.\n")
|
||||
await $`git add .`.cwd(source.path).quiet()
|
||||
await $`git commit -qm initial`.cwd(source.path).quiet()
|
||||
await Bun.write(path.join(destination.path, "file.txt"), "Destination content.\n")
|
||||
await $`git init -q`.cwd(destination.path).quiet()
|
||||
})
|
||||
const bus = yield* Bus.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const { session, created, prompt } = yield* recordSnapshotStep(
|
||||
source.path,
|
||||
() => Bun.write(path.join(source.path, "file.txt"), "After assistant edit.\n"),
|
||||
stored,
|
||||
)
|
||||
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
|
||||
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
|
||||
yield* session.move({ sessionID: created.id, directory: AbsolutePath.make(destination.path) })
|
||||
yield* execution.awaitIdle(created.id)
|
||||
|
||||
const unavailable = {
|
||||
...reverted,
|
||||
snapshot: stored(Snapshot.ID.make(`snapshot:missing/${"0".repeat(40)}/${"0".repeat(40)}`)),
|
||||
}
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: unavailable })
|
||||
expect(yield* session.revert.clear(created.id).pipe(Effect.flip)).toBeInstanceOf(Snapshot.Error)
|
||||
expect((yield* session.get(created.id)).revert).toEqual(unavailable)
|
||||
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("Destination content.\n")
|
||||
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
|
||||
const seed = path.join(source.path, ".git")
|
||||
const missing = path.join(source.path, "unavailable-seed")
|
||||
yield* Effect.promise(() => fs.rename(seed, missing))
|
||||
expect(yield* session.revert.clear(created.id).pipe(Effect.flip)).toBeInstanceOf(Snapshot.Error)
|
||||
expect((yield* session.get(created.id)).revert).toEqual(reverted)
|
||||
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("Destination content.\n")
|
||||
|
||||
yield* Effect.promise(() => fs.rename(missing, seed))
|
||||
yield* session.revert.clear(created.id)
|
||||
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("After assistant edit.\n")
|
||||
expect((yield* session.get(created.id)).revert).toBeUndefined()
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
it.live(
|
||||
"undoes and restores a file rename without losing either path",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const directory = (yield* tmpdirScoped()).path
|
||||
const original = path.join(directory, "old name.txt")
|
||||
const renamed = path.join(directory, "new name.txt")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await Bun.write(original, "Preserve this content.\n")
|
||||
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated content.\n")
|
||||
await $`git init -q`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
})
|
||||
|
||||
const session = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
|
||||
const prompt = yield* session.prompt({ sessionID: created.id, text: "Rename the file", resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
const { session, created, prompt } = yield* recordSnapshotStep(directory, () => fs.rename(original, renamed))
|
||||
const services = LocationServiceMap.Service.get(created.location)
|
||||
const revert = yield* SessionRevert.Service.pipe(Effect.provide(services))
|
||||
expect(yield* SessionRevert.Service.pipe(Effect.provide(services))).toBe(revert)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Initial snapshot missing")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* Effect.promise(() => fs.rename(original, renamed))
|
||||
const after = yield* snapshot.capture()
|
||||
if (!after) throw new Error("Renamed snapshot missing")
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
snapshot: after,
|
||||
files: yield* snapshot.files({ from: before, to: after }),
|
||||
})
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
|
||||
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
|
||||
expect({
|
||||
original: yield* Effect.promise(() => Bun.file(original).exists()),
|
||||
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
|
||||
}).toEqual({ original: true, renamed: false })
|
||||
expect(yield* read(original)).toBe("Preserve this content.\n")
|
||||
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
|
||||
["new name.txt", "deleted"],
|
||||
["old name.txt", "added"],
|
||||
])
|
||||
expect(yield* read(path.join(directory, "unrelated.txt"))).toBe("Keep this later edit.\n")
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
|
||||
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
|
||||
expect({
|
||||
original: yield* Effect.promise(() => Bun.file(original).exists()),
|
||||
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
|
||||
}).toEqual({ original: true, renamed: false })
|
||||
expect(yield* Effect.promise(() => Bun.file(original).text())).toBe("Preserve this content.\n")
|
||||
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
|
||||
["new name.txt", "deleted"],
|
||||
["old name.txt", "added"],
|
||||
])
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
|
||||
"Keep this later edit.\n",
|
||||
)
|
||||
|
||||
yield* session.revert.clear(created.id)
|
||||
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
|
||||
expect(yield* Effect.promise(() => Bun.file(renamed).text())).toBe("Preserve this content.\n")
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
|
||||
"Keep this later edit.\n",
|
||||
)
|
||||
expect((yield* session.get(created.id)).revert).toBeUndefined()
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
yield* session.revert.clear(created.id)
|
||||
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
|
||||
expect(yield* read(renamed)).toBe("Preserve this content.\n")
|
||||
expect(yield* read(path.join(directory, "unrelated.txt"))).toBe("Keep this later edit.\n")
|
||||
expect((yield* session.get(created.id)).revert).toBeUndefined()
|
||||
}),
|
||||
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
})
|
||||
|
||||
const recordSnapshotStep = Effect.fnUntraced(function* (
|
||||
directory: string,
|
||||
mutate: () => Promise<unknown>,
|
||||
stored = (id: Snapshot.ID) => id,
|
||||
) {
|
||||
const session = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
|
||||
const prompt = yield* session.prompt({ sessionID: created.id, text: "Edit the files", resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Initial snapshot missing")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: stored(before),
|
||||
})
|
||||
yield* Effect.promise(mutate)
|
||||
const after = yield* snapshot.capture()
|
||||
if (!after) throw new Error("Edited snapshot missing")
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
snapshot: stored(after),
|
||||
files: yield* snapshot.files({ from: before, to: after }),
|
||||
})
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
return { session, created, prompt }
|
||||
})
|
||||
|
||||
@@ -10,10 +10,76 @@ 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 { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
describe("Snapshot", () => {
|
||||
for (const transition of ["symlink", "file"] as const) {
|
||||
testEffect(Layer.empty).live(`restores directory/${transition} transitions without touching external files`, () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const project = path.join(tmp.path, "project")
|
||||
const assets = path.join(project, "assets")
|
||||
const external = path.join(tmp.path, "external")
|
||||
const leaf = transition === "symlink" ? "assets/logo.svg" : "assets/nested/logo.svg"
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.mkdir(external)
|
||||
await Bun.write(path.join(external, "logo.svg"), "External content must survive.\n")
|
||||
if (transition === "symlink") await fs.symlink(external, assets, "dir")
|
||||
if (transition === "file") {
|
||||
await fs.mkdir(path.dirname(path.join(project, leaf)), { recursive: true })
|
||||
await Bun.write(path.join(project, leaf), "Directory content.\n")
|
||||
}
|
||||
await initGit(project)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Initial snapshot missing")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.rm(assets, { recursive: true })
|
||||
if (transition === "file") await Bun.write(assets, "File content.\n")
|
||||
if (transition === "symlink") {
|
||||
await fs.mkdir(assets)
|
||||
await Bun.write(path.join(project, leaf), "Directory content.\n")
|
||||
}
|
||||
})
|
||||
const after = yield* snapshot.capture()
|
||||
if (!after) throw new Error("Changed snapshot missing")
|
||||
const files = yield* snapshot.files({ from: before, to: after })
|
||||
expect(files).toEqual([RelativePath.make("assets"), RelativePath.make(leaf)])
|
||||
const restored = yield* snapshot
|
||||
.restore({ files: new Map(files.map((file) => [file, before])) })
|
||||
.pipe(Effect.exit)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(external, "logo.svg")).exists())).toBe(true)
|
||||
expect(yield* read(path.join(external, "logo.svg"))).toBe("External content must survive.\n")
|
||||
yield* restored
|
||||
if (transition === "symlink") {
|
||||
expect(yield* Effect.promise(() => fs.readlink(assets))).toBe(external)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.unlink(assets)
|
||||
await fs.mkdir(assets)
|
||||
})
|
||||
yield* snapshot.restore({
|
||||
files: new Map([
|
||||
[RelativePath.make("assets"), before],
|
||||
[RelativePath.make(leaf), after],
|
||||
]),
|
||||
})
|
||||
expect(yield* read(path.join(external, "logo.svg"))).toBe("External content must survive.\n")
|
||||
expect(yield* Effect.promise(async () => (await fs.lstat(assets)).isDirectory())).toBe(true)
|
||||
expect(yield* read(path.join(project, leaf))).toBe("Directory content.\n")
|
||||
return
|
||||
}
|
||||
expect(yield* read(path.join(project, leaf))).toBe("Directory content.\n")
|
||||
yield* snapshot.restore({ files: new Map(files.toReversed().map((file) => [file, after])) })
|
||||
expect(yield* read(assets)).toBe("File content.\n")
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(Layer.empty).live("keeps lazy repository discovery after the first caller is interrupted", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -119,8 +185,46 @@ describe("Snapshot", () => {
|
||||
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||
yield* snapshot.restore({ files: plan })
|
||||
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
||||
yield* Effect.promise(() => fs.symlink("loop", path.join(location, "loop")))
|
||||
expect(
|
||||
yield* snapshot
|
||||
.restore({ files: new Map([[RelativePath.make("scope/loop/file.txt"), before]]) })
|
||||
.pipe(Effect.flip),
|
||||
).toMatchObject({
|
||||
operation: "restore",
|
||||
cause: { reason: { cause: { code: "ELOOP" } } },
|
||||
})
|
||||
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
|
||||
expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n")
|
||||
const error = yield* snapshot
|
||||
.restore({
|
||||
files: new Map([[RelativePath.make("../escape.txt"), before]]),
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(error.message).toContain("Path escapes the project")
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "escape.txt")).exists())).toBe(false)
|
||||
expect(
|
||||
yield* snapshot
|
||||
.restore({
|
||||
files: new Map([
|
||||
[
|
||||
RelativePath.make("scope/tracked.txt"),
|
||||
Snapshot.ID.make(`snapshot:../${"0".repeat(40)}/${"0".repeat(40)}`),
|
||||
],
|
||||
]),
|
||||
})
|
||||
.pipe(Effect.flip),
|
||||
).toMatchObject({ operation: "restore", message: "Invalid snapshot reference" })
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(tmp.path, "target.txt"), "Keep the external target.\n")
|
||||
await fs.symlink(path.join(tmp.path, "target.txt"), path.join(location, "link.txt"))
|
||||
})
|
||||
yield* snapshot.restore({ files: new Map([[RelativePath.make("scope/link.txt"), before]]) })
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(location, "link.txt")).exists())).toBe(false)
|
||||
expect(yield* read(path.join(tmp.path, "target.txt"))).toBe("Keep the external target.\n")
|
||||
yield* Effect.promise(() => fs.rm(location, { recursive: true }))
|
||||
yield* snapshot.restore({ files: plan })
|
||||
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
@@ -214,8 +318,20 @@ describe("Snapshot", () => {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
return yield* snapshot.capture()
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
|
||||
expect(yield* capture(project)).toBeDefined()
|
||||
yield* Effect.promise(() => Bun.write(path.join(project, "tracked.txt"), "Uncommitted source content.\n"))
|
||||
const captured = yield* capture(project)
|
||||
if (!captured) throw new Error("Snapshot missing")
|
||||
expect(yield* capture(linked)).toBeDefined()
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
expect(
|
||||
yield* snapshot.diff({
|
||||
from: captured,
|
||||
to: Snapshot.ID.make(captured.slice(captured.lastIndexOf("/") + 1)),
|
||||
}),
|
||||
).toEqual([])
|
||||
expect(yield* read(path.join(linked, "tracked.txt"))).toBe("main\n")
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, linked)))
|
||||
|
||||
const projectID = yield* Effect.gen(function* () {
|
||||
return (yield* Location.Service).project.id
|
||||
|
||||
@@ -52,18 +52,19 @@ it.live(
|
||||
const cell = PluginRuntime.makeCell()
|
||||
// Host and private instances must reuse the same global layer identities.
|
||||
const replacements: LayerNode.Replacements = [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Database.node, Database.node],
|
||||
[Bus.node, Bus.node],
|
||||
[App.node, App.node],
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: false })],
|
||||
[Watcher.node, Watcher.configured({ enabled: false })],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
|
||||
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(cell)],
|
||||
[llmClient, Layer.succeed(LLMClient.Service, llm)],
|
||||
[SessionRunnerModel.node, Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) })],
|
||||
[
|
||||
Instance.byLocationNode,
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Database.node.replace(Database.node),
|
||||
Bus.node.replace(Bus.node),
|
||||
App.node.replace(App.node),
|
||||
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
PluginRuntime.node.replace(PluginRuntime.layerWithCell(cell)),
|
||||
PluginRuntime.providerNode.replace(PluginRuntime.providerNodeWithCell(cell)),
|
||||
llmClient.replace(Layer.succeed(LLMClient.Service, llm)),
|
||||
SessionRunnerModel.node.replace(
|
||||
Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }),
|
||||
),
|
||||
Instance.byLocationNode.replace(
|
||||
Layer.effect(
|
||||
Instance.Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -151,7 +152,7 @@ it.live(
|
||||
})
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
const context = yield* Layer.build(
|
||||
createEmbeddedRoutes({}, replacements).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
|
||||
Reference in New Issue
Block a user