Files
openwork/scripts/ci/verify-clean-revert.mjs

98 lines
4.0 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const SHA_PATTERN = /^[0-9a-f]{40}$/i;
export function parseArgs(args) {
const options = {};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!["--base", "--head", "--reverted"].includes(arg)) {
throw new Error(`Unknown argument: ${arg}`);
}
const value = args[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value.`);
options[arg.slice(2)] = value;
index += 1;
}
if (!options.base || !options.head) {
throw new Error("Usage: node scripts/ci/verify-clean-revert.mjs --base <sha> --head <sha> [--reverted <sha>]");
}
if (options.reverted && !SHA_PATTERN.test(options.reverted)) {
throw new Error("--reverted must be a full 40-character commit SHA.");
}
return options;
}
function command(run, args, cwd, allowFailure = false) {
const result = run("git", args, { cwd, encoding: "utf8" });
if (!allowFailure && result.status !== 0) {
throw new Error(result.stderr.trim() || `git ${args[0]} failed with exit code ${result.status ?? "unknown"}.`);
}
return result;
}
function parseRevertedSha(message) {
const matches = [...message.matchAll(/This reverts commit ([0-9a-f]{40})/gi)];
const shas = new Set(matches.map((match) => match[1].toLowerCase()));
if (shas.size === 0) throw new Error("head commit message has no 'This reverts commit <40-hex>' line");
if (shas.size > 1) throw new Error("head commit message names multiple distinct reverted commits; only single-commit reverts are supported");
return [...shas][0];
}
export function verifyCleanRevert(options, run = spawnSync) {
const cwd = process.cwd();
let reverted = options.reverted?.toLowerCase();
if (!reverted) {
const message = command(run, ["log", "-1", "--format=%B", options.head], cwd).stdout;
reverted = parseRevertedSha(message);
}
const parents = command(run, ["rev-list", "--parents", "-n", "1", reverted], cwd).stdout.trim().split(/\s+/);
if (parents.length > 2) throw new Error("reverted commit is a merge commit; only non-merge commits are supported");
const ancestor = command(run, ["merge-base", "--is-ancestor", reverted, options.base], cwd, true);
if (ancestor.status !== 0) throw new Error("reverted commit is not an ancestor of the PR base");
const tempRoot = mkdtempSync(resolve(tmpdir(), "verify-clean-revert-"));
const worktree = resolve(tempRoot, "worktree");
let added = false;
try {
command(run, ["worktree", "add", "--detach", worktree, options.base], cwd);
added = true;
const revert = command(run, ["revert", "--no-commit", "--no-edit", reverted], worktree, true);
if (revert.status !== 0) throw new Error("revert does not apply cleanly to PR base");
const expectedTree = command(run, ["write-tree"], worktree).stdout.trim();
const headTree = command(run, ["rev-parse", `${options.head}^{tree}`], cwd).stdout.trim();
if (expectedTree !== headTree) throw new Error("PR tree is not the exact inverse of the reverted commit");
return reverted;
} finally {
if (added) command(run, ["worktree", "remove", "--force", worktree], cwd, true);
rmSync(tempRoot, { recursive: true, force: true });
}
}
function main() {
let reverted = "";
try {
const options = parseArgs(process.argv.slice(2));
reverted = options.reverted?.toLowerCase() ?? "";
reverted = verifyCleanRevert(options);
console.log(`PASS clean-revert of ${reverted}`);
console.log("verdict=pass");
console.log(`reverted=${reverted}`);
} catch (error) {
console.log(`FAIL ${error instanceof Error ? error.message : String(error)}`);
console.log("verdict=fail");
console.log(`reverted=${reverted}`);
process.exitCode = 1;
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();