// The source's history pages: stagit's rendering of the scrubbed mirror, // published at /source/git/ by `archilyzer source publish` (source.ts), which // runs this after the mirror is built and its objects audited, and before the // staged files are audited — so every page goes through the same gate as the // mirror, the tree and the tarball. // // stagit (codemadness.org, C over libgit2) is an OPERATOR-INSTALLED tool, like // git-filter-repo: never vendored, never committed. It is found as STAGIT_BIN, // else `stagit` on PATH, else ~/.local/bin/stagit (resolveStagit). WITHOUT IT // THE PUBLISH GOES ON: one log line says so and how to install it, and the // source is published without /source/git/ (the /source/ page then shows no // History links). A render that fails, or pages over the host's limits, are // the same: a WARNING, and no history — never a failed build. // // WHAT IS PUBLISHED, from an allowlist of what stagit writes: // log.html, files.html, refs.html, atom.xml, tags.xml, commit/.html // and style.css, written here from common/styles/tokens.css. NOT stagit's // per-file pages (file/…): browsing is the raw tree at /source/tree/, so // every link into file/ is rewritten to it and file/ is never staged. // // THE POST-PASS (rewriteHistoryPage) touches every .html page and never the // two feeds. It adds exactly two things — the homepage's pre-paint theme // script (lib/themeConfig.ts buildThemeScript, the string the homepage's // ThemeScript emits), and one line at the top linking back to /source/ — and // points two kinds of stagit link somewhere that exists: `…file/.html` // at the raw tree, and its logo.png / favicon.png at the site's own icon. // stagit's header (the name, the description, the clone line, Log | Files | // Refs) stays as stagit writes it. // // THE CAP. At most SOURCE_HISTORY_MAX_COMMITS commits, the newest, get a page: // past it stagit runs with `-l`, the log lists that many and says how many // more there are, and only the listed commits' pages are published (stagit // still writes one for every commit). The /source/ page then says "the latest // N of M". The 15,000-file drop in source.ts stays, as the last resort. // // THE CACHE. stagit's `-c ` renders incrementally: it walks from // HEAD to the commit the cache names and keeps every commit page already in // its output directory (`-l` keeps them too; it cannot be combined with // `-c`). So the cache is a DIRECTORY kept between publishes — // `${XDG_CACHE_HOME:-~/.cache}/archilyzer/source-history/` // (paths.sourceHistoryCacheDir; never inside the checkout or the public dir) // holding the cache file, stagit's output and a key. Its pages are kept only // when the key matches (the scrub rules and step, filter-repo, stagit, the // header text) and the last run finished (the key is removed before a render // and written after it); its log lines only when the commit they end at is an // ancestor of today's head. One publish holds it at a time (`lock`, the // holder's pid); a second renders without it, and a lock whose pid is not // running or that is over an hour old is stale and replaced. A cache that // cannot be written (EACCES, EROFS, ENOSPC) is one line and a render without // it. `--force` renders it afresh, `--check` never touches it, and a refusal // removes it. // // LINKS TO WHAT IS NOT PUBLISHED become text: a diff's file that main no // longer has, a commit with no page (past the cap). The raw tree's file list // decides (`treeFiles`). import { createHash } from "node:crypto"; import { accessSync, constants, existsSync, statSync } from "node:fs"; import { cp, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { PROJECT_NAME } from "../lib/project"; import { buildThemeScript, HOMEPAGE_DEFAULT_BASE } from "../lib/themeConfig"; import { onPath, tildify } from "./sourceAudit"; /** How to install stagit, as the log line and the doctor say it. */ export const STAGIT_INSTALL = "git clone git://git.codemadness.org/stagit && make -C stagit && cp stagit/stagit ~/.local/bin/"; /** * The cap: at most this many commits — the newest — get a page and a log * line (stagit `-l`); the log says how many more there are. One file per * commit counts against the step's 15,000 (Pages' 20,000). */ export const SOURCE_HISTORY_MAX_COMMITS = 10_000; /** The site's own 32 px icon, which stagit's logo.png and favicon.png become. */ export const SITE_ICON_HREF = "/icons/icon-32.png"; /** * The one line put at the top of every page. ASCII only (`·`): the * pages are rewritten byte for byte (latin1 in, latin1 out), so anything * injected must be the same bytes in every encoding. */ export const HISTORY_BACK_LINK = `

${PROJECT_NAME} · Source

`; /** The pre-paint script the homepage emits (homepage/app/layout.tsx). */ export function homepageThemeScript(): string { return buildThemeScript({ defaultBase: HOMEPAGE_DEFAULT_BASE }); } // What stagit writes that is published: the top-level pages and feeds, and // one page per commit. const TOP_FILES = ["log.html", "files.html", "refs.html", "atom.xml", "tags.xml"] as const; const COMMIT_PAGE = /^[0-9a-f]{40}\.html$/; /** Something about the history went wrong: a WARNING, and no history. */ export class HistoryProblem extends Error { constructor(message: string) { super(message); this.name = "HistoryProblem"; } } // ── the binary ────────────────────────────────────────────────────────────── function isExecutableFile(p: string): boolean { try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch { return false; } } /** * The stagit binary to run, or null. `bin` is paths.stagitBin: STAGIT_BIN, * else "stagit". A name with a slash is that file, or nothing. A bare name is * looked up on PATH, then in ~/.local/bin (the editor's process may not have * it on its PATH, where a hand-built tool is usually put). */ export function resolveStagit(bin: string, env: NodeJS.ProcessEnv, homeDir: string = os.homedir()): string | null { if (bin.includes("/")) return isExecutableFile(bin) ? bin : null; const found = onPath(bin, env.PATH); if (found) return found; const local = path.join(homeDir, ".local", "bin", bin); return isExecutableFile(local) ? local : null; } /** * Which stagit renders: "absent", else the first 12 hex of its binary's * sha256 (stagit has no version flag). Part of the publish's skip key and of * the cache's, and the manifest's `history.tool`. Never the path: the * manifest is published, and a path under the home dir is a denied literal. */ export async function stagitIdentity(found: string | null): Promise { if (!found) return "absent"; const bytes = await readFile(await realpath(found)); return `stagit (sha256 ${createHash("sha256").update(bytes).digest("hex").slice(0, 12)})`; } // ── the stylesheet ────────────────────────────────────────────────────────── // The tokens the stylesheet reads, on each base. `--brand` is Signal on both // (the homepage's accent: the base blocks default to it), and every token a // value names through var() comes along. export const HISTORY_TOKENS = [ "--background", "--surface", "--foreground", "--muted-foreground", "--faint", "--border-strong", "--brand", "--brand-soft", "--info", "--success", "--destructive", ] as const; type Decls = Map; function declarations(body: string): Decls { const out: Decls = new Map(); for (const part of body.split(";")) { const i = part.indexOf(":"); if (i === -1) continue; const name = part.slice(0, i).trim(); if (name.startsWith("--") || name === "color-scheme") out.set(name, part.slice(i + 1).trim()); } return out; } /** * The light and the dark base blocks of tokens.css, as declarations: the rule * whose selector list holds `html[data-base="light"]`, and the one holding * `html[data-base="dark"]`. A file without both is an error. */ export function tokenBlocks(tokensCss: string): { light: Decls; dark: Decls } { const text = tokensCss.replace(/\/\*[\s\S]*?\*\//g, ""); let light: Decls | null = null; let dark: Decls | null = null; for (const m of text.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { const selectors = m[1].split(",").map((s) => s.trim()); if (selectors.includes('html[data-base="light"]')) light = declarations(m[2]); else if (selectors.includes('html[data-base="dark"]')) dark = declarations(m[2]); } if (!light || !dark) throw new HistoryProblem("tokens.css has no light or no dark base block"); return { light, dark }; } // `names` and every custom property their values reach through var(), in // the block's own order, as ` name: value;` lines. function closure(block: Decls, names: readonly string[], base: string): string { const want = new Set(["color-scheme"]); const visit = (name: string) => { if (want.has(name)) return; const value = block.get(name); if (value === undefined) throw new HistoryProblem(`tokens.css's ${base} block has no ${name}`); want.add(name); for (const ref of value.matchAll(/var\(\s*(--[\w-]+)/g)) visit(ref[1]); }; for (const n of names) visit(n); return [...block].filter(([k]) => want.has(k)).map(([k, v]) => ` ${k}: ${v};`).join("\n"); } /** * style.css for the history pages, from common/styles/tokens.css: the * homepage's two grounds. Without the theme script (no JS) the pages follow * `prefers-color-scheme`; with it, `html[data-base]` is the visitor's stored * choice, or the homepage's default. The rules are stagit's own stylesheet, * recoloured: links in the accent, diff insertions in --success and deletions * in --destructive (each line also keeps its + or − sign). */ export function historyStylesheet(tokensCss: string): string { const { light, dark } = tokenBlocks(tokensCss); const lightVars = closure(light, HISTORY_TOKENS, "light"); const darkVars = closure(dark, HISTORY_TOKENS, "dark"); const indent = (s: string) => s.split("\n").map((l) => ` ${l}`).join("\n"); return `/* The source's history pages (stagit), styled by \`archilyzer source publish\` from common/styles/tokens.css — the homepage's two grounds. Generated. */ :root, html[data-base="light"] { ${lightVars} } @media (prefers-color-scheme: dark) { :root:not([data-base]) { ${indent(darkVars)} } } html[data-base="dark"] { ${darkVars} } html { background: var(--background); } body { margin: 0; padding: 1rem; background: var(--background); color: var(--foreground); font-family: ui-monospace, "IBM Plex Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875rem; line-height: 1.5; } a { color: var(--brand); } a:hover { color: var(--foreground); } a:not([href]) { color: inherit; text-decoration: none; } p.archilyzer-source { margin: 0 0 1rem; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 0.8125rem; } p.archilyzer-source a { color: var(--muted-foreground); text-decoration: none; } p.archilyzer-source a:hover { color: var(--foreground); text-decoration: underline; } h1, h2, h3, h4, h5, h6 { font-size: 1em; margin: 0; } tr.url a { overflow-wrap: anywhere; } img, h1, h2 { vertical-align: middle; } img { border: 0; } a:target { background-color: var(--brand-soft); } a.d, a.h, a.i, a.line { text-decoration: none; } #blob a { color: var(--faint); } #blob a:hover { color: var(--brand); text-decoration: none; } table thead td { font-weight: bold; } table td { padding: 0 0.4em; } #content { overflow-x: auto; } #content table td { vertical-align: top; white-space: nowrap; } #branches tr:hover td, #tags tr:hover td, #index tr:hover td, #log tr:hover td, #files tr:hover td { background-color: var(--surface); } #index tr td:nth-child(2), #tags tr td:nth-child(3), #branches tr td:nth-child(3), #log tr td:nth-child(2) { white-space: normal; } td.num { text-align: right; } .desc { color: var(--muted-foreground); } hr { border: 0; border-top: 1px solid var(--border-strong); height: 1px; } pre { font-family: inherit; } pre a.h { color: var(--info); } .A, span.i, pre a.i { color: var(--success); } .D, span.d, pre a.d { color: var(--destructive); } pre a.h:hover, pre a.i:hover, pre a.d:hover { text-decoration: none; } `; } // ── the post-pass ─────────────────────────────────────────────────────────── /** * What is published beside the pages, for the post-pass to link to only what * is there: a path of the raw tree (decoded, as tracked), a commit with a page. */ export type PublishedSet = { tree: (path: string) => boolean; commit: (sha: string) => boolean; }; // stagit's percent-encoding undone; null when it is not valid. function decodedPath(p: string): string | null { try { return decodeURIComponent(p); } catch { return null; } } /** * One stagit page, as published. Adds the theme script before `` and * HISTORY_BACK_LINK after ``; points every `href="…file/.html"` * (the Files index, the header's README and LICENSE, a diff's file names) at * the raw tree — `../tree/` from the same depth, the path as stagit * encoded it — and stagit's logo.png and favicon.png at the site's icon. * * With `published`, a link to what is not published loses its `href` and * stays as text (an `` with no `href`, its `id` kept — a diff header is the * diffstat's `#h` target): a file no longer in main (a diff of a deleted or * renamed file), and a commit with no page (past the cap, the oldest page's * parent). Nothing else changes. Page text cannot fake an `href="…"`: stagit * encodes every `"` it prints from the repository as `"`. */ export function rewriteHistoryPage(html: string, themeScript: string, published?: PublishedSet): string { if (/<\/script/i.test(themeScript)) throw new Error("the theme script may not close its own element"); let out = html; const head = out.indexOf(""); if (head !== -1) out = `${out.slice(0, head)}\n${out.slice(head)}`; const body = /]*>\n?/.exec(out); if (body) { const at = body.index + body[0].length; out = `${out.slice(0, at)}${HISTORY_BACK_LINK}\n${out.slice(at)}`; } out = out .replace(/ href="((?:\.\.\/)*)file\/([^"]*)\.html"/g, (_m, up: string, p: string) => { if (published) { const tracked = decodedPath(p); if (tracked === null || !published.tree(tracked)) return ""; } return ` href="${up}../tree/${p}"`; }) .replace(/src="(?:\.\.\/)*logo\.png"/g, `src="${SITE_ICON_HREF}"`) .replace(/href="(?:\.\.\/)*favicon\.png"/g, `href="${SITE_ICON_HREF}"`); if (published) { out = out.replace(/ href="((?:\.\.\/)*)commit\/([0-9a-f]{40})\.html"/g, (m, _up: string, sha: string) => published.commit(sha) ? m : "", ); } return out; } /** * The commits the log lists, newest first: every `href="commit/.html"` * in log.html (stagit writes one per log line; a commit's own text cannot * fake one, since stagit encodes every `"` it prints as `"`). These, and * only these, have their pages published. */ export function loggedCommits(logHtml: string): string[] { const seen = new Set(); for (const m of logHtml.matchAll(//g)) seen.add(m[1]); return [...seen]; } /** * Copy the allowlist of stagit's output from `work` into `dest` — the * top-level pages and feeds, and the page of each commit in `commits` (the * ones the log lists; any other page in `work` stays there) — each page * through the post-pass (byte for byte otherwise: read and written as latin1, * so a diff of a file that is not UTF-8 keeps its bytes), then style.css. * The feeds are copied as they are. A listed page that is not there is a * HistoryProblem. */ export async function stageHistory( work: string, dest: string, o: { themeScript: string; stylesheet: string; commits: readonly string[]; // The raw tree's files (as tracked); absent, no tree link is dropped. treeFiles?: ReadonlySet; }, ): Promise<{ files: number; bytes: number; largest: { rel: string; bytes: number } }> { if (/[^\x00-\x7f]/.test(o.themeScript)) throw new Error("the theme script must be ASCII"); const rels: string[] = []; for (const f of TOP_FILES) { if (!existsSync(path.join(work, f))) throw new HistoryProblem(`stagit wrote no ${f}`); rels.push(f); } for (const sha of o.commits) { const rel = `commit/${sha}.html`; if (!COMMIT_PAGE.test(`${sha}.html`)) throw new HistoryProblem(`the log names ${sha.slice(0, 40)}, not a commit id`); if (!existsSync(path.join(work, rel))) throw new HistoryProblem(`the log lists ${sha.slice(0, 12)}, whose page is not there`); rels.push(rel); } const pages = new Set(o.commits); const treeFiles = o.treeFiles; const published: PublishedSet | undefined = treeFiles ? { tree: (p) => treeFiles.has(p), commit: (sha) => pages.has(sha) } : undefined; await mkdir(path.join(dest, "commit"), { recursive: true }); let bytes = 0; let largest = { rel: "", bytes: -1 }; const note = (rel: string, n: number) => { bytes += n; if (n > largest.bytes) largest = { rel, bytes: n }; }; for (const rel of rels) { const src = path.join(work, rel); const dst = path.join(dest, rel); if (rel.endsWith(".html")) { const text = rewriteHistoryPage((await readFile(src)).toString("latin1"), o.themeScript, published); const buf = Buffer.from(text, "latin1"); await writeFile(dst, buf); note(rel, buf.length); } else { await cp(src, dst); note(rel, (await stat(dst)).size); } } await writeFile(path.join(dest, "style.css"), o.stylesheet); note("style.css", Buffer.byteLength(o.stylesheet)); return { files: rels.length + 1, bytes, largest }; } // ── the render, with its cache ────────────────────────────────────────────── export type HistoryRun = ( command: string, args: string[], o: { cwd: string; timeoutMs: number }, ) => Promise<{ code: number; out: string[] }>; export type RenderHistoryOpts = { stagit: string; // The scrubbed bare clone — a directory named archilyzer.git (stagit names // the repository after it) — at `head`, with `commits` commits in all. gitDir: string; head: string; commits: number; // The cap: at most this many commits (the newest) get a page and a log // line. SOURCE_HISTORY_MAX_COMMITS, but for the tests. maxCommits: number; // Where the published copy goes (stage/source/git). dest: string; // The publish's own scratch dir: the render's directory when there is no // cache to use. scratch: string; // The kept cache directory, or null (a `--check`, or a cache dir the step // may not use). cacheDir: string | null; // Changes whenever pages rendered before must not be kept. cacheKey: string; // `--force`: render every page again. fresh: boolean; // stagit's header: the description line and the clone URL. description: string; cloneUrl: string; // The Atom feeds' absolute base (`/source/git/`). baseUrl: string; stylesheet: string; themeScript: string; // The raw tree's files, as tracked: a page's link to a file not among them // (deleted or renamed since) becomes text. Absent: every link is kept. treeFiles?: ReadonlySet; // A child, run with the publish's environment and cancel signal. run: HistoryRun; onLog: (line: string) => void; }; export type RenderedHistory = { files: number; bytes: number; largest: { rel: string; bytes: number }; // The commits with a page — min(commits, maxCommits), the newest. shown: number; // Pages stagit wrote this run (all of them without a usable cache). rendered: number; cached: boolean; }; const pidAlive = (pid: number): boolean => { try { process.kill(pid, 0); return true; } catch (err) { return (err as NodeJS.ErrnoException).code === "EPERM"; } }; /** * A lock this old is stale whoever holds its pid now: a publish holds the * cache for one render (stagit's timeout is 10 minutes), and a pid is reused. */ export const HISTORY_LOCK_STALE_MS = 60 * 60 * 1000; /** * Hold the cache directory, or say it is busy. The directory is made on the * way (recursively). The lock names its holder's pid. A lock is stale when * that pid is not running OR the lock is older than HISTORY_LOCK_STALE_MS: a * render was cut off, so nothing in the directory is trusted — it is emptied * and taken, with one line. An I/O error (an unwritable or full cache dir) * is thrown: renderHistory then renders without the cache. */ export async function holdHistoryCache( dir: string, onLog: (line: string) => void = () => {}, now: () => number = Date.now, ): Promise { await mkdir(dir, { recursive: true, mode: 0o700 }); const lock = path.join(dir, "lock"); for (let attempt = 0; attempt < 2; attempt++) { try { await writeFile(lock, `${process.pid}\n`, { flag: "wx" }); return true; } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; const pid = Number((await readFile(lock, "utf8").catch(() => "")).trim()); const mtime = (await stat(lock).catch(() => null))?.mtimeMs ?? now(); const age = now() - mtime; const running = Number.isInteger(pid) && pid > 0 && pidAlive(pid); if (running && age < HISTORY_LOCK_STALE_MS) return false; onLog( `[source] history: a stale lock on the render cache (pid ${pid > 0 ? pid : "unknown"}, ` + `${running ? `${Math.round(age / 60_000)} minutes old` : "not running"}) was replaced; the cache is rendered afresh`, ); await emptyDir(dir); } } return false; } async function emptyDir(dir: string): Promise { for (const f of await readdir(dir).catch(() => [] as string[])) { await rm(path.join(dir, f), { recursive: true, force: true }); } } async function releaseHistoryCache(dir: string): Promise { await rm(path.join(dir, "lock"), { force: true }); } /** * Remove the cache (a refusal: it was rendered under rules that may not be * today's). Left alone while another publish holds it. */ export async function dropHistoryCache(dir: string): Promise { if (!existsSync(dir)) return; if (await holdHistoryCache(dir)) await rm(dir, { recursive: true, force: true }); } async function countCommitPages(work: string): Promise { const names = await readdir(path.join(work, "commit")).catch(() => [] as string[]); return names.filter((f) => COMMIT_PAGE.test(f)).length; } /** * Render the history into `o.dest`. Throws HistoryProblem when stagit fails * or its output is not what it should be; the caller publishes without the * history then. * * THE CAP. Up to `maxCommits` commits, stagit runs with `-c` (its log lines * cached: a publish renders only the new commits). Past it, with `-l * `: the log lists the newest `maxCommits` and says how many more * there are ("N more commits remaining, fetch the repository"). stagit refuses * `-c` with `-l`, and `-l` still writes a page for EVERY commit — so what is * published is the page of each commit the log lists, and no other. The * pages already in the cache's directory are kept by stagit either way. */ export async function renderHistory(o: RenderHistoryOpts): Promise { // stagit's header reads both from the repository directory. Neither is // published as a file (the mirror is staged from an allowlist). No newline // after the description: stagit keeps it, in the and the header. await writeFile(path.join(o.gitDir, "description"), o.description); await writeFile(path.join(o.gitDir, "url"), `${o.cloneUrl}\n`); const withoutCache = async (): Promise<RenderedHistory> => ({ ...(await renderOnce(o, path.join(o.scratch, "history"), null)), cached: false, }); const dir = o.cacheDir; if (!dir) return withoutCache(); // A cache that cannot be used (unwritable, read-only, full) is one line and // a render without it: never a failed build. const unusable = (err: unknown) => o.onLog(`[source] history: the render cache ${tildify(dir)} is unusable (${ioCode(err)}); rendering without it`); let held: boolean; try { held = await holdHistoryCache(dir, o.onLog); } catch (err) { if (!isIoError(err)) throw err; unusable(err); return withoutCache(); } if (!held) { o.onLog("[source] history: the render cache is in use by another publish; rendering without it"); return withoutCache(); } try { const keyFile = path.join(dir, "key.json"); const cacheFile = path.join(dir, "stagit.cache"); const work = path.join(dir, "out"); let usable: boolean; try { usable = !o.fresh && (await cacheUsable(o, keyFile, work)); if (!usable) await emptyCache(dir); else await dropStaleLogCache(o, dir, cacheFile); // The key goes before the render and comes back after it: a render cut // off leaves none, and the next publish starts over. await rm(keyFile, { force: true }); } catch (err) { if (!isIoError(err)) throw err; unusable(err); return await withoutCache(); } try { const r = await renderOnce(o, work, cacheFile); await writeKey(o, keyFile); return { ...r, cached: usable }; } catch (err) { if (!(err instanceof HistoryProblem) || !usable) throw err; // Not a fault: stagit's -c walk is in commit-date order and stops at the // head it rendered last, so the commits of a merge that are older than // that head are left out, and the log comes up short. Once more, every // page. o.onLog( `[source] history: the cached render does not cover this head (${err.message}) — stagit's -c stops at the last head it rendered, and a merge of older commits falls behind it; rendering every page again`, ); try { await emptyCache(dir); } catch (ioErr) { if (!isIoError(ioErr)) throw ioErr; unusable(ioErr); return await withoutCache(); } const r = await renderOnce(o, work, cacheFile); await writeKey(o, keyFile); return { ...r, cached: false }; } } catch (err) { await emptyCache(dir).catch(() => {}); throw err; } finally { await releaseHistoryCache(dir).catch(() => {}); } } // An I/O error from the file system (it carries an errno code), as opposed to // the history's own problems, a cancel, or a bug. function isIoError(err: unknown): err is NodeJS.ErrnoException { return err instanceof Error && !(err instanceof HistoryProblem) && typeof (err as NodeJS.ErrnoException).code === "string"; } function ioCode(err: unknown): string { return (err as NodeJS.ErrnoException)?.code ?? "an I/O error"; } // The key, after a render that finished. A key that cannot be written is no // key: the next publish renders every page again. async function writeKey(o: RenderHistoryOpts, keyFile: string): Promise<void> { try { await writeFile(keyFile, JSON.stringify({ key: o.cacheKey }) + "\n"); } catch (err) { if (!isIoError(err)) throw err; o.onLog(`[source] history: the render cache's key could not be written (${ioCode(err)}); the next publish renders every page again`); } } // Everything in the cache but its lock. async function emptyCache(dir: string): Promise<void> { for (const f of await readdir(dir).catch(() => [] as string[])) { if (f !== "lock") await rm(path.join(dir, f), { recursive: true, force: true }); } } // The cache's pages can be kept: the same key, and a last run that finished. // (A page is its commit's, by id; pages of commits no longer in history are // never published, since only the log's commits are.) async function cacheUsable(o: RenderHistoryOpts, keyFile: string, work: string): Promise<boolean> { let key: unknown = null; try { key = (JSON.parse(await readFile(keyFile, "utf8")) as { key?: unknown }).key; } catch { return false; } return key === o.cacheKey && existsSync(path.join(work, "log.html")); } // stagit's `-c` file holds the log lines down to the commit it names, and // stagit appends them to the new ones: when that commit is not an ancestor // of today's head (a main rewritten since), the lines are of another history, // and the file goes (the pages stay). async function dropStaleLogCache(o: RenderHistoryOpts, dir: string, cacheFile: string): Promise<void> { if (!existsSync(cacheFile)) return; const last = (await readFile(cacheFile, "utf8").catch(() => "")).split("\n")[0].trim(); const ancestor = /^[0-9a-f]{40}$/.test(last) && (await o.run("git", ["--git-dir", o.gitDir, "merge-base", "--is-ancestor", last, o.head], { cwd: dir, timeoutMs: 30_000, })).code === 0; if (!ancestor) await rm(cacheFile, { force: true }); } async function renderOnce( o: RenderHistoryOpts, work: string, cacheFile: string | null, ): Promise<Omit<RenderedHistory, "cached">> { try { await mkdir(work, { recursive: true }); } catch (err) { if (!isIoError(err)) throw err; throw new HistoryProblem(`the render directory cannot be made (${ioCode(err)})`); } const before = await countCommitPages(work); const capped = o.commits > o.maxCommits; const shown = Math.min(o.commits, o.maxCommits); const args = [ ...(capped ? ["-l", String(o.maxCommits)] : cacheFile ? ["-c", cacheFile] : []), "-u", o.baseUrl, o.gitDir, ]; const r = await o.run(o.stagit, args, { cwd: work, timeoutMs: 600_000 }); // The per-file pages are never published, and stagit writes them all again // on every run: none is kept. await rm(path.join(work, "file"), { recursive: true, force: true }).catch(() => {}); if (r.code !== 0) { const tail = r.out.filter((l) => l.trim()).slice(-2).join(" / "); throw new HistoryProblem(`stagit exited ${r.code}${tail ? `: ${tail}` : ""}`); } const logPath = path.join(work, "log.html"); if (!existsSync(logPath)) throw new HistoryProblem("stagit wrote no log.html"); const commits = loggedCommits((await readFile(logPath)).toString("latin1")); if (commits.length !== shown) { throw new HistoryProblem(`stagit's log lists ${commits.length} commits, not ${shown} (${o.commits} in all, at most ${o.maxCommits})`); } if (commits[0] !== o.head) throw new HistoryProblem(`stagit's log does not start at the head`); const staged = await stageHistory(work, o.dest, { themeScript: o.themeScript, stylesheet: o.stylesheet, commits, treeFiles: o.treeFiles, }); const after = await countCommitPages(work); return { ...staged, shown, rendered: cacheFile ? after - before : after }; } /** * The cache's key: whatever, changed, makes a page rendered before wrong — the * rules (with the step's version) and filter-repo that made the ids, the * stagit that wrote the pages, and the header text every page carries. */ export function historyCacheKey(parts: { rulesHash: string; filterRepo: string; stagit: string; description: string; cloneUrl: string; baseUrl: string; }): string { return createHash("sha256").update(JSON.stringify(parts)).digest("hex"); }