Compare commits

...
Author SHA1 Message Date
Kit Langton ffa2db8dcf fix(core): preserve external files during snapshot restore
Plan ancestor restoration before descendant deletion and keep missing paths beneath restored non-directory entries away from filesystem removal. Validate canonical parents for remaining deletions while leaving present entries to native Git checkout.

Handle historical ignore queries beneath restored symlinks so Session Undo and Redo finish without manual repair. Cover external-file preservation, repeated and reversed restoration, mixed trees, typed failures, and unchanged user index state.
2026-09-02 21:15:34 -04:00
4 changed files with 369 additions and 52 deletions
+49 -24
View File
@@ -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 },
)
}),
),
)
+28 -3
View File
@@ -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,
+86 -25
View File
@@ -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 }),
})
})
+206
View File
@@ -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()),