Files
oh-my-pi/packages/coding-agent/test/lsp/edits.test.ts
T
roboomp 4a6519f07f fix(lsp): roll back rename_file edits when the move fails
rename_file wrote server-provided reference edits before creating the
destination parent and moving the source, with no rollback. A failed
mkdir/rename (e.g. EXDEV across mounts, EACCES) left the reference files
rewritten while the source stayed put, violating the atomic "moves file
AND rewrites all imports/references" contract.

Extract applyEditsThenRename in lsp/edits.ts: snapshot each edited file,
apply edits, then mkdir+rename; on failure restore every snapshot before
rethrowing. Rewire the rename_file handler to build the summary and hand
the edits to the helper.

Fixes #8379
2026-08-13 03:05:29 +00:00

55 lines
2.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { applyEditsThenRename } from "@oh-my-pi/pi-coding-agent/lsp/edits";
import type { TextEdit } from "@oh-my-pi/pi-coding-agent/lsp/types";
// Rewrite `./moved` → `./renamed` on line 0 of the reference file below.
const importEdit: TextEdit[] = [
{ range: { start: { line: 0, character: 19 }, end: { line: 0, character: 26 } }, newText: "./renamed" },
];
describe("applyEditsThenRename", () => {
let dir: string;
let source: string;
let ref: string;
const refBefore = 'import { x } from "./moved";\n';
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "edits-rename-"));
source = path.join(dir, "moved.ts");
ref = path.join(dir, "ref.ts");
await Bun.write(source, "export const x = 1;\n");
await Bun.write(ref, refBefore);
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it("applies reference edits and moves the source when the move succeeds", async () => {
const dest = path.join(dir, "nested", "renamed.ts");
await applyEditsThenRename([{ filePath: ref, edits: importEdit }], source, dest);
expect(await Bun.file(dest).text()).toBe("export const x = 1;\n");
expect(await Bun.file(source).exists()).toBe(false);
expect(await Bun.file(ref).text()).toBe('import { x } from "./renamed";\n');
});
it("rolls back reference edits when the move fails", async () => {
// A regular file stands where a dest-parent directory must be, so the
// recursive mkdir throws ENOTDIR before the rename runs.
const blocker = path.join(dir, "blocker");
await Bun.write(blocker, "not a dir");
const dest = path.join(blocker, "sub", "renamed.ts");
await expect(applyEditsThenRename([{ filePath: ref, edits: importEdit }], source, dest)).rejects.toThrow();
// Failed move must leave source, destination, and reference files untouched.
expect(await Bun.file(ref).text()).toBe(refBefore);
expect(await Bun.file(source).exists()).toBe(true);
expect(await Bun.file(dest).exists()).toBe(false);
});
});