commit ec6eeadbc88f01c3fd43d861865d7a874fd0dc03
parent c407383f03aa83336de341c60ccc399bc5a4ed57
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 15:33:23 -0400
reports: per-report revision history — a bare git store beside report.json, a revision per changed export
reports export commits a revision (report.json, report.md, exports.json) to
sites/<site>/reports/<id>/history-git/ when report.json's sha256 changed since
the newest revision; the message is `Revision N` and a change summary. The
site is author and committer, dated in UTC, with git run in a minimal
environment. The footer names the revision and the report sha256; export.json
records the commit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 1285 insertions(+), 14 deletions(-)
diff --git a/common/lib/report/revisions.test.ts b/common/lib/report/revisions.test.ts
@@ -0,0 +1,144 @@
+// The revision diff and change summary (lib/report/revisions.ts), over two
+// versions of a small fact-check.
+//
+// Run with: node_modules/.bin/tsx --test lib/report/revisions.test.ts
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import type { Report } from "./schema";
+import {
+ claimChanges,
+ reportChangeSummary,
+ revisionCommitMessage,
+ revisionSummaryOf,
+ wordDiff,
+ type DiffSegment,
+} from "./revisions";
+
+const side = (d: DiffSegment[], op: "old" | "new") =>
+ d.filter((s) => s.op === "eq" || s.op === (op === "old" ? "del" : "ins")).map((s) => s.text).join("");
+
+function v1(): Report {
+ return {
+ format: "archilyzer-report",
+ version: 1,
+ id: "demo",
+ kind: "factcheck",
+ title: "Checking a demo article",
+ subtitle: "Three claims",
+ published: "2026-03-01",
+ citations: {
+ c01: { kind: "video", channel: "demo-channel", id: "abc", start: 10, end: 20, quote: "The bridge opened in spring." },
+ c02: { kind: "video", channel: "demo-channel", id: "abc", start: 30, end: 40, quote: "I was there." },
+ c03: { kind: "page", url: "https://example.org/a", quote: "A page." },
+ },
+ sections: [
+ {
+ id: "s1",
+ title: "The bridge",
+ claims: [
+ { id: "k1", title: "When it opened", text: "The bridge opened in 2018.", verdict: "PARTLY", findings: "He says [spring](cite:c01).", citations: ["c01"] },
+ { id: "k2", text: "He was not there.", verdict: "CONTRADICTED", findings: "He [was](cite:c02).", citations: ["c02"] },
+ { id: "k3", text: "A third claim.", verdict: "UNTESTABLE", citations: ["c03"] },
+ ],
+ },
+ ],
+ };
+}
+
+function v2(): Report {
+ const r = v1();
+ r.title = "Checking the demo article";
+ r.series = "Demo checks";
+ delete r.subtitle;
+ const claims = r.sections[0].claims!;
+ claims[0] = { ...claims[0], text: "The bridge opened in 2019.", verdict: "CORROBORATED" };
+ claims[1] = { ...claims[1], findings: "He says he [was there](cite:c02)." };
+ claims.splice(2, 1);
+ claims.push({ id: "k4", title: "The mail", text: "The mail came late.", verdict: "NOT_FOUND", citations: ["c04"] });
+ r.citations!.c02 = { ...r.citations!.c02, quote: "I was there for it." };
+ delete r.citations!.c03;
+ r.citations!.c04 = { kind: "page", url: "https://example.org/b", quote: "Another page." };
+ return r;
+}
+
+test("wordDiff: equal text is one run; a replaced word reads old then new; each side reassembles", () => {
+ assert.deepEqual(wordDiff("same words", "same words"), [{ op: "eq", text: "same words" }]);
+ const d = wordDiff("The bridge opened in 2018.", "The bridge opened in 2019.");
+ assert.deepEqual(d, [
+ { op: "eq", text: "The bridge opened in " },
+ { op: "del", text: "2018." },
+ { op: "ins", text: "2019." },
+ ]);
+ const a = "one two three four five six";
+ const b = "one 2 three four six seven";
+ const d2 = wordDiff(a, b);
+ assert.equal(side(d2, "old"), a);
+ assert.equal(side(d2, "new"), b);
+ assert.deepEqual(wordDiff("", "new text"), [{ op: "ins", text: "new text" }]);
+ assert.deepEqual(wordDiff("old text", ""), [{ op: "del", text: "old text" }]);
+});
+
+test("claimChanges: edited fields with word diffs, added and removed claims", () => {
+ const changes = claimChanges(v1(), v2());
+ assert.deepEqual(
+ changes.map((c) => [c.id, c.status, c.fields.map((f) => f.field)]),
+ [
+ ["k1", "edited", ["text", "verdict"]],
+ ["k2", "edited", ["findings"]],
+ ["k4", "added", ["title", "text", "verdict"]],
+ ["k3", "removed", ["text", "verdict"]],
+ ],
+ );
+ const verdict = changes[0].fields.find((f) => f.field === "verdict")!;
+ assert.deepEqual(verdict.diff, [
+ { op: "del", text: "PARTLY" },
+ { op: "ins", text: "CORROBORATED" },
+ ]);
+ assert.equal(changes[0].section, "The bridge");
+ assert.ok(changes[2].fields.every((f) => f.diff.every((s) => s.op === "ins")));
+ assert.ok(changes[3].fields.every((f) => f.diff.every((s) => s.op === "del")));
+ assert.deepEqual(claimChanges(v1(), v1()), []);
+});
+
+test("reportChangeSummary: every kind of change, in a fixed order", () => {
+ assert.deepEqual(reportChangeSummary(v1(), v2()), [
+ "Title changed: “Checking a demo article” → “Checking the demo article”",
+ "Series added: “Demo checks”",
+ "Subtitle removed (was “Three claims”)",
+ "Claim added: k4 “The mail”",
+ "Claim removed: k3 “A third claim.”",
+ "Verdict changed: k1 PARTLY → CORROBORATED",
+ "Claims edited: k1 (text), k2 (findings)",
+ "Citation added: c04",
+ "Citation removed: c03",
+ "Quote edited: c02",
+ ]);
+});
+
+test("reportChangeSummary: the first revision counts; an edit outside the list says so", () => {
+ assert.deepEqual(reportChangeSummary(null, v1()), ["First revision: 1 section, 3 claims, 3 citations."]);
+ const moved = v1();
+ (moved.citations!.c01 as { start: number }).start = 11;
+ assert.deepEqual(reportChangeSummary(v1(), moved), ["Other edits: no title, claim, verdict, citation or quote changed"]);
+ const summary = v1();
+ summary.summary = "A new summary.";
+ assert.deepEqual(reportChangeSummary(v1(), summary), ["Summary edited"]);
+});
+
+test("reportChangeSummary: long lists are cut, long labels shortened", () => {
+ const many = v1();
+ for (let i = 0; i < 15; i++) {
+ many.sections[0].claims!.push({ id: `n${i}`, text: `${"x".repeat(100)} ${i}` });
+ }
+ const [line] = reportChangeSummary(v1(), many);
+ assert.match(line, /^15 claims added: n0 “x{79}…”, /);
+ assert.match(line, / and 3 more$/);
+});
+
+test("the commit message carries the summary, and gives it back", () => {
+ const lines = reportChangeSummary(v1(), v2());
+ const msg = revisionCommitMessage(2, lines);
+ assert.match(msg, /^Revision 2\n\n- Title changed/);
+ assert.deepEqual(revisionSummaryOf(msg), lines);
+});
diff --git a/common/lib/report/revisions.ts b/common/lib/report/revisions.ts
@@ -0,0 +1,334 @@
+// A REPORT'S REVISIONS — what changed between two versions of a report.json,
+// as the revision history shows it (plans/report-sites.md, "History").
+//
+// `archilyzer reports export` commits a revision to the report's own git
+// history whenever its report.json changed (publish/reportHistory.ts); the
+// commit message is `Revision N` and the lines of `reportChangeSummary`. The
+// site's history page (`/reports/<id>/history/`) lists every revision with
+// that summary and a claim-level word diff against the one before
+// (`claimChanges`), both computed at compose into `history.json`
+// (ReportHistoryView), so the page only renders.
+//
+// Pure, no imports but types: the export site's pages can use it.
+
+import type { Claim, Report } from "./schema";
+
+export const REPORT_HISTORY_FORMAT = "archilyzer-report-history";
+export const REPORT_HISTORY_VERSION = 1;
+
+// The history's one branch, in the report's repository and its published clone.
+export const REPORT_HISTORY_BRANCH = "main";
+
+// ─── Where it is published (site-root paths) ───
+
+export function reportHistoryPagePath(reportId: string): string {
+ return `/reports/${reportId}/history/`;
+}
+
+export function reportHistoryViewPath(reportId: string): string {
+ return `/reports/${reportId}/history/history.json`;
+}
+
+// The dumb-HTTP clone of the report's history: `git clone <site>/reports/<id>/history/repo`.
+export function reportHistoryRepoPath(reportId: string): string {
+ return `/reports/${reportId}/history/repo`;
+}
+
+// ─── Views ───
+
+// One run of words in a diff: the same in both, inserted, or deleted.
+export type DiffSegment = { op: "eq" | "ins" | "del"; text: string };
+
+export const CLAIM_DIFF_FIELDS = ["title", "text", "verdict", "findings"] as const;
+export type ClaimDiffField = (typeof CLAIM_DIFF_FIELDS)[number];
+
+export type ClaimFieldDiff = { field: ClaimDiffField; diff: DiffSegment[] };
+
+// A claim that changed between two revisions: added (its fields all
+// inserted), removed (all deleted) or edited (each field that differs).
+export type ClaimChange = {
+ id: string;
+ // The title of the section it is in (the newer revision's, for a removed
+ // claim the older's).
+ section: string;
+ status: "added" | "removed" | "edited";
+ fields: ClaimFieldDiff[];
+};
+
+export type ReportRevisionView = {
+ revision: number;
+ // The commit's date, ISO 8601 in UTC.
+ date: string;
+ // The commit's full hash.
+ commit: string;
+ // The sha256 of the revision's report.json — the hash every export's
+ // footer prints.
+ reportSha256: string;
+ // The change summary, one line each (the commit message's body).
+ summary: string[];
+ // The claims that changed against the revision before; none for the first.
+ claims: ClaimChange[];
+};
+
+export type ReportHistoryView = {
+ format: typeof REPORT_HISTORY_FORMAT;
+ version: typeof REPORT_HISTORY_VERSION;
+ reportId: string;
+ // The report's name as its newest revision has it.
+ series?: string;
+ title: string;
+ branch: string;
+ // What `git clone` takes: the repository's URL on the site, or its
+ // site-root path when the site has no `siteUrl`.
+ clone: string;
+ // Oldest first.
+ revisions: ReportRevisionView[];
+};
+
+// What a report's page shows of its history: the newest revision, and
+// whether the report as published is that revision (false when report.json
+// changed after it and was not exported again).
+export type ReportHistoryRef = {
+ revision: number;
+ date: string;
+ href: string;
+ current: boolean;
+};
+
+// ─── The word diff ───
+
+// Words and the whitespace between them, each a token.
+export function diffTokens(text: string): string[] {
+ return text.match(/\s+|[^\s]+/g) ?? [];
+}
+
+// Above this many token pairs the diff gives up on alignment: the whole old
+// text deleted, the whole new text inserted.
+export const WORD_DIFF_MAX_CELLS = 4_000_000;
+
+function push(out: DiffSegment[], op: DiffSegment["op"], text: string): void {
+ if (!text) return;
+ const last = out[out.length - 1];
+ if (last && last.op === op) last.text += text;
+ else out.push({ op, text });
+}
+
+// `a` → `b` as runs of words kept, deleted and inserted: a longest common
+// subsequence over word and whitespace tokens, the common ends trimmed first.
+// Adjacent runs of one kind are merged; an equal text is one `eq` run.
+export function wordDiff(a: string, b: string): DiffSegment[] {
+ const out: DiffSegment[] = [];
+ if (a === b) {
+ push(out, "eq", a);
+ return out;
+ }
+ const x = diffTokens(a);
+ const y = diffTokens(b);
+ let pre = 0;
+ while (pre < x.length && pre < y.length && x[pre] === y[pre]) pre++;
+ let suf = 0;
+ while (suf < x.length - pre && suf < y.length - pre && x[x.length - 1 - suf] === y[y.length - 1 - suf]) suf++;
+ const xs = x.slice(pre, x.length - suf);
+ const ys = y.slice(pre, y.length - suf);
+ push(out, "eq", x.slice(0, pre).join(""));
+ const n = xs.length;
+ const m = ys.length;
+ if (n === 0 || m === 0 || n * m > WORD_DIFF_MAX_CELLS) {
+ push(out, "del", xs.join(""));
+ push(out, "ins", ys.join(""));
+ } else {
+ // lcs[i][j] = the LCS length of xs[i..] and ys[j..].
+ const w = m + 1;
+ const lcs = new Uint32Array((n + 1) * w);
+ for (let i = n - 1; i >= 0; i--) {
+ for (let j = m - 1; j >= 0; j--) {
+ lcs[i * w + j] = xs[i] === ys[j] ? lcs[(i + 1) * w + j + 1] + 1 : Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]);
+ }
+ }
+ // Deletions before insertions within a change, so a replaced word reads
+ // old-then-new.
+ let i = 0;
+ let j = 0;
+ let del = "";
+ let ins = "";
+ const flush = () => {
+ push(out, "del", del);
+ push(out, "ins", ins);
+ del = "";
+ ins = "";
+ };
+ while (i < n || j < m) {
+ if (i < n && j < m && xs[i] === ys[j]) {
+ flush();
+ push(out, "eq", xs[i]);
+ i++;
+ j++;
+ } else if (j < m && (i >= n || lcs[i * w + j + 1] >= lcs[(i + 1) * w + j])) {
+ ins += ys[j++];
+ } else {
+ del += xs[i++];
+ }
+ }
+ flush();
+ }
+ push(out, "eq", x.slice(x.length - suf).join(""));
+ return out;
+}
+
+// ─── Claims ───
+
+type PlacedClaim = { claim: Claim; section: string };
+
+function claimsById(report: Report): Map<string, PlacedClaim> {
+ const out = new Map<string, PlacedClaim>();
+ for (const s of report.sections) for (const claim of s.claims ?? []) out.set(claim.id, { claim, section: s.title });
+ return out;
+}
+
+function claimField(c: Claim, f: ClaimDiffField): string {
+ return (c[f] ?? "").toString();
+}
+
+// Every claim that differs between `prev` and `next` in its title, text,
+// verdict or findings, added or removed — in the newer revision's order, the
+// removed ones after, in the older's.
+export function claimChanges(prev: Report, next: Report): ClaimChange[] {
+ const before = claimsById(prev);
+ const after = claimsById(next);
+ const out: ClaimChange[] = [];
+ for (const [id, { claim, section }] of after) {
+ const old = before.get(id);
+ if (!old) {
+ out.push({
+ id,
+ section,
+ status: "added",
+ fields: CLAIM_DIFF_FIELDS.filter((f) => claimField(claim, f)).map((f) => ({ field: f, diff: wordDiff("", claimField(claim, f)) })),
+ });
+ continue;
+ }
+ const fields = CLAIM_DIFF_FIELDS.filter((f) => claimField(old.claim, f) !== claimField(claim, f)).map((f) => ({
+ field: f,
+ diff: wordDiff(claimField(old.claim, f), claimField(claim, f)),
+ }));
+ if (fields.length > 0) out.push({ id, section, status: "edited", fields });
+ }
+ for (const [id, { claim, section }] of before) {
+ if (after.has(id)) continue;
+ out.push({
+ id,
+ section,
+ status: "removed",
+ fields: CLAIM_DIFF_FIELDS.filter((f) => claimField(claim, f)).map((f) => ({ field: f, diff: wordDiff(claimField(claim, f), "") })),
+ });
+ }
+ return out;
+}
+
+// ─── The change summary ───
+
+// A claim as one line names it: its title, else its text, cut short.
+export const SUMMARY_LABEL_MAX = 80;
+// At most this many claims or citations are named in one line.
+export const SUMMARY_LIST_MAX = 12;
+
+function short(s: string): string {
+ const one = s.replace(/\s+/g, " ").trim();
+ return one.length > SUMMARY_LABEL_MAX ? `${one.slice(0, SUMMARY_LABEL_MAX - 1)}…` : one;
+}
+
+function claimLabel(c: Claim): string {
+ return `${c.id} “${short(c.title || c.text)}”`;
+}
+
+function list(items: string[]): string {
+ const shown = items.slice(0, SUMMARY_LIST_MAX).join(", ");
+ return items.length > SUMMARY_LIST_MAX ? `${shown} and ${items.length - SUMMARY_LIST_MAX} more` : shown;
+}
+
+function countClaims(r: Report): number {
+ return r.sections.reduce((n, s) => n + (s.claims?.length ?? 0), 0);
+}
+
+const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
+
+function headerChange(name: string, a: string | undefined, b: string | undefined): string | null {
+ if ((a ?? "") === (b ?? "")) return null;
+ if (!a) return `${name} added: “${short(b!)}”`;
+ if (!b) return `${name} removed (was “${short(a)}”)`;
+ return `${name} changed: “${short(a)}” → “${short(b)}”`;
+}
+
+// What changed from `prev` to `next`, one line each, in a fixed order: the
+// title, series and subtitle; claims added and removed; verdicts changed;
+// claims whose title, text or findings were edited; citations added and
+// removed; quotes edited; the summary and method. The first revision
+// (`prev` null) is one line counting what it holds. A change outside all of
+// these (a citation's span, a source, formatting) is one line saying so.
+export function reportChangeSummary(prev: Report | null, next: Report): string[] {
+ if (!prev) {
+ return [
+ `First revision: ${plural(next.sections.length, "section")}, ${plural(countClaims(next), "claim")}, ` +
+ `${plural(Object.keys(next.citations ?? {}).length, "citation")}.`,
+ ];
+ }
+ const out: string[] = [];
+ for (const line of [
+ headerChange("Title", prev.title, next.title),
+ headerChange("Series", prev.series, next.series),
+ headerChange("Subtitle", prev.subtitle, next.subtitle),
+ ]) {
+ if (line) out.push(line);
+ }
+
+ const before = claimsById(prev);
+ const after = claimsById(next);
+ const added = [...after.values()].filter((c) => !before.has(c.claim.id)).map((c) => claimLabel(c.claim));
+ const removed = [...before.values()].filter((c) => !after.has(c.claim.id)).map((c) => claimLabel(c.claim));
+ if (added.length) out.push(`${added.length === 1 ? "Claim" : `${added.length} claims`} added: ${list(added)}`);
+ if (removed.length) out.push(`${removed.length === 1 ? "Claim" : `${removed.length} claims`} removed: ${list(removed)}`);
+ const verdicts: string[] = [];
+ const edited: string[] = [];
+ for (const [id, { claim }] of after) {
+ const old = before.get(id)?.claim;
+ if (!old) continue;
+ if ((old.verdict ?? "") !== (claim.verdict ?? "")) {
+ verdicts.push(`${id} ${old.verdict ?? "none"} → ${claim.verdict ?? "none"}`);
+ }
+ const fields = (["title", "text", "findings"] as const).filter((f) => (old[f] ?? "") !== (claim[f] ?? ""));
+ if (fields.length) edited.push(`${id} (${fields.join(", ")})`);
+ }
+ if (verdicts.length) out.push(`Verdict${verdicts.length === 1 ? "" : "s"} changed: ${list(verdicts)}`);
+ if (edited.length) out.push(`Claim${edited.length === 1 ? "" : "s"} edited: ${list(edited)}`);
+
+ const cBefore = prev.citations ?? {};
+ const cAfter = next.citations ?? {};
+ const cAdded = Object.keys(cAfter).filter((id) => !(id in cBefore));
+ const cRemoved = Object.keys(cBefore).filter((id) => !(id in cAfter));
+ const quotes = Object.keys(cAfter).filter((id) => id in cBefore && cBefore[id].quote !== cAfter[id].quote);
+ if (cAdded.length) out.push(`Citation${cAdded.length === 1 ? "" : "s"} added: ${list(cAdded)}`);
+ if (cRemoved.length) out.push(`Citation${cRemoved.length === 1 ? "" : "s"} removed: ${list(cRemoved)}`);
+ if (quotes.length) out.push(`Quote${quotes.length === 1 ? "" : "s"} edited: ${list(quotes)}`);
+
+ if ((prev.summary ?? "") !== (next.summary ?? "")) out.push("Summary edited");
+ if ((prev.method ?? "") !== (next.method ?? "")) out.push("Method edited");
+ if (out.length === 0) out.push("Other edits: no title, claim, verdict, citation or quote changed");
+ return out;
+}
+
+// A revision's commit message: `Revision N`, a blank line, then the summary,
+// one `- ` line each.
+export function revisionCommitMessage(revision: number, summary: readonly string[]): string {
+ return `Revision ${revision}\n\n${summary.map((l) => `- ${l}`).join("\n")}\n`;
+}
+
+// The summary lines back out of a commit message `revisionCommitMessage`
+// wrote (anything else: its non-empty lines after the subject).
+export function revisionSummaryOf(message: string): string[] {
+ return message
+ .split("\n")
+ .slice(1)
+ .map((l) => l.trim())
+ .filter(Boolean)
+ .map((l) => l.replace(/^- /, ""));
+}
diff --git a/common/publish/reportExportFiles.ts b/common/publish/reportExportFiles.ts
@@ -58,6 +58,16 @@ export type ReportExportManifest = {
reportSha256: string;
exportedAt: string;
footer: ReportExportFooter;
+ // The revision these exports belong to (publish/reportHistory.ts): its
+ // number, commit, date and change summary, and whether this export made
+ // it. Absent when the history could not be read or written.
+ revision?: {
+ revision: number;
+ commit: string;
+ date: string;
+ summary: string[];
+ committed: boolean;
+ };
files: Partial<Record<ReportExportFormat, ReportExportFileEntry>>;
// What was skipped, and why (a PDF with no browser, a pack with no `zip`).
notes: string[];
diff --git a/common/publish/reportExports.ts b/common/publish/reportExports.ts
@@ -40,9 +40,18 @@
// local).
//
// THE FOOTER (lib/report/exportHtml.ts ReportExportFooter) names which document
-// this is: today the report's date and the sha256 of its report.json;
-// `exportFooterFor` below is where the revision history (slice RH) fills in
-// the revision number and the commit.
+// this is: `Revision N`, the report's date and the sha256 of its report.json
+// (`exportFooterFor`). It never names a commit: the revision's commit holds
+// report.md, whose footer would have to name the commit holding it. The
+// history page maps the sha256 to its commit (./reportHistory.ts).
+//
+// THE REVISION. Each report's export commits a revision to the report's own
+// history (./reportHistory.ts, `sites/<siteId>/reports/<id>/history-git/`)
+// when its report.json changed since the newest revision: report.json,
+// report.md and exports.json (the sha256 of every file made and of the
+// citations JSON and CSV), with the change summary as the message. An export
+// of an unchanged report commits nothing and belongs to the newest revision.
+// export.json records the revision and its commit.
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
import os from "node:os";
@@ -79,12 +88,17 @@ import {
REPORT_EXPORT_MANIFEST_VERSION,
reportExportDir,
reportExportsDir,
- reportFileSha256,
sha256Hex,
type ReportExportFileEntry,
type ReportExportManifest,
} from "./reportExportFiles";
-import { siteReportDir, type ReportMediaIndex } from "./reportMedia";
+import { siteReportDir, siteReportFile, type ReportMediaIndex } from "./reportMedia";
+import {
+ commitReportRevision,
+ pendingRevision,
+ reportHistoryGitDir,
+ revisionExports,
+} from "./reportHistory";
// A still or a screenshot in report.html: at most this wide, recompressed.
export const EXPORT_IMAGE_MAX_WIDTH = 1200;
@@ -150,13 +164,17 @@ export function parseReportExportFormats(v: string): ReportExportFormat[] | null
return REPORT_EXPORT_FORMATS.filter((f) => parts.includes(f));
}
-// THE FOOTER HOOK. Today: the report's own date and the sha256 of its
-// report.json. The revision history (slice RH) adds `revision` and `commit`
-// here — nothing else changes: the HTML, PDF and Markdown all print
+// THE FOOTER: the revision this export belongs to (when the report has a
+// history), the report's own date and the sha256 of its report.json — never
+// the commit (see the header). The HTML, PDF and Markdown all print
// reportExportFooterLine of what this returns, leaving out what is absent.
-export function exportFooterFor(view: Pick<ReportPageView, "published" | "updated">, reportSha256: string): ReportExportFooter {
+export function exportFooterFor(
+ view: Pick<ReportPageView, "published" | "updated">,
+ reportSha256: string,
+ revision?: number,
+): ReportExportFooter {
const date = view.updated ?? view.published;
- return { ...(date ? { date } : {}), reportSha256 };
+ return { ...(revision !== undefined ? { revision } : {}), ...(date ? { date } : {}), reportSha256 };
}
// ─── PDF ───
@@ -393,14 +411,15 @@ async function exportOneReport(o: {
signal?: AbortSignal;
}): Promise<{ result?: ReportExportResult; problems: ReportExportProblem[] }> {
const { paths, site, report, view } = o;
- const sha = await reportFileSha256(paths, site.siteId, report.id);
- if (!sha) return { problems: [{ report: report.id, message: "its report.json cannot be read" }] };
+ const reportJson = await readFile(siteReportFile(paths, site.siteId, report.id)).catch(() => null);
+ if (!reportJson) return { problems: [{ report: report.id, message: "its report.json cannot be read" }] };
return writeReportExports({
...o,
dir: reportExportDir(paths, site.siteId, report.id),
- reportSha256: sha,
+ reportSha256: sha256Hex(reportJson),
files: reportFiles(paths, site.siteId, view, o.resolved),
ffmpegBin: paths.ffmpegBin,
+ history: { gitDir: reportHistoryGitDir(paths, site.siteId, report.id), reportJson },
});
}
@@ -409,6 +428,9 @@ async function exportOneReport(o: {
// calls it for each report; the report-site e2e stage calls it for its
// fixture. `report` is the document as resolved (its verification
// computed): the evidence pack's citations.json is its citation set.
+// `history` names the report's revision store and the report.json's bytes
+// (their sha256 is `reportSha256`): the footer names the revision, and a
+// changed report.json is committed as the next one.
export async function writeReportExports(o: {
dir: string;
site: Pick<Site, "siteId" | "siteUrl" | "siteTitle">;
@@ -423,11 +445,21 @@ export async function writeReportExports(o: {
now: Date;
log: (line: string) => void;
signal?: AbortSignal;
+ history?: { gitDir: string; reportJson: Uint8Array };
}): Promise<{ result: ReportExportResult; problems: ReportExportProblem[] }> {
const { site, report, view, files } = o;
const problems: ReportExportProblem[] = [];
const sha = o.reportSha256;
- const footer = exportFooterFor(view, sha);
+ let pending: Awaited<ReturnType<typeof pendingRevision>> | null = null;
+ if (o.history) {
+ if (sha256Hex(o.history.reportJson) !== sha) throw new Error(`report ${report.id}: the history's report.json is not the one exported`);
+ try {
+ pending = await pendingRevision(o.history.gitDir, sha);
+ } catch (e) {
+ problems.push({ report: report.id, message: `its revision history cannot be read: ${firstLine(e)}` });
+ }
+ }
+ const footer = exportFooterFor(view, sha, pending?.revision);
const siteUrl = site.siteUrl || undefined;
const siteTitle = site.siteTitle || undefined;
@@ -517,6 +549,35 @@ export async function writeReportExports(o: {
}
}
+ // The revision: committed when report.json changed since the newest.
+ let revision: ReportExportManifest["revision"];
+ if (o.history && pending) {
+ try {
+ const made: Record<string, Uint8Array | string> = {};
+ for (const f of REPORT_EXPORT_FORMATS) {
+ if (entries[f]) made[REPORT_EXPORT_FILENAMES[f]] = await readFile(path.join(dir, REPORT_EXPORT_FILENAMES[f]));
+ }
+ made["citations.json"] = `${JSON.stringify(citationSet(report, view), null, 2)}\n`;
+ made["citations.csv"] = citationsCsv(view);
+ const r = await commitReportRevision({
+ gitDir: o.history.gitDir,
+ site,
+ reportJson: o.history.reportJson,
+ markdown,
+ exports: revisionExports(report.id, sha, made),
+ now: o.now,
+ });
+ revision = { revision: r.revision, commit: r.commit, date: r.date, summary: r.summary, committed: r.committed };
+ o.log(
+ r.committed
+ ? ` + ${report.id}: revision ${r.revision} (${r.commit.slice(0, 12)}): ${r.summary.join("; ")}`
+ : ` = ${report.id}: unchanged since revision ${r.revision} (${r.commit.slice(0, 12)})`,
+ );
+ } catch (e) {
+ problems.push({ report: report.id, message: `its revision could not be committed: ${firstLine(e)}` });
+ }
+ }
+
const manifest: ReportExportManifest = {
format: REPORT_EXPORT_MANIFEST_FORMAT,
version: REPORT_EXPORT_MANIFEST_VERSION,
@@ -525,6 +586,7 @@ export async function writeReportExports(o: {
reportSha256: sha,
exportedAt: o.now.toISOString(),
footer,
+ ...(revision ? { revision } : {}),
files: entries,
notes,
};
diff --git a/common/publish/reportHistory.test.ts b/common/publish/reportHistory.test.ts
@@ -0,0 +1,248 @@
+// A report's revision history (publish/reportHistory.ts): commits only on a
+// change, the site as author and committer in UTC with nothing of the
+// operator's, the history view, and the published dumb-HTTP clone — served by
+// the export's own static server and cloned with git.
+//
+// Run with: node_modules/.bin/tsx --test publish/reportHistory.test.ts
+
+import { after, test } from "node:test";
+import assert from "node:assert/strict";
+import { execFileSync, spawn } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import net from "node:net";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ REPORT_HISTORY_REPO_FILE_RE,
+ commitReportRevision,
+ historyGitEnv,
+ pendingRevision,
+ publishReportHistory,
+ readReportHistoryView,
+ readRevisionHead,
+ reportHistoryRef,
+ revisionExports,
+} from "./reportHistory";
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const SERVE_OUT = path.resolve(HERE, "../../export/scripts/serve-out.mjs");
+const ROOT = mkdtempSync(path.join(os.tmpdir(), "report-history-"));
+after(() => rmSync(ROOT, { recursive: true, force: true }));
+
+const SITE = { siteId: "demo-site", siteTitle: "Demo Reports" };
+const sha = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex");
+
+function reportJson(over: { title?: string; text?: string; verdict?: string } = {}): Buffer {
+ return Buffer.from(
+ `${JSON.stringify(
+ {
+ format: "archilyzer-report",
+ version: 1,
+ id: "demo",
+ kind: "factcheck",
+ title: over.title ?? "Checking a demo article",
+ citations: { c01: { kind: "page", url: "https://example.org/a", quote: "A page." } },
+ sections: [
+ {
+ id: "s1",
+ title: "The bridge",
+ claims: [{ id: "k1", text: over.text ?? "The bridge opened in 2018.", verdict: over.verdict ?? "PARTLY", citations: ["c01"] }],
+ },
+ ],
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+}
+
+const gitLog = (gitDir: string, format: string) =>
+ execFileSync("git", ["--git-dir", gitDir, "log", `--format=${format}`, "refs/heads/main"], {
+ env: historyGitEnv(),
+ encoding: "utf8",
+ }).trim();
+
+async function commit(gitDir: string, data: Buffer, now: string) {
+ return commitReportRevision({
+ gitDir,
+ site: SITE,
+ reportJson: data,
+ markdown: `# ${JSON.parse(data.toString()).title}\n`,
+ exports: revisionExports("demo", sha(data), { "report.md": "# md\n", "citations.csv": "a,b\n" }),
+ now: new Date(now),
+ });
+}
+
+// The operator's identity as git and the environment would give it, when
+// there is one: none of it may reach a commit.
+function operatorStrings(): string[] {
+ const out = new Set<string>(["Leaky Operator", "leaky@operator.example", "Leaky Committer"]);
+ for (const key of ["user.name", "user.email"]) {
+ try {
+ const v = execFileSync("git", ["config", "--global", key], { encoding: "utf8" }).trim();
+ if (v) out.add(v);
+ } catch {
+ // unset
+ }
+ }
+ const user = os.userInfo().username;
+ if (user && user.length > 2) out.add(user);
+ return [...out];
+}
+
+test("a revision is committed only when report.json changed", async () => {
+ const gitDir = path.join(ROOT, "only-on-change", "history-git");
+ assert.deepEqual(await pendingRevision(gitDir, sha(reportJson())), { revision: 1, changed: true, head: null });
+ const r1 = await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z");
+ assert.equal(r1.revision, 1);
+ assert.equal(r1.committed, true);
+ assert.deepEqual(r1.summary, ["First revision: 1 section, 1 claim, 1 citation."]);
+ const again = await commit(gitDir, reportJson(), "2026-03-02T12:00:00Z");
+ assert.equal(again.committed, false);
+ assert.equal(again.revision, 1);
+ assert.equal(again.commit, r1.commit);
+ assert.equal(gitLog(gitDir, "%H").split("\n").length, 1, "no second commit");
+ const r2 = await commit(gitDir, reportJson({ text: "The bridge opened in 2019.", verdict: "CORROBORATED" }), "2026-03-08T12:00:00Z");
+ assert.equal(r2.revision, 2);
+ assert.deepEqual(r2.summary, ["Verdict changed: k1 PARTLY → CORROBORATED", "Claim edited: k1 (text)"]);
+ const head = await readRevisionHead(gitDir);
+ assert.equal(head?.commit, r2.commit);
+ assert.equal(head?.date, "2026-03-08T12:00:00Z");
+ assert.deepEqual(gitLog(gitDir, "%s").split("\n"), ["Revision 2", "Revision 1"]);
+ // A revision holds report.json byte for byte, report.md and exports.json.
+ const tree = execFileSync("git", ["--git-dir", gitDir, "ls-tree", "--name-only", "refs/heads/main"], {
+ env: historyGitEnv(),
+ encoding: "utf8",
+ });
+ assert.deepEqual(tree.trim().split("\n"), ["exports.json", "report.json", "report.md"]);
+ const blob = execFileSync("git", ["--git-dir", gitDir, "cat-file", "blob", "refs/heads/main:report.json"], { env: historyGitEnv() });
+ assert.equal(sha(blob), r2.reportSha256);
+ const exportsJson = JSON.parse(
+ execFileSync("git", ["--git-dir", gitDir, "cat-file", "blob", "refs/heads/main:exports.json"], { env: historyGitEnv(), encoding: "utf8" }),
+ );
+ assert.equal(exportsJson.format, "archilyzer-report-revision-exports");
+ assert.equal(exportsJson.reportSha256, r2.reportSha256);
+ assert.deepEqual(exportsJson.files["citations.csv"], { bytes: 4, sha256: sha("a,b\n") });
+});
+
+test("the site is author and committer, dated in UTC; nothing of the operator's is in a commit", async () => {
+ const saved = { ...process.env };
+ // What a careless git invocation would pick up from this process.
+ Object.assign(process.env, {
+ GIT_AUTHOR_NAME: "Leaky Operator",
+ GIT_AUTHOR_EMAIL: "leaky@operator.example",
+ GIT_COMMITTER_NAME: "Leaky Committer",
+ GIT_COMMITTER_EMAIL: "leaky@operator.example",
+ EMAIL: "leaky@operator.example",
+ TZ: "America/Chicago",
+ GIT_AUTHOR_DATE: "2001-01-01T00:00:00-0600",
+ });
+ const gitDir = path.join(ROOT, "identity", "history-git");
+ try {
+ await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z");
+ await commit(gitDir, reportJson({ title: "Checking the demo article" }), "2026-03-08T18:30:05Z");
+ } finally {
+ for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
+ Object.assign(process.env, saved);
+ }
+ assert.equal(
+ gitLog(gitDir, "%an|%ae|%cn|%ce|%ad|%cd").replace(/\n/g, "\n"),
+ [
+ `Demo Reports|noreply@demo-site.invalid|Demo Reports|noreply@demo-site.invalid|Sun Mar 8 18:30:05 2026 +0000|Sun Mar 8 18:30:05 2026 +0000`,
+ `Demo Reports|noreply@demo-site.invalid|Demo Reports|noreply@demo-site.invalid|Sun Mar 1 12:00:00 2026 +0000|Sun Mar 1 12:00:00 2026 +0000`,
+ ].join("\n"),
+ );
+ const raw = execFileSync("git", ["--git-dir", gitDir, "cat-file", "--batch-all-objects", "--batch"], { env: historyGitEnv() }).toString();
+ for (const s of operatorStrings()) assert.ok(!raw.includes(s), `a commit names the operator (${s.length} chars)`);
+ assert.doesNotMatch(raw, /gpgsig/);
+ assert.doesNotMatch(raw, /[+-](?!0000)\d{4}\n/, "every date is +0000");
+});
+
+test("the history view: each revision's hashes, summary and claim diff; the page's ref", async () => {
+ const gitDir = path.join(ROOT, "view", "history-git");
+ const r1 = await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z");
+ const r2 = await commit(gitDir, reportJson({ text: "The bridge opened in 2019." }), "2026-03-08T12:00:00Z");
+ const view = await readReportHistoryView(gitDir, { reportId: "demo", siteUrl: "https://reports.example.org/" });
+ assert.ok(view);
+ assert.equal(view.clone, "https://reports.example.org/reports/demo/history/repo");
+ assert.deepEqual(
+ view.revisions.map((r) => [r.revision, r.commit, r.reportSha256, r.date]),
+ [
+ [1, r1.commit, r1.reportSha256, "2026-03-01T12:00:00Z"],
+ [2, r2.commit, r2.reportSha256, "2026-03-08T12:00:00Z"],
+ ],
+ );
+ assert.deepEqual(view.revisions[0].claims, []);
+ assert.deepEqual(view.revisions[1].summary, ["Claim edited: k1 (text)"]);
+ assert.deepEqual(view.revisions[1].claims[0].fields[0].diff, [
+ { op: "eq", text: "The bridge opened in " },
+ { op: "del", text: "2018." },
+ { op: "ins", text: "2019." },
+ ]);
+ assert.deepEqual(reportHistoryRef(view, r2.reportSha256), {
+ revision: 2,
+ date: "2026-03-08T12:00:00Z",
+ href: "/reports/demo/history/",
+ current: true,
+ });
+ assert.equal(reportHistoryRef(view, sha("edited")).current, false);
+ const relative = await readReportHistoryView(gitDir, { reportId: "demo" });
+ assert.equal(relative?.clone, "/reports/demo/history/repo");
+ assert.equal(await readReportHistoryView(path.join(ROOT, "none"), { reportId: "demo" }), null);
+});
+
+async function freePort(): Promise<number> {
+ return new Promise((resolve, reject) => {
+ const s = net.createServer();
+ s.once("error", reject);
+ s.listen(0, "127.0.0.1", () => {
+ const { port } = s.address() as net.AddressInfo;
+ s.close(() => resolve(port));
+ });
+ });
+}
+
+test("the published clone: an allowlisted dumb-HTTP repository that clones over HTTP to the same report.json", async () => {
+ const gitDir = path.join(ROOT, "clone", "history-git");
+ await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z");
+ await commit(gitDir, reportJson({ verdict: "CONTRADICTED" }), "2026-03-08T12:00:00Z");
+ const view = await readReportHistoryView(gitDir, { reportId: "demo" });
+ assert.ok(view);
+ const publicDir = path.join(ROOT, "clone", "public");
+ const files = await publishReportHistory({ gitDir, publicDir, view });
+ for (const f of files) assert.match(f, REPORT_HISTORY_REPO_FILE_RE);
+ assert.ok(files.includes("info/refs") && files.includes("objects/info/packs"));
+ assert.ok(files.some((f) => f.endsWith(".pack")));
+ const published = JSON.parse(readFileSync(path.join(publicDir, "reports", "demo", "history", "history.json"), "utf8"));
+ assert.deepEqual(published, view);
+
+ const port = await freePort();
+ const server = spawn(process.execPath, [SERVE_OUT, publicDir, String(port)], {
+ env: { ...process.env, HOST: "127.0.0.1" },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ try {
+ await new Promise<void>((resolve, reject) => {
+ server.once("error", reject);
+ server.once("exit", (code) => reject(new Error(`serve-out exited ${code}`)));
+ server.stdout.on("data", (c: Buffer) => {
+ if (c.toString().includes("serving")) resolve();
+ });
+ });
+ const dest = path.join(ROOT, "clone", "cloned");
+ execFileSync("git", ["clone", "--quiet", `http://127.0.0.1:${port}/reports/demo/history/repo`, dest], {
+ env: historyGitEnv(),
+ stdio: "pipe",
+ });
+ const last = view.revisions[view.revisions.length - 1];
+ assert.equal(sha(readFileSync(path.join(dest, "report.json"))), last.reportSha256);
+ const head = execFileSync("git", ["-C", dest, "rev-parse", "HEAD"], { env: historyGitEnv(), encoding: "utf8" }).trim();
+ assert.equal(head, last.commit);
+ const first = execFileSync("git", ["-C", dest, "show", `${view.revisions[0].commit}:report.json`], { env: historyGitEnv() });
+ assert.equal(sha(first), view.revisions[0].reportSha256);
+ } finally {
+ server.kill();
+ }
+});
diff --git a/common/publish/reportHistory.ts b/common/publish/reportHistory.ts
@@ -0,0 +1,473 @@
+// A REPORT'S REVISION HISTORY — per report, never site-wide (plans/report-sites.md,
+// "History"). Readers can check that a report is the one published, and see
+// every edit with its hashes.
+//
+// THE STORE. A bare git repository beside the report's report.json:
+// `sites/<siteId>/reports/<reportId>/history-git/` (never a path segment named
+// `.git` — wrangler drops one). It sits inside the corpus's own tree; the
+// operator adds `history-git/` to that tree's .gitignore (nothing here writes
+// it). One branch, `main`; one commit per revision, holding
+//
+// report.json the report.json as it was on disk, byte for byte
+// report.md the Markdown export of that revision (lib/report/exportMarkdown.ts)
+// exports.json the sha256 and size of every export file made for that
+// revision and of its citations JSON and CSV
+//
+// with the message `Revision N` and the change summary against the revision
+// before (lib/report/revisions.ts reportChangeSummary).
+//
+// WHEN A REVISION IS MADE. `reports export` (./reportExports.ts) commits one
+// when the sha256 of report.json differs from the newest revision's; an export
+// of an unchanged report commits nothing (its files may still differ — a PDF
+// is not byte-stable — and exports.json keeps the hashes of the revision's own
+// export).
+//
+// WHO AND WHEN. Every commit's author and committer is the SITE — its title,
+// `noreply@<siteId>.invalid` — dated in UTC (`+0000`). git runs with a minimal
+// environment: no system or global config, HOME pointing nowhere, none of the
+// operator's GIT_* variables (cleanGitEnv) — so no operator name, email, time
+// zone or signing key reaches a commit.
+//
+// THE FOOTER SCHEME. An export's footer (`exportFooterFor`) prints `Revision N`
+// and the sha256 of the report.json it was made from — never the commit: a
+// commit holds report.md, whose footer would have to name the commit holding
+// it. The history page and history.json map each revision's report sha256 to
+// its commit, so a reader goes footer → sha256 → commit. export.json (the
+// local manifest) records the commit once it is made.
+//
+// PUBLISHING. Compose (./composeReports.ts) reads the revisions into
+// `history.json` (ReportHistoryView) and stages a dumb-HTTP clone at
+// `reports/<id>/history/repo/`, built as the source mirror is
+// (./source.ts): a fresh bare clone, repacked, `update-server-info`, then an
+// ALLOWLISTED copy — HEAD, packed-refs, info/refs, objects/info/packs, the
+// packs, refs/heads/main — so no config, hook or log of the store is
+// published.
+
+import { createHash } from "node:crypto";
+import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { execa } from "execa";
+import { publishFileSizeProblem } from "../lib/builtExport";
+import type { Paths } from "../lib/paths";
+import type { Site } from "../lib/site";
+import type { Report } from "../lib/report/schema";
+import {
+ REPORT_HISTORY_BRANCH,
+ REPORT_HISTORY_FORMAT,
+ REPORT_HISTORY_VERSION,
+ claimChanges,
+ reportChangeSummary,
+ reportHistoryPagePath,
+ reportHistoryRepoPath,
+ reportHistoryViewPath,
+ revisionCommitMessage,
+ revisionSummaryOf,
+ type ReportHistoryRef,
+ type ReportHistoryView,
+ type ReportRevisionView,
+} from "../lib/report/revisions";
+import { cleanGitEnv } from "./sourceAudit";
+import { siteReportDir } from "./reportMedia";
+
+export const REPORT_HISTORY_GIT_DIRNAME = "history-git";
+export const REVISION_EXPORTS_FILENAME = "exports.json";
+export const REVISION_EXPORTS_FORMAT = "archilyzer-report-revision-exports";
+export const REVISION_EXPORTS_VERSION = 1;
+
+// The published clone's packs are split below this (the publish limit is 24 MiB).
+const PACK_SIZE = "20m";
+const HEAD_REF = `refs/heads/${REPORT_HISTORY_BRANCH}`;
+const ZERO_OID = "0".repeat(40);
+
+// `sites/<siteId>/reports/<reportId>/history-git/`.
+export function reportHistoryGitDir(paths: Paths, siteId: string, reportId: string): string {
+ return path.join(siteReportDir(paths, siteId, reportId), REPORT_HISTORY_GIT_DIRNAME);
+}
+
+// ─── git, with nothing of the operator's ───
+
+export type HistoryIdentity = { name: string; email: string };
+
+// The site, as the author and committer of its reports' revisions.
+export function historyIdentity(site: Pick<Site, "siteId" | "siteTitle">): HistoryIdentity {
+ return { name: site.siteTitle?.trim() || site.siteId, email: `noreply@${site.siteId}.invalid` };
+}
+
+// `@<seconds> +0000`: git's own date form, in UTC.
+export function gitDateUtc(d: Date): string {
+ return `@${Math.floor(d.getTime() / 1000)} +0000`;
+}
+
+// The whole environment git runs with: PATH, and nothing else of this
+// process's — no HOME (so no ~/.gitconfig), no system or global config, no
+// GIT_* variable, no EMAIL — then the identity and date when committing.
+export function historyGitEnv(identity?: HistoryIdentity, date?: Date): NodeJS.ProcessEnv {
+ const env: NodeJS.ProcessEnv = cleanGitEnv({
+ PATH: process.env.PATH,
+ HOME: path.join(os.tmpdir(), "archilyzer-report-history-nohome"),
+ XDG_CONFIG_HOME: path.join(os.tmpdir(), "archilyzer-report-history-nohome"),
+ GIT_CONFIG_NOSYSTEM: "1",
+ GIT_CONFIG_GLOBAL: os.devNull,
+ GIT_TERMINAL_PROMPT: "0",
+ TZ: "UTC",
+ LC_ALL: "C",
+ LANG: "C",
+ });
+ if (identity) {
+ env.GIT_AUTHOR_NAME = identity.name;
+ env.GIT_AUTHOR_EMAIL = identity.email;
+ env.GIT_COMMITTER_NAME = identity.name;
+ env.GIT_COMMITTER_EMAIL = identity.email;
+ }
+ if (date) {
+ env.GIT_AUTHOR_DATE = gitDateUtc(date);
+ env.GIT_COMMITTER_DATE = gitDateUtc(date);
+ }
+ return env;
+}
+
+// Config every invocation carries on its command line, over whatever a
+// repository's own config could say.
+const GIT_FLAGS = ["-c", "commit.gpgSign=false", "-c", "core.hooksPath=/dev/null", "-c", "core.logAllRefUpdates=false"];
+
+type GitOpts = { input?: string | Uint8Array; env?: NodeJS.ProcessEnv; cwd?: string };
+
+async function gitRaw(args: string[], opts: GitOpts = {}): Promise<Buffer> {
+ const r = await execa("git", [...GIT_FLAGS, ...args], {
+ env: opts.env ?? historyGitEnv(),
+ extendEnv: false,
+ input: opts.input,
+ cwd: opts.cwd,
+ encoding: "buffer",
+ // A blob's bytes are its bytes: its final newline included.
+ stripFinalNewline: false,
+ reject: false,
+ timeout: 120_000,
+ });
+ if (r.exitCode !== 0) {
+ const err = Buffer.from(r.stderr as Uint8Array).toString("utf8").trim().split("\n")[0];
+ throw new Error(`git ${args.find((a) => !a.startsWith("-") && !a.includes("/")) ?? args[0]} failed (exit ${r.exitCode}): ${err}`);
+ }
+ return Buffer.from(r.stdout as Uint8Array);
+}
+
+async function git(gitDir: string, args: string[], opts: GitOpts = {}): Promise<string> {
+ return (await gitRaw(["--git-dir", gitDir, ...args], opts)).toString("utf8").trim();
+}
+
+const sha256 = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex");
+
+async function isRepo(gitDir: string): Promise<boolean> {
+ return (await stat(path.join(gitDir, "HEAD")).catch(() => null))?.isFile() ?? false;
+}
+
+async function headCommit(gitDir: string): Promise<string | null> {
+ if (!(await isRepo(gitDir))) return null;
+ try {
+ return await git(gitDir, ["rev-parse", "--verify", "--quiet", `${HEAD_REF}^{commit}`]);
+ } catch {
+ return null;
+ }
+}
+
+// ─── Reading ───
+
+export type StoredRevision = {
+ revision: number;
+ commit: string;
+ // ISO 8601, UTC, to the second.
+ date: string;
+ reportSha256: string;
+ // The report.json's bytes.
+ reportJson: Buffer;
+ message: string;
+};
+
+const isoSeconds = (secs: number) => new Date(secs * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
+
+// Every revision in the store, oldest first; [] when there is no store.
+export async function readStoredRevisions(gitDir: string): Promise<StoredRevision[]> {
+ if (!(await headCommit(gitDir))) return [];
+ const log = await gitRaw(["--git-dir", gitDir, "log", "--reverse", "--format=%H%x1f%ct%x1f%B%x1e", HEAD_REF]);
+ const entries = log
+ .toString("utf8")
+ .split("\x1e")
+ .map((e) => e.replace(/^\n/, ""))
+ .filter((e) => e.trim());
+ const out: StoredRevision[] = [];
+ for (const [i, e] of entries.entries()) {
+ const [commit, ct, message] = e.split("\x1f");
+ const reportJson = await gitRaw(["--git-dir", gitDir, "cat-file", "blob", `${commit}:report.json`]);
+ out.push({
+ revision: i + 1,
+ commit,
+ date: isoSeconds(Number(ct)),
+ reportSha256: sha256(reportJson),
+ reportJson,
+ message: message.trimEnd(),
+ });
+ }
+ return out;
+}
+
+export type RevisionHead = { revision: number; commit: string; date: string; reportSha256: string; summary: string[] };
+
+// The newest revision, or null when there is none.
+export async function readRevisionHead(gitDir: string): Promise<RevisionHead | null> {
+ const head = await headCommit(gitDir);
+ if (!head) return null;
+ const count = Number(await git(gitDir, ["rev-list", "--count", HEAD_REF]));
+ const [ct, ...msg] = (await git(gitDir, ["log", "-1", "--format=%ct%n%B", HEAD_REF])).split("\n");
+ const reportJson = await gitRaw(["--git-dir", gitDir, "cat-file", "blob", `${head}:report.json`]);
+ return {
+ revision: count,
+ commit: head,
+ date: isoSeconds(Number(ct)),
+ reportSha256: sha256(reportJson),
+ summary: revisionSummaryOf(msg.join("\n")),
+ };
+}
+
+// The revision a report.json of `reportSha256` is: the newest when it is that
+// report.json (`changed` false), else the next one to be committed.
+export async function pendingRevision(
+ gitDir: string,
+ reportSha256: string,
+): Promise<{ revision: number; changed: boolean; head: RevisionHead | null }> {
+ const head = await readRevisionHead(gitDir);
+ if (!head) return { revision: 1, changed: true, head };
+ if (head.reportSha256 === reportSha256) return { revision: head.revision, changed: false, head };
+ return { revision: head.revision + 1, changed: true, head };
+}
+
+// ─── Committing ───
+
+export type RevisionExports = {
+ format: typeof REVISION_EXPORTS_FORMAT;
+ version: typeof REVISION_EXPORTS_VERSION;
+ reportId: string;
+ reportSha256: string;
+ // By file name: report.html, report.pdf, report.md, evidence-pack.zip (each
+ // that was made), citations.json, citations.csv.
+ files: Record<string, { bytes: number; sha256: string }>;
+};
+
+export function revisionExports(
+ reportId: string,
+ reportSha256: string,
+ files: Record<string, Uint8Array | string>,
+): RevisionExports {
+ const out: RevisionExports["files"] = {};
+ for (const name of Object.keys(files).sort()) {
+ const data = typeof files[name] === "string" ? Buffer.from(files[name] as string) : (files[name] as Uint8Array);
+ out[name] = { bytes: data.length, sha256: sha256(data) };
+ }
+ return { format: REVISION_EXPORTS_FORMAT, version: REVISION_EXPORTS_VERSION, reportId, reportSha256, files: out };
+}
+
+export type CommittedRevision = RevisionHead & {
+ // Whether this call made the commit (false: report.json had not changed).
+ committed: boolean;
+};
+
+function parseReport(data: Buffer): Report | null {
+ try {
+ const v = JSON.parse(data.toString("utf8")) as Report;
+ return v && typeof v === "object" && Array.isArray(v.sections) ? v : null;
+ } catch {
+ return null;
+ }
+}
+
+async function ensureRepo(gitDir: string): Promise<void> {
+ if (await isRepo(gitDir)) return;
+ await mkdir(path.dirname(gitDir), { recursive: true });
+ // No template: no hooks, no description, no sample files.
+ await gitRaw(["init", "--bare", "--quiet", `--initial-branch=${REPORT_HISTORY_BRANCH}`, "--template=", gitDir]);
+}
+
+// Commit `reportJson` (with its Markdown export and exports.json) as the next
+// revision — when it is not the newest revision's report.json already, in
+// which case nothing is written and the newest is answered.
+export async function commitReportRevision(o: {
+ gitDir: string;
+ site: Pick<Site, "siteId" | "siteTitle">;
+ reportJson: Uint8Array;
+ markdown: string;
+ exports: RevisionExports;
+ now: Date;
+}): Promise<CommittedRevision> {
+ const reportJson = Buffer.from(o.reportJson);
+ const sha = sha256(reportJson);
+ const pending = await pendingRevision(o.gitDir, sha);
+ if (!pending.changed && pending.head) return { ...pending.head, committed: false };
+ await ensureRepo(o.gitDir);
+ const g = o.gitDir;
+ const prev = pending.head
+ ? parseReport(await gitRaw(["--git-dir", g, "cat-file", "blob", `${pending.head.commit}:report.json`]))
+ : null;
+ const next = parseReport(reportJson);
+ const summary = next
+ ? reportChangeSummary(pending.head ? prev ?? emptyReport(next) : null, next)
+ : ["report.json is not a readable report"];
+ const blob = (data: string | Uint8Array) => git(g, ["hash-object", "-w", "--stdin"], { input: data });
+ const entries = [
+ [REVISION_EXPORTS_FILENAME, await blob(`${JSON.stringify(o.exports, null, 2)}\n`)],
+ ["report.json", await blob(reportJson)],
+ ["report.md", await blob(o.markdown)],
+ ];
+ const tree = await git(g, ["mktree"], { input: entries.map(([name, id]) => `100644 blob ${id}\t${name}\n`).join("") });
+ const env = historyGitEnv(historyIdentity(o.site), o.now);
+ const commit = await git(
+ g,
+ ["commit-tree", "--no-gpg-sign", tree, ...(pending.head ? ["-p", pending.head.commit] : []), "-F", "-"],
+ { input: revisionCommitMessage(pending.revision, summary), env },
+ );
+ await git(g, ["update-ref", "-m", `revision ${pending.revision}`, HEAD_REF, commit, pending.head?.commit ?? ZERO_OID]);
+ return {
+ revision: pending.revision,
+ commit,
+ date: isoSeconds(Math.floor(o.now.getTime() / 1000)),
+ reportSha256: sha,
+ summary,
+ committed: true,
+ };
+}
+
+// A report with nothing in it: what an unreadable earlier revision is
+// compared as.
+function emptyReport(like: Report): Report {
+ return { ...like, title: "", series: undefined, subtitle: undefined, summary: undefined, method: undefined, citations: {}, sections: [] };
+}
+
+// ─── The history page's data ───
+
+// Every revision with its summary and claim diff, or null when the report has
+// no revisions. `clone` is what `git clone` takes.
+export async function readReportHistoryView(
+ gitDir: string,
+ o: { reportId: string; siteUrl?: string },
+): Promise<ReportHistoryView | null> {
+ const stored = await readStoredRevisions(gitDir);
+ if (stored.length === 0) return null;
+ const revisions: ReportRevisionView[] = [];
+ let prev: Report | null = null;
+ let newest: Report | null = null;
+ for (const s of stored) {
+ const report = parseReport(s.reportJson);
+ revisions.push({
+ revision: s.revision,
+ date: s.date,
+ commit: s.commit,
+ reportSha256: s.reportSha256,
+ summary: revisionSummaryOf(s.message),
+ claims: prev && report ? claimChanges(prev, report) : [],
+ });
+ if (report) newest = report;
+ prev = report;
+ }
+ const base = o.siteUrl?.trim().replace(/\/+$/, "");
+ const repo = reportHistoryRepoPath(o.reportId);
+ return {
+ format: REPORT_HISTORY_FORMAT,
+ version: REPORT_HISTORY_VERSION,
+ reportId: o.reportId,
+ ...(newest?.series ? { series: newest.series } : {}),
+ title: newest?.title ?? o.reportId,
+ branch: REPORT_HISTORY_BRANCH,
+ clone: base ? `${base}${repo}` : repo,
+ revisions,
+ };
+}
+
+// What the report's page shows: the newest revision, current when the
+// report.json now is that revision's.
+export function reportHistoryRef(view: ReportHistoryView, currentSha256: string | null): ReportHistoryRef {
+ const last = view.revisions[view.revisions.length - 1];
+ return {
+ revision: last.revision,
+ date: last.date,
+ href: reportHistoryPagePath(view.reportId),
+ current: currentSha256 === last.reportSha256,
+ };
+}
+
+// ─── The published clone ───
+
+// What the published clone holds, by path relative to it: nothing else is
+// copied out of the clone, and the audit (lib/builtExport.ts) refuses
+// anything else under a `reports/<id>/history/repo/`.
+export const REPORT_HISTORY_REPO_FILE_RE =
+ /^(HEAD|packed-refs|info\/refs|objects\/info\/packs|refs\/heads\/main|objects\/pack\/pack-[0-9a-f]+\.(pack|idx))$/;
+
+// Stage a dumb-HTTP clone of the store at `destDir` (replaced whole): a fresh
+// bare clone of the one branch, repacked, its server info written, copied
+// from an allowlist. Throws when a file would be over the publish limit.
+// Answers the files written, relative to `destDir`.
+export async function stageReportHistoryRepo(gitDir: string, destDir: string): Promise<string[]> {
+ const tmp = await mkdtemp(path.join(os.tmpdir(), "report-history-stage-"));
+ try {
+ const clone = path.join(tmp, "clone");
+ await gitRaw(
+ ["clone", "--bare", "--quiet", "--no-local", "--single-branch", "--branch", REPORT_HISTORY_BRANCH, "--template=", gitDir, clone],
+ { cwd: tmp },
+ );
+ await git(clone, ["repack", "-a", "-d", "-q", `--max-pack-size=${PACK_SIZE}`]);
+ await git(clone, ["prune-packed"]);
+ await git(clone, ["pack-refs", "--all"]);
+ await git(clone, ["update-server-info"]);
+ const refs = (await git(clone, ["for-each-ref", "--format=%(refname)"])).split("\n").filter(Boolean);
+ if (refs.length !== 1 || refs[0] !== HEAD_REF) {
+ throw new Error(`the history clone must hold ${HEAD_REF} alone, and holds ${refs.join(", ") || "no refs"}`);
+ }
+ const head = await git(clone, ["rev-parse", HEAD_REF]);
+
+ await rm(destDir, { recursive: true, force: true });
+ for (const d of ["info", "objects/info", "objects/pack", "refs/heads"]) {
+ await mkdir(path.join(destDir, d), { recursive: true });
+ }
+ const files: string[] = [];
+ for (const f of ["HEAD", "packed-refs", "info/refs", "objects/info/packs"]) {
+ await cp(path.join(clone, f), path.join(destDir, f));
+ files.push(f);
+ }
+ await writeFile(path.join(destDir, "refs", "heads", REPORT_HISTORY_BRANCH), `${head}\n`);
+ files.push(`refs/heads/${REPORT_HISTORY_BRANCH}`);
+ for (const f of (await readdir(path.join(clone, "objects", "pack"))).sort()) {
+ if (!/^pack-[0-9a-f]+\.(pack|idx)$/.test(f)) continue;
+ await cp(path.join(clone, "objects", "pack", f), path.join(destDir, "objects", "pack", f));
+ files.push(`objects/pack/${f}`);
+ }
+ for (const f of files) {
+ const problem = publishFileSizeProblem(f, (await stat(path.join(destDir, f))).size);
+ if (problem) throw new Error(`the history clone's ${problem}`);
+ }
+ return files;
+ } finally {
+ await rm(tmp, { recursive: true, force: true });
+ }
+}
+
+// Publish a report's history under `publicDir`: history.json and the clone,
+// at `reports/<id>/history/`. Answers the clone's files.
+export async function publishReportHistory(o: {
+ gitDir: string;
+ publicDir: string;
+ view: ReportHistoryView;
+}): Promise<string[]> {
+ const viewFile = path.join(o.publicDir, ...reportHistoryViewPath(o.view.reportId).split("/").filter(Boolean));
+ await mkdir(path.dirname(viewFile), { recursive: true });
+ await writeFile(viewFile, `${JSON.stringify(o.view, null, 2)}\n`);
+ const repoDir = path.join(o.publicDir, ...reportHistoryRepoPath(o.view.reportId).split("/").filter(Boolean));
+ return stageReportHistoryRepo(o.gitDir, repoDir);
+}
+
+// The sha256 of a file, or null when it cannot be read.
+export async function fileSha256(file: string): Promise<string | null> {
+ try {
+ return sha256(await readFile(file));
+ } catch {
+ return null;
+ }
+}