mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 19:36:25 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffa2db8dcf |
+49
-24
@@ -564,7 +564,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
|
||||
const entryType = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
|
||||
const text = (yield* repositoryOperation("restore", repository, [
|
||||
"ls-tree",
|
||||
"-z",
|
||||
@@ -572,42 +572,67 @@ const layer = Layer.effect(
|
||||
"--",
|
||||
file,
|
||||
])).text.replace(/\0$/, "")
|
||||
if (!text) return false
|
||||
if (!/^\d+\s+\w+\s+[0-9a-f]+\t/.test(text))
|
||||
if (!text) return undefined
|
||||
const entry = /^\d+\s+(\w+)\s+[0-9a-f]+\t/.exec(text)
|
||||
if (!entry)
|
||||
return yield* new OperationError({
|
||||
operation: "restore",
|
||||
directory: repository.worktree,
|
||||
message: `Invalid tree entry for ${file}`,
|
||||
})
|
||||
return true
|
||||
return entry[1]
|
||||
})
|
||||
|
||||
const restore = Effect.fn("Git.tree.restore")(
|
||||
(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({
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* Effect.forEach(input.files, ([file, tree]) =>
|
||||
Effect.map(entryType(input.repository, tree, file), (type) => ({ file, tree, type })),
|
||||
)
|
||||
const ordered = entries.toSorted((a, b) => a.file.split("/").length - b.file.split("/").length)
|
||||
yield* Effect.forEach(
|
||||
ordered,
|
||||
(entry) =>
|
||||
Effect.gen(function* () {
|
||||
if (entry.type) {
|
||||
yield* repositoryOperation("restore", input.repository, ["checkout", entry.tree, "--", entry.file])
|
||||
return
|
||||
}
|
||||
// A restored symlink, file, or missing ancestor has no project-owned descendants to delete.
|
||||
const ancestor = ordered.findLast((parent) => entry.file.startsWith(`${parent.file}/`))
|
||||
if (ancestor && ancestor.type !== "tree") return
|
||||
const absolute = path.join(input.repository.worktree, entry.file)
|
||||
yield* Effect.gen(function* () {
|
||||
const parent = yield* fs
|
||||
.realPath(path.dirname(absolute))
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined))
|
||||
if (parent === undefined) return
|
||||
const worktree = yield* fs.realPath(input.repository.worktree)
|
||||
if (!FSUtil.contains(worktree, parent))
|
||||
return yield* new OperationError({
|
||||
operation: "restore",
|
||||
directory: input.repository.worktree,
|
||||
message: `Failed to remove ${file}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
),
|
||||
message: `Path escapes the project: ${entry.file}`,
|
||||
})
|
||||
yield* fs.remove(absolute, { recursive: true, force: true })
|
||||
}).pipe(
|
||||
Effect.catchTag("PlatformError", (cause) =>
|
||||
Effect.fail(
|
||||
new OperationError({
|
||||
operation: "restore",
|
||||
directory: input.repository.worktree,
|
||||
message: `Failed to remove ${entry.file}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -139,9 +139,34 @@ const layer = Layer.effect(
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: repo.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index.ignored({ repository: repo.source, paths: files }).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.gen(function* () {
|
||||
// Git cannot check historical descendants below a current symlink; check the link itself instead.
|
||||
const paths = yield* Effect.forEach(files, (file) =>
|
||||
Effect.gen(function* () {
|
||||
const parts = file.split("/")
|
||||
for (let index = 1; index < parts.length; index++) {
|
||||
const parent = RelativePath.make(parts.slice(0, index).join("/"))
|
||||
const symlink = yield* fs.readLink(path.join(repo.worktree, parent)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
if (symlink) return { file, query: parent }
|
||||
}
|
||||
return { file, query: file }
|
||||
}),
|
||||
)
|
||||
if (paths.every((entry) => entry.file === entry.query)) return yield* Effect.fail(cause)
|
||||
const ignored = yield* git.index.ignored({
|
||||
repository: repo.source,
|
||||
paths: Array.from(new Set(paths.map((entry) => entry.query))),
|
||||
})
|
||||
return new Set(paths.filter((entry) => ignored.has(entry.query)).map((entry) => entry.file))
|
||||
}),
|
||||
),
|
||||
Effect.mapError((cause) => failure(operation, cause)),
|
||||
)
|
||||
return {
|
||||
input: comparison,
|
||||
files,
|
||||
|
||||
@@ -64,31 +64,10 @@ describe("Session.revert files", () => {
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
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* recordStep(
|
||||
created.id,
|
||||
Effect.promise(() => fs.rename(original, renamed)),
|
||||
)
|
||||
|
||||
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 })
|
||||
@@ -117,4 +96,86 @@ describe("Session.revert files", () => {
|
||||
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
"undoes and redoes a symlink replacement without changing its external target",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const external = path.join(tmp.path, "external")
|
||||
const assets = path.join(directory, "assets")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await fs.mkdir(external)
|
||||
await Bun.write(path.join(external, "logo.svg"), "External content must survive.\n")
|
||||
await fs.symlink(external, assets, "dir")
|
||||
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: "Replace the symlink", resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
yield* recordStep(
|
||||
created.id,
|
||||
Effect.promise(async () => {
|
||||
await fs.unlink(assets)
|
||||
await fs.mkdir(assets)
|
||||
await Bun.write(path.join(assets, "logo.svg"), "Project content.\n")
|
||||
}),
|
||||
)
|
||||
// Repeating Undo must remain safe even once its symlink has already been restored.
|
||||
yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
|
||||
yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
|
||||
expect(yield* Effect.promise(() => fs.readlink(assets))).toBe(external)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(external, "logo.svg")).text())).toBe(
|
||||
"External content must survive.\n",
|
||||
)
|
||||
|
||||
yield* session.revert.clear(created.id)
|
||||
expect(yield* Effect.promise(async () => (await fs.lstat(assets)).isDirectory())).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(assets, "logo.svg")).text())).toBe("Project content.\n")
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(external, "logo.svg")).text())).toBe(
|
||||
"External content must survive.\n",
|
||||
)
|
||||
expect((yield* session.get(created.id)).revert).toBeUndefined()
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
})
|
||||
|
||||
const recordStep = Effect.fnUntraced(function* (sessionID: Session.ID, change: Effect.Effect<void>) {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const bus = yield* Bus.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,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* change
|
||||
const after = yield* snapshot.capture()
|
||||
if (!after) throw new Error("Changed snapshot missing")
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
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 }),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,212 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
describe("Snapshot", () => {
|
||||
for (const reverse of [false, true]) {
|
||||
testEffect(Layer.empty).live(
|
||||
`restores symlink and directory round trips without escaping (reverse=${reverse})`,
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const external = path.join(tmp.path, "external")
|
||||
const assets = path.join(project, "assets")
|
||||
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")
|
||||
await fs.symlink(external, assets, "dir")
|
||||
await initGit(project)
|
||||
})
|
||||
const index = yield* Effect.promise(() => Bun.file(path.join(project, ".git", "index")).bytes())
|
||||
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.unlink(assets)
|
||||
await fs.mkdir(assets)
|
||||
await Bun.write(path.join(assets, "logo.svg"), "Project content.\n")
|
||||
})
|
||||
const after = yield* snapshot.capture()
|
||||
if (!after) throw new Error("Changed snapshot missing")
|
||||
const changed = yield* snapshot.files({ from: before, to: after })
|
||||
expect(changed).toEqual([RelativePath.make("assets"), RelativePath.make("assets/logo.svg")])
|
||||
const files = reverse ? changed.toReversed() : changed
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
yield* snapshot.restore({ files: new Map(files.map((file) => [file, before])) })
|
||||
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")
|
||||
expect(yield* Effect.promise(() => fs.readlink(assets))).toBe(external)
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
yield* snapshot.restore({ files: new Map(files.map((file) => [file, 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(assets, "logo.svg"))).toBe("Project content.\n")
|
||||
}
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(project, ".git", "index")).bytes())).toEqual(
|
||||
index,
|
||||
)
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const present of [false, true]) {
|
||||
testEffect(Layer.empty).live(`does not follow an existing external symlink (present=${present})`, () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const external = path.join(tmp.path, "external")
|
||||
const assets = path.join(project, "assets")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(assets, { recursive: true })
|
||||
await fs.mkdir(external)
|
||||
await Bun.write(path.join(assets, "logo.svg"), "Project content.\n")
|
||||
await Bun.write(path.join(external, "logo.svg"), "External content must survive.\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(() => fs.unlink(path.join(assets, "logo.svg")))
|
||||
const after = yield* snapshot.capture()
|
||||
if (!after) throw new Error("Changed snapshot missing")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.rmdir(assets)
|
||||
await fs.symlink(external, assets, "dir")
|
||||
})
|
||||
|
||||
const result = yield* snapshot
|
||||
.restore({ files: new Map([[RelativePath.make("assets/logo.svg"), present ? before : after]]) })
|
||||
.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")
|
||||
if (present) {
|
||||
yield* result
|
||||
expect(yield* read(path.join(assets, "logo.svg"))).toBe("Project content.\n")
|
||||
expect(yield* Effect.promise(async () => (await fs.lstat(assets)).isDirectory())).toBe(true)
|
||||
return
|
||||
}
|
||||
const error = yield* result.pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Snapshot.Error)
|
||||
expect(error).toMatchObject({
|
||||
operation: "restore",
|
||||
message: "Path escapes the project: assets/logo.svg",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readlink(assets))).toBe(external)
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(Layer.empty).live("uses the closest restored ancestor for mixed-tree deletions", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const external = path.join(tmp.path, "external")
|
||||
const assets = path.join(project, "assets")
|
||||
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")
|
||||
await fs.symlink(external, assets, "dir")
|
||||
await initGit(project)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const link = yield* snapshot.capture()
|
||||
if (!link) throw new Error("Symlink snapshot missing")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.unlink(assets)
|
||||
await fs.mkdir(path.join(assets, "nested"), { recursive: true })
|
||||
await Bun.write(path.join(assets, "nested", "logo.svg"), "Project content.\n")
|
||||
})
|
||||
const directory = yield* snapshot.capture()
|
||||
if (!directory) throw new Error("Directory snapshot missing")
|
||||
yield* Effect.promise(() => fs.unlink(path.join(assets, "nested", "logo.svg")))
|
||||
const removed = yield* snapshot.capture()
|
||||
if (!removed) throw new Error("Removed-file snapshot missing")
|
||||
|
||||
yield* snapshot.restore({
|
||||
files: new Map([
|
||||
[RelativePath.make("assets/nested/logo.svg"), removed],
|
||||
[RelativePath.make("assets/nested"), directory],
|
||||
[RelativePath.make("assets"), link],
|
||||
]),
|
||||
})
|
||||
expect(yield* Effect.promise(async () => (await fs.lstat(path.join(assets, "nested"))).isDirectory())).toBe(
|
||||
true,
|
||||
)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(assets, "nested", "logo.svg")).exists())).toBe(false)
|
||||
expect(yield* read(path.join(external, "logo.svg"))).toBe("External content must survive.\n")
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("checks symlink ignore rules without hiding fatal Git errors", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const external = path.join(tmp.path, "external")
|
||||
const assets = path.join(project, "assets")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.mkdir(external)
|
||||
await fs.symlink(external, assets, "dir")
|
||||
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.unlink(assets)
|
||||
await fs.mkdir(assets)
|
||||
await Bun.write(path.join(assets, "logo.svg"), "Project content.\n")
|
||||
})
|
||||
const after = yield* snapshot.capture()
|
||||
if (!after) throw new Error("Directory snapshot missing")
|
||||
yield* snapshot.restore({
|
||||
files: new Map([
|
||||
[RelativePath.make("assets"), before],
|
||||
[RelativePath.make("assets/logo.svg"), before],
|
||||
]),
|
||||
})
|
||||
expect(yield* snapshot.files({ from: after, to: before })).toEqual([
|
||||
RelativePath.make("assets"),
|
||||
RelativePath.make("assets/logo.svg"),
|
||||
])
|
||||
yield* Effect.promise(() => Bun.write(path.join(project, ".gitignore"), "assets\n"))
|
||||
expect(yield* snapshot.files({ from: after, to: before })).toEqual([])
|
||||
yield* Effect.promise(() => Bun.write(path.join(project, ".git", "config"), "[broken\n"))
|
||||
const error = yield* snapshot.files({ from: after, to: before }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Snapshot.Error)
|
||||
expect(error.operation).toBe("files")
|
||||
expect(error.message).toContain("bad config line")
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("keeps lazy repository discovery after the first caller is interrupted", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
Reference in New Issue
Block a user