Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 49 additions & 24 deletions packages/core/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,50 +564,75 @@ 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",
tree,
"--",
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 },
)
}),
),
)

Expand Down
31 changes: 28 additions & 3 deletions packages/core/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
111 changes: 86 additions & 25 deletions packages/core/test/session-revert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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 }),
})
})
Loading
Loading