fix(cli): undo complete timeline batch receipts atomically (#5279)

* fix(cli): undo complete timeline batch receipts atomically

* test(cli): retain both timeline suites before merging main
This commit is contained in:
Abdullah M. Nazif
2026-10-09 17:55:56 +00:00
committed by GitHub
parent a1803448b8
commit d03e41c9b4
2 changed files with 193 additions and 36 deletions
+66 -36
View File
@@ -253,16 +253,22 @@ export async function runApply(args: Record<string, unknown>): Promise<void> {
);
}
export async function runUndo(args: Record<string, unknown>): Promise<void> {
const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined);
const json = args.json === true;
const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1];
if (!input)
return refuse(
"timeline undo",
{ reason: "an undo receipt is required", fix: "pass the receipt JSON or its file" },
json,
);
type UndoReceipt = { file: string; version: string; backupPath: string };
function isUndoReceipt(value: unknown): value is UndoReceipt {
return (
isRecord(value) &&
typeof value.file === "string" &&
typeof value.version === "string" &&
typeof value.backupPath === "string"
);
}
type UndoInput =
| { ok: true; receipts: UndoReceipt[]; batch: boolean }
| { ok: false; reason: string; fix: string };
function readUndoInput(input: string): UndoInput {
let raw: string;
try {
raw = readFileSync(input, "utf-8");
@@ -273,40 +279,64 @@ export async function runUndo(args: Record<string, unknown>): Promise<void> {
try {
parsed = JSON.parse(raw);
} catch {
return {
ok: false,
reason: "undo receipt is not valid JSON",
fix: "pass the applied JSON receipt",
};
}
const value = isRecord(parsed) && "receipt" in parsed ? parsed.receipt : parsed;
const values: unknown[] = Array.isArray(value) ? value : [value];
if (!values.every(isUndoReceipt)) {
return {
ok: false,
reason: "undo receipt is missing file, version, or backupPath",
fix: "pass an applied timeline receipt",
};
}
return { ok: true, receipts: values, batch: Array.isArray(value) };
}
export async function runUndo(args: Record<string, unknown>): Promise<void> {
const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined);
const json = args.json === true;
const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1];
if (!input)
return refuse(
"timeline undo",
{ reason: "undo receipt is not valid JSON", fix: "pass the applied JSON receipt" },
{ reason: "an undo receipt is required", fix: "pass the receipt JSON or its file" },
json,
);
const undo = readUndoInput(input);
if (!undo.ok) return refuse("timeline undo", undo, json);
const inputs = undo.receipts.map((receipt) => {
const target = join(project.dir, receipt.file);
return {
sourceFile: receipt.file,
absPath: target,
before: readFileSync(target, "utf-8"),
after: readFileSync(join(project.dir, receipt.backupPath), "utf-8"),
expectedVersion: receipt.version,
};
});
let receipts: AppliedFileMutation[];
try {
receipts = applyFileMutations(project.dir, inputs);
} catch (error) {
if (isFileChanged(error))
return refusal(error.message, "read the latest timeline before retrying undo", json);
throw error;
}
const value = isRecord(parsed) && isRecord(parsed.receipt) ? parsed.receipt : parsed;
if (
!isRecord(value) ||
typeof value.file !== "string" ||
typeof value.version !== "string" ||
typeof value.backupPath !== "string"
) {
return refuse(
"timeline undo",
{
reason: "undo receipt is missing file, version, or backupPath",
fix: "pass an applied timeline receipt",
},
json,
);
}
const backup = join(project.dir, value.backupPath);
const target = join(project.dir, value.file);
const before = readFileSync(target, "utf-8");
const after = readFileSync(backup, "utf-8");
const receipts = applyFileMutations(project.dir, [
{ sourceFile: value.file, absPath: target, before, after, expectedVersion: value.version },
]);
const result = {
ok: true,
receipt: receipts.map((receipt) => publicReceipt(receipt)),
file: value.file,
file: undo.batch ? undo.receipts.map((receipt) => receipt.file) : undo.receipts[0]!.file,
};
if (json) console.log(JSON.stringify(withMeta(result), null, 2));
else console.log(`undid ${value.file}`);
else
console.log(
undo.batch
? `undid ${receipts.length} file${receipts.length === 1 ? "" : "s"}`
: `undid ${undo.receipts[0]!.file}`,
);
}
@@ -17,6 +17,31 @@ function project(): string {
return dir;
}
function batchProject() {
const dir = project();
mkdirSync(join(dir, "compositions"));
writeFileSync(
join(dir, "index.html"),
`<div data-composition-id="main" data-duration="12"><div id="clip" data-hf-id="clip" data-start="1" data-duration="2" data-track-index="0"></div><div id="scene" data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="4" data-track-index="1"></div></div>`,
);
writeFileSync(
join(dir, "compositions/scene.html"),
`<div data-composition-id="scene"><div id="nested" data-hf-id="nested" data-start="0" data-duration="2" data-track-index="0"></div></div>`,
);
const files = ["index.html", "compositions/scene.html"];
const sources = () => files.map((file) => readFileSync(join(dir, file), "utf8"));
const before = sources();
const planPath = join(dir, "edits.json");
writeFileSync(
planPath,
JSON.stringify([
{ verb: "set", ref: "#clip", volume: "0.25" },
{ verb: "set", ref: "#nested", volume: "0.5" },
]),
);
return { dir, files, sources, before, planPath };
}
function run(dir: string, ...args: string[]) {
return spawnSync(
"bun",
@@ -569,4 +594,106 @@ describe("timeline edit command", () => {
rmSync(dir, { recursive: true, force: true });
}
});
it.each(["response", "array", "file"])(
"undoes all files in an apply receipt passed as a %s and returns a redo receipt",
(format) => {
const { dir, files, sources, before, planPath } = batchProject();
try {
const applied = run(dir, "apply", planPath);
expect(applied.status, applied.stderr).toBe(0);
const appliedJson: { receipt: Array<Record<string, unknown>> } = JSON.parse(applied.stdout);
expect(appliedJson.receipt).toHaveLength(2);
const after = sources();
expect(after[0]).toContain('data-volume="0.25"');
expect(after[1]).toContain('data-volume="0.5"');
let input = format === "array" ? JSON.stringify(appliedJson.receipt) : applied.stdout;
if (format === "file") {
input = join(dir, "receipt.json");
writeFileSync(input, applied.stdout);
}
const undone = run(dir, "undo", input);
expect(undone.status, undone.stderr).toBe(0);
expect(JSON.parse(undone.stdout)).toMatchObject({ ok: true, file: files });
expect(sources()).toEqual(before);
const redone = run(dir, "undo", undone.stdout);
expect(redone.status, redone.stderr).toBe(0);
expect(sources()).toEqual(after);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
it.each(["stale", "invalid"])(
"leaves the whole undo batch untouched when a later receipt is %s",
(failure) => {
const { dir, sources, planPath } = batchProject();
try {
const applied = run(dir, "apply", planPath);
expect(applied.status, applied.stderr).toBe(0);
const appliedJson: { receipt: Array<Record<string, unknown>> } = JSON.parse(applied.stdout);
if (failure === "stale") {
const childPath = join(dir, "compositions/scene.html");
writeFileSync(childPath, readFileSync(childPath, "utf8") + "<!-- newer edit -->");
}
const current = sources();
const input =
failure === "stale"
? applied.stdout
: JSON.stringify([appliedJson.receipt[0], { file: "bad" }]);
const undone = run(dir, "undo", input);
expect(undone.status).toBe(2);
expect(JSON.parse(undone.stderr)).toMatchObject({
ok: false,
reason:
failure === "stale"
? "file changed since the timeline was read"
: "timeline undo: undo receipt is missing file, version, or backupPath",
});
expect(sources()).toEqual(current);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
it("undoes a complete timeline ids response", () => {
const dir = project();
try {
const indexPath = join(dir, "index.html");
const before = readFileSync(indexPath, "utf8").replace(/ data-hf-id="[^"]*"/g, "");
writeFileSync(indexPath, before);
const stamped = run(dir, "ids");
expect(stamped.status, stamped.stderr).toBe(0);
expect(readFileSync(indexPath, "utf8")).toContain("data-hf-id=");
const undone = run(dir, "undo", stamped.stdout);
expect(undone.status, undone.stderr).toBe(0);
expect(readFileSync(indexPath, "utf8")).toBe(before);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("accepts an empty receipt from an unchanged apply", () => {
const dir = project();
try {
const before = readFileSync(join(dir, "index.html"), "utf8");
const planPath = join(dir, "edits.json");
writeFileSync(planPath, "[]");
const applied = run(dir, "apply", planPath);
expect(applied.status, applied.stderr).toBe(0);
const undone = run(dir, "undo", applied.stdout);
expect(undone.status, undone.stderr).toBe(0);
expect(JSON.parse(undone.stdout)).toMatchObject({ ok: true, receipt: [], file: [] });
expect(readFileSync(join(dir, "index.html"), "utf8")).toBe(before);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});