fix(cli): refuse undo when a target or backup is missing (#5365)

This commit is contained in:
Abdullah M. Nazif
2026-10-24 04:11:31 +00:00
committed by GitHub
parent 191caf943a
commit 9ab4de4599
2 changed files with 88 additions and 16 deletions
+30 -15
View File
@@ -5,7 +5,7 @@ import {
} from "@hyperframes/studio-server";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { join, relative } from "node:path";
import { describeProject, type ProjectTimeline, type TimelineRow } from "./describeProject.js";
import { resolveRef } from "./resolveRef.js";
import { parseTimeExpression } from "./timeExpr.js";
@@ -309,8 +309,25 @@ function readUndoInput(input: string): UndoInput {
return { ok: true, receipts: values, batch: Array.isArray(value) };
}
function refuseUndoError(error: unknown, projectDir: string, json: boolean): void {
if (isRecord(error) && error.code === "ENOENT" && typeof error.path === "string")
return refuse(
"timeline undo",
{
reason: `undo file is missing: ${relative(projectDir, error.path).split("\\").join("/")}`,
fix: "restore the missing target or backup file before retrying undo",
},
json,
);
if (isFileChanged(error))
return refusal(error.message, "read the latest timeline before retrying undo", json);
throw error;
}
export async function runUndo(args: Record<string, unknown>): Promise<void> {
const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined);
const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined, {
requireIndex: false,
});
const json = args.json === true;
const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1];
if (!input)
@@ -321,23 +338,21 @@ export async function runUndo(args: Record<string, unknown>): Promise<void> {
);
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 {
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,
};
});
receipts = applyFileMutations(project.dir, inputs);
} catch (error) {
if (isFileChanged(error))
return refusal(error.message, "read the latest timeline before retrying undo", json);
throw error;
return refuseUndoError(error, project.dir, json);
}
const result = {
ok: true,
+58 -1
View File
@@ -1,5 +1,13 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -778,6 +786,55 @@ describe("timeline edit command", () => {
},
);
it.each([
{ format: "single", missing: "target", index: 1 },
{ format: "single", missing: "backup", index: 1 },
{ format: "batch", missing: "target", index: 1 },
{ format: "batch", missing: "backup", index: 1 },
{ format: "single", missing: "target", index: 0 },
{ format: "batch", missing: "target", index: 0 },
])(
"refuses a $format undo when its $missing at index $index is missing",
({ format, missing, index }) => {
const { dir, files, planPath } = batchProject();
try {
const applied = run(dir, "apply", planPath);
expect(applied.status, applied.stderr).toBe(0);
const receipt: { file: string; backupPath: string } = JSON.parse(applied.stdout).receipt[
index
];
const missingFile = missing === "target" ? receipt.file : receipt.backupPath;
const missingPath = join(dir, missingFile);
rmSync(missingPath);
const remainingFiles = files.filter((file) => join(dir, file) !== missingPath);
const sources = () => remainingFiles.map((file) => readFileSync(join(dir, file), "utf8"));
const current = sources();
const backupDir = join(dir, ".hyperframes", "backup");
const backups = () =>
readdirSync(backupDir)
.sort()
.map((file) => [file, readFileSync(join(backupDir, file), "utf8")]);
const currentBackups = backups();
const input = format === "single" ? JSON.stringify(receipt) : applied.stdout;
const undone = run(dir, "undo", input);
expect(undone.status, undone.stderr).toBe(2);
expect(undone.stdout).toBe("");
expect(JSON.parse(undone.stderr)).toEqual({
ok: false,
reason: `timeline undo: undo file is missing: ${missingFile}`,
fix: "restore the missing target or backup file before retrying undo",
});
expect(sources()).toEqual(current);
expect(backups()).toEqual(currentBackups);
expect(existsSync(missingPath)).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
it("undoes a complete timeline ids response", () => {
const dir = project();
try {