Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 1957d1eac6190c482aa0114162f1ae98832b0a06
parent b2c696cba2f27ea1f874e195c08227ae00b5bc9c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 10:07:43 -0400

common: SG review fixes — every history log line is masked (L1); a render cache that cannot be made or written (EACCES, EROFS, ENOSPC on the dir, the lock or the key) is one line and a render without it, never a failed build (L2); the retry after stagit's -c misses a merge of older commits says so (L3); the post-pass drops the href of a link to a file main no longer has, or to a commit with no page (the parent past the cap), keeping the text and the #h target (L4); `source publish`'s usage names the history (L5); a test for the file-count drop, through a historyFileLimit seam (L6); a lock is stale when its pid is not running or it is over an hour old, and is replaced with one line (L8)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/bin/archilyzer.ts | 2+-
Mcommon/publish/source.test.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/publish/source.ts | 18++++++++++++++----
Mcommon/publish/sourceHistory.test.ts | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcommon/publish/sourceHistory.ts | 211++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
5 files changed, 361 insertions(+), 49 deletions(-)

diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -153,7 +153,7 @@ export const COMMANDS: Command[] = [ { path: ["source", "publish"], usage: - "[--force] [--check] [--keep-scratch] the scrubbed git mirror, raw tree and tarball into homepage/public, behind the denied-literal gate (--check: audit and count, write nothing)", + "[--force] [--check] [--keep-scratch] the scrubbed git mirror, raw tree, history pages (stagit, when installed) and tarball into homepage/public, behind the denied-literal gate (--check: audit and count, write nothing)", flags: { force: "boolean", check: "boolean", "keep-scratch": "boolean" }, run: async ({ flags }) => { const { publishSource } = await import("../publish/source"); diff --git a/common/publish/source.test.ts b/common/publish/source.test.ts @@ -867,3 +867,50 @@ test("history: the render cache is the XDG cache's archilyzer/source-history — const want = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"); assert.equal(real, path.join(want, "archilyzer", "source-history")); }); + +test("history: an unusable render cache is one masked line and a render without it — the publish and its history go on (review L1, L2)", async () => { + const repo = sourceRepo(); + // The cache's path carries a denied literal: the line naming it is masked, + // as every line of the step is. + const planted = "plantedcachedir"; + const logs: string[] = []; + const log = path.join(dir("stagit-log"), "calls"); + const root = path.join(dir("ro-cache"), planted); + mkdirSync(root, { recursive: true }); + chmodSync(root, 0o555); + try { + const o = opts(repo, operatorFiles("", `${planted}\n`), logs, { + filterRepo: ["true"], + stagit: fakeStagit(log), + tokensFile: TOKENS_FILE, + historyCacheDir: path.join(root, "archilyzer", "source-history"), + }); + assert.equal(await publishSource(o), 0, logs.join("\n")); + const text = logs.join("\n"); + assert.match(text, /\[source\] history: the render cache .*\[REDACTED\].* is unusable \(EACCES\); rendering without it/); + assert.ok(!text.includes(planted), "never the literal"); + assert.equal(stagitCalls(log).at(-1), `cache= limit= base=https://archilyzer.pages.dev/source/git/ repo=${MIRROR_DIR}`); + assert.equal(JSON.parse(readFileSync(path.join(o.publicDir!, "source", "manifest.json"), "utf8")).history.commits, 3); + } finally { + chmodSync(root, 0o755); + } +}); + +test("history: pages that would take the publish over the step's file limit are dropped with a WARNING, never the publish (review L6)", async () => { + const repo = sourceRepo(); + const logs: string[] = []; + const log = path.join(dir("stagit-log"), "calls"); + const o = opts(repo, operatorFiles("", ""), logs, { + filterRepo: ["true"], + stagit: fakeStagit(log), + tokensFile: TOKENS_FILE, + historyCacheDir: null, + // Any limit the rest already fills: the history is what is dropped. + historyFileLimit: 1, + }); + assert.equal(await publishSource(o), 0, logs.join("\n")); + assert.match(logs.join("\n"), /\[source\] WARNING: 9 history files would make \d+ files to publish, over the step's limit of 1 \(Pages allows 20,000 per deployment\) — publishing without the history pages \(\/source\/git\/\)/); + assert.ok(!existsSync(path.join(o.publicDir!, "source", "git"))); + assert.ok(existsSync(path.join(o.publicDir!, "source", MIRROR_DIR, "info", "refs")), "the rest is published"); + assert.ok(!("history" in JSON.parse(readFileSync(path.join(o.publicDir!, "source", "manifest.json"), "utf8")))); +}); diff --git a/common/publish/source.ts b/common/publish/source.ts @@ -151,6 +151,9 @@ export type SourcePublishOpts = PublishOpts & { historyCacheDir?: string | null; // The history's cap. Default: SOURCE_HISTORY_MAX_COMMITS (the tests' seam). maxHistoryCommits?: number; + // The file limit the history's last-resort drop checks. Default: MAX_FILES + // (the tests' seam; step 14 always applies MAX_FILES). + historyFileLimit?: number; // The design tokens the history pages' style.css is written from. Default: // <repo>/common/styles/tokens.css. tokensFile?: string; @@ -820,7 +823,7 @@ async function publish( ]); if (badRoot) throw new SourceRefusal(badRoot); // From here a refusal removes the history's render cache too. - const historyCache = await historyCacheFor(opts, paths, publicDir, onLog); + const historyCache = await historyCacheFor(opts, paths, publicDir, (l) => onLog(maskLiterals(l, ctx.literals))); progress.historyCache = historyCache; await mkdir(scratchRoot, { recursive: true }); const scratch = await mkdtemp(path.join(scratchRoot, "archilyzer-source-")); @@ -986,6 +989,8 @@ async function publish( filterRepoId, stage, dest: stageHistoryDir, + // The raw tree's files, as tracked: links to any other become text. + treeFiles: new Set((await walkFiles(stageTree)).map((f) => f.rel)), // Everything staged so far, and the manifest still to come. stagedSoFar: (await walkFiles(stage)).length + 1, }); @@ -1121,6 +1126,7 @@ async function stageHistoryPages(a: { filterRepoId: string; stage: string; dest: string; + treeFiles: ReadonlySet<string>; stagedSoFar: number; }): Promise<SourceHistory | null> { const { ctx } = a; @@ -1177,16 +1183,20 @@ async function stageHistoryPages(a: { baseUrl, stylesheet, themeScript: homepageThemeScript(), + treeFiles: a.treeFiles, run: child, - onLog: ctx.onLog, + // stagit's words (a failure's tail, a timeout's argv) can name the home + // dir: every line is masked, as every other child line of the step is. + onLog: (l) => ctx.onLog(maskLiterals(l, ctx.literals)), }); } catch (err) { if (err instanceof HistoryProblem) return without(`the history pages were not rendered: ${err.message}`); throw err; } - if (a.stagedSoFar + r.files > MAX_FILES) { + const fileLimit = a.opts.historyFileLimit ?? MAX_FILES; + if (a.stagedSoFar + r.files > fileLimit) { return without( - `${r.files} history files would make ${a.stagedSoFar + r.files} files to publish, over the step's limit of ${MAX_FILES} (Pages allows 20,000 per deployment)`, + `${r.files} history files would make ${a.stagedSoFar + r.files} files to publish, over the step's limit of ${fileLimit} (Pages allows 20,000 per deployment)`, ); } if (r.largest.bytes > MAX_FILE_BYTES) { diff --git a/common/publish/sourceHistory.test.ts b/common/publish/sourceHistory.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import { chmodSync, + utimesSync, existsSync, mkdirSync, mkdtempSync, @@ -18,6 +19,7 @@ import { buildThemeScript, HOMEPAGE_DEFAULT_BASE } from "../lib/themeConfig"; import { writeFakeStagit } from "./__fixtures__/fakeStagit"; import { HISTORY_BACK_LINK, + HISTORY_LOCK_STALE_MS, HISTORY_TOKENS, HistoryProblem, SITE_ICON_HREF, @@ -115,6 +117,24 @@ test("the post-pass at the top level: files.html's links go to ../tree/<path>, a assert.ok(!out.includes('href="file/')); }); +test("the post-pass with what is published: a link to a file main no longer has, or to a commit with no page, becomes text (its id kept)", () => { + const sha = "a".repeat(40); + const gone = "b".repeat(40); + const page = `<html>\n<head>\n</head>\n<body>\n<a href="../commit/${sha}.html">${sha}</a> <b>parent</b> <a href="../commit/${gone}.html">${gone}</a>\n<b>diff --git a/<a id="h0" href="../file/old/name.txt.html">old/name.txt</a> b/<a href="../file/app/%5Bslug%5D/page.tsx.html">app/[slug]/page.tsx</a></b>\n<a href="../file/bad%zz.html">bad</a>\n</body>\n</html>\n`; + const published = { + tree: (p: string) => p === "app/[slug]/page.tsx", + commit: (c: string) => c === sha, + }; + const out = rewriteHistoryPage(page, "t()", published); + assert.ok(out.includes(`<a href="../commit/${sha}.html">${sha}</a>`), "a published commit keeps its link"); + assert.ok(out.includes(`<b>parent</b> <a>${gone}</a>`), "an unpublished commit is text"); + assert.ok(out.includes(`a/<a id="h0">old/name.txt</a>`), "a file main no longer has is text, and keeps the diffstat's target"); + assert.ok(out.includes(`b/<a href="../../tree/app/%5Bslug%5D/page.tsx">`), "a file in the tree keeps its (encoded) link"); + assert.ok(out.includes(`<a>bad</a>`), "an encoding that does not decode is not linked"); + // Without the set, nothing is dropped (the unit tests above). + assert.ok(rewriteHistoryPage(page, "t()").includes(`href="../../tree/old/name.txt"`)); +}); + test("the theme script is the homepage's own: its base, its string", () => { assert.equal(HOMEPAGE_DEFAULT_BASE, "dark"); assert.equal(homepageThemeScript(), buildThemeScript({ defaultBase: HOMEPAGE_DEFAULT_BASE })); @@ -427,7 +447,7 @@ test("the cache: -c in the cache dir, incremental the next time, the key and the o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2" }); r = await renderHistory(o); assert.equal(r.cached, false); - assert.match(o.logs.join("\n"), new RegExp(`the log lists ${oldest.slice(0, 12)}, whose page is not there from the cache; rendering every page again`)); + assert.match(o.logs.join("\n"), new RegExp(`the cached render does not cover this head \\(the log lists ${oldest.slice(0, 12)}, whose page is not there\\) — stagit's -c stops at the last head it rendered, and a merge of older commits falls behind it; rendering every page again`)); assert.ok(existsSync(path.join(o.dest, "commit", `${oldest}.html`))); // `fresh` (--force) renders every page, whatever the cache holds. @@ -460,13 +480,106 @@ test("the cache: -c in the cache dir, incremental the next time, the key and the assert.deepEqual(readdirSync(cacheDir), []); }); +test("a stale lock: its pid not running, or over an hour old whoever runs its pid now, is replaced with one line", async () => { + const cacheDir = path.join(dir("stale"), "archilyzer", "source-history"); + mkdirSync(path.join(cacheDir, "out"), { recursive: true }); + const lock = path.join(cacheDir, "lock"); + const logs: string[] = []; + const onLog = (l: string) => { + logs.push(l); + }; + // Live and fresh: busy. + writeFileSync(lock, `${process.pid}\n`); + assert.equal(await holdHistoryCache(cacheDir, onLog), false); + assert.deepEqual(logs, []); + // Live, but two hours old: stale. + const old = new Date(Date.now() - 2 * HISTORY_LOCK_STALE_MS); + utimesSync(lock, old, old); + assert.equal(await holdHistoryCache(cacheDir, onLog), true); + assert.match(logs.join("\n"), new RegExp(`a stale lock on the render cache \\(pid ${process.pid}, 120 minutes old\\) was replaced; the cache is rendered afresh`)); + assert.ok(!existsSync(path.join(cacheDir, "out")), "emptied"); + assert.equal(readFileSync(lock, "utf8"), `${process.pid}\n`, "and held"); + rmSync(lock); + // Not running: stale, whatever its age. + const dead = spawnSync("sh", ["-c", "echo $$"], { encoding: "utf8" }).stdout.trim(); + writeFileSync(lock, `${dead}\n`); + logs.length = 0; + assert.equal(await holdHistoryCache(cacheDir, onLog), true); + assert.match(logs.join("\n"), new RegExp(`\\(pid ${dead}, not running\\) was replaced`)); +}); + +test("a render cache that cannot be written (a read-only dir, or one that cannot be made) is one line and a render without it", async () => { + const { gitDir } = bareRepo(2); + const log = path.join(dir("log"), "calls"); + const stagit = fakeStagit(log); + const root = dir("ro"); + const readOnly = path.join(root, "cache"); + mkdirSync(readOnly); + chmodSync(readOnly, 0o555); + const parent = path.join(root, "parent"); + mkdirSync(parent); + chmodSync(parent, 0o555); + try { + for (const cacheDir of [readOnly, path.join(parent, "archilyzer", "source-history")]) { + const o = renderOpts(gitDir, stagit, { cacheDir }); + const r = await renderHistory(o); + assert.equal(r.cached, false); + assert.equal(r.shown, 2); + assert.equal(calls(log).at(-1), `cache= limit= base=${o.baseUrl} repo=archilyzer.git`, "rendered without -c"); + assert.match(o.logs.join("\n"), /the render cache .* is unusable \(EACCES\); rendering without it/); + assert.equal(readdirSync(path.join(o.dest, "commit")).length, 2); + } + } finally { + chmodSync(readOnly, 0o755); + chmodSync(parent, 0o755); + } +}); + +test("a --no-ff merge of commits older than the cached head: stagit's -c misses them, the count notices, and every page is rendered again", async () => { + const { gitDir, work } = bareRepo(2); + const log = path.join(dir("log"), "calls"); + const stagit = fakeStagit(log); + const cacheDir = path.join(dir("merge-cache"), "archilyzer", "source-history"); + // A branch whose commits are dated before the head the cache will name. + const base = gitIn(work, "rev-parse", "HEAD~1"); + gitIn(work, "checkout", "-q", "-b", "side", base); + for (const i of [7, 8]) { + writeFileSync(path.join(work, `side-${i}.txt`), `${i}\n`); + gitIn(work, "add", "-A"); + execFileSync("git", ["commit", "-q", "-m", `side ${i}`], { + cwd: work, + stdio: "pipe", + env: { ...process.env, GIT_COMMITTER_DATE: `2001-01-0${i - 6}T00:00:00Z`, GIT_AUTHOR_DATE: `2001-01-0${i - 6}T00:00:00Z` }, + }); + } + gitIn(work, "checkout", "-q", "main"); + let o = renderOpts(gitDir, stagit, { cacheDir }); + await renderHistory(o); + gitIn(work, "merge", "-q", "--no-ff", "-m", "merge side", "side"); + gitIn(TMP, "--git-dir", gitDir, "fetch", "-q", work, "main:main"); + o = renderOpts(gitDir, stagit, { cacheDir }); + const r = await renderHistory(o); + assert.equal(r.shown, 5); + assert.equal(r.cached, false); + assert.match(o.logs.join("\n"), /the cached render does not cover this head \(stagit's log lists 3 commits, not 5 .*\) — stagit's -c stops at the last head it rendered, and a merge of older commits falls behind it; rendering every page again/); + assert.equal(readdirSync(path.join(o.dest, "commit")).length, 5); +}); + // ── the real stagit ───────────────────────────────────────────────────────── test("the real stagit: a page per commit, every link into file/ now into the tree, every relative link resolves", async (t) => { const stagit = resolveStagit(process.env.STAGIT_BIN ?? "stagit", process.env); if (!stagit) return t.skip("stagit is not installed (STAGIT_BIN, PATH, ~/.local/bin)"); - const { gitDir } = bareRepo(3); - const o = renderOpts(gitDir, stagit, { cacheDir: path.join(dir("real-root"), "archilyzer-source-history") }); + const { gitDir, work } = bareRepo(1); + // A file that main no longer has: its diffs are published, its links not. + writeFileSync(path.join(work, "gone.txt"), "soon gone\n"); + gitIn(work, "add", "-A"); + gitIn(work, "commit", "-q", "-m", "add gone.txt"); + gitIn(work, "rm", "-q", "gone.txt"); + gitIn(work, "commit", "-q", "-m", "remove gone.txt"); + gitIn(TMP, "--git-dir", gitDir, "fetch", "-q", work, "main:main"); + const treeFiles = new Set(["README.md", "app/[slug]/page.tsx"]); + const o = renderOpts(gitDir, stagit, { cacheDir: path.join(dir("real-root"), "archilyzer", "source-history"), treeFiles }); const r = await renderHistory(o); assert.equal(r.rendered, 3); assert.equal(readdirSync(path.join(o.dest, "commit")).length, 3); @@ -484,8 +597,8 @@ test("the real stagit: a page per commit, every link into file/ now into the tre .normalize(path.posix.join(path.posix.dirname(`source/git/${page}`), target)) .replace(/\/$/, ""); if (resolved.startsWith("source/tree/")) { - // Into the raw tree: a file of main. - assert.ok(["source/tree/README.md", "source/tree/app/%5Bslug%5D/page.tsx"].includes(resolved), `${page}: ${target}`); + // Into the raw tree: only a file main has (`gone.txt` is text). + assert.ok(treeFiles.has(decodeURIComponent(resolved.slice("source/tree/".length))), `${page}: ${target}`); } else if (resolved.startsWith("source/git/")) { assert.ok(existsSync(path.join(path.dirname(path.dirname(o.dest)), resolved)), `${page}: ${target} → ${resolved}`); } else { @@ -493,6 +606,8 @@ test("the real stagit: a page per commit, every link into file/ now into the tre } } } + const pages = readdirSync(path.join(o.dest, "commit")).map((f) => readFileSync(path.join(o.dest, "commit", f), "utf8")); + assert.ok(pages.some((h) => /<a id="h0">gone\.txt<\/a>/.test(h)), "gone.txt's diff header is text, its #h0 target kept"); const logHtml = readFileSync(path.join(o.dest, "log.html"), "utf8"); assert.match(logHtml, /<title>Log - archilyzer - Archilyzer<\/title>/); assert.match(logHtml, /git clone <a href="https:\/\/archilyzer\.pages\.dev\/source\/archilyzer\.git">/); @@ -501,7 +616,7 @@ test("the real stagit: a page per commit, every link into file/ now into the tre // The cap, by stagit itself: -l keeps the NEWEST in the log (and says how // many more), writes a page for every commit, and only the listed are // published. - const capped = renderOpts(gitDir, stagit, { maxCommits: 2 }); + const capped = renderOpts(gitDir, stagit, { maxCommits: 2, treeFiles }); const rc = await renderHistory(capped); const newest = gitIn(TMP, "--git-dir", gitDir, "rev-list", "--max-count=2", "main").split("\n"); assert.equal(rc.shown, 2); @@ -509,4 +624,9 @@ test("the real stagit: a page per commit, every link into file/ now into the tre assert.deepEqual(readdirSync(path.join(capped.dest, "commit")).sort(), newest.map((c) => `${c}.html`).sort()); assert.equal(readdirSync(path.join(capped.scratch, "history", "commit")).length, 3); assert.match(readFileSync(path.join(capped.dest, "log.html"), "utf8"), /1 more commits remaining, fetch the repository/); + // The oldest published page's parent has no page: its link is text. + const oldestShown = readFileSync(path.join(capped.dest, "commit", `${newest[1]}.html`), "utf8"); + const parent = gitIn(TMP, "--git-dir", gitDir, "rev-parse", `${newest[1]}^`); + assert.ok(oldestShown.includes(`<b>parent</b> <a>${parent}</a>`), "the parent past the cap is text"); + assert.ok(!oldestShown.includes(`commit/${parent}.html`)); }); diff --git a/common/publish/sourceHistory.ts b/common/publish/sourceHistory.ts @@ -44,8 +44,15 @@ // 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. `--force` renders it afresh, -// `--check` never touches it, and a refusal removes it. +// 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"; @@ -54,7 +61,7 @@ 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 } from "./sourceAudit"; +import { onPath, tildify } from "./sourceAudit"; /** How to install stagit, as the log line and the doctor say it. */ export const STAGIT_INSTALL = @@ -237,6 +244,7 @@ body { } 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; @@ -285,15 +293,38 @@ 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 `</head>` and * HISTORY_BACK_LINK after `<body>`; points every `href="…file/<path>.html"` * (the Files index, the header's README and LICENSE, a diff's file names) at * the raw tree — `../tree/<path>` from the same depth, the path as stagit * encoded it — and stagit's logo.png and favicon.png at the site's icon. - * Nothing else changes. Page text cannot fake an `href="…"`: stagit encodes - * every `"` it prints from the repository as `&quot;`. + * + * With `published`, a link to what is not published loses its `href` and + * stays as text (an `<a>` with no `href`, its `id` kept — a diff header is the + * diffstat's `#h<n>` 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 `&quot;`. */ -export function rewriteHistoryPage(html: string, themeScript: string): string { +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("</head>"); @@ -303,10 +334,22 @@ export function rewriteHistoryPage(html: string, themeScript: string): string { const at = body.index + body[0].length; out = `${out.slice(0, at)}${HISTORY_BACK_LINK}\n${out.slice(at)}`; } - return out - .replace(/href="((?:\.\.\/)*)file\/([^"]*)\.html"/g, (_m, up: string, p: string) => `href="${up}../tree/${p}"`) + 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; } /** @@ -333,7 +376,13 @@ export function loggedCommits(logHtml: string): string[] { export async function stageHistory( work: string, dest: string, - o: { themeScript: string; stylesheet: string; commits: readonly string[] }, + o: { + themeScript: string; + stylesheet: string; + commits: readonly string[]; + // The raw tree's files (as tracked); absent, no tree link is dropped. + treeFiles?: ReadonlySet<string>; + }, ): 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[] = []; @@ -347,6 +396,11 @@ export async function stageHistory( 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 }; @@ -358,7 +412,7 @@ export async function stageHistory( 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); + 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); @@ -409,6 +463,9 @@ export type RenderHistoryOpts = { 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<string>; // A child, run with the publish's environment and cancel signal. run: HistoryRun; onLog: (line: string) => void; @@ -435,12 +492,24 @@ const pidAlive = (pid: number): boolean => { }; /** + * 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 whose holder is - * gone means a render was cut off, so nothing in the directory is trusted: it - * is emptied and taken. + * 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): Promise<boolean> { +export async function holdHistoryCache( + dir: string, + onLog: (line: string) => void = () => {}, + now: () => number = Date.now, +): Promise<boolean> { await mkdir(dir, { recursive: true, mode: 0o700 }); const lock = path.join(dir, "lock"); for (let attempt = 0; attempt < 2; attempt++) { @@ -450,7 +519,14 @@ export async function holdHistoryCache(dir: string): Promise<boolean> { } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; const pid = Number((await readFile(lock, "utf8").catch(() => "")).trim()); - if (Number.isInteger(pid) && pid > 0 && pidAlive(pid)) return false; + 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); } } @@ -501,42 +577,95 @@ export async function renderHistory(o: RenderHistoryOpts): Promise<RenderedHisto await writeFile(path.join(o.gitDir, "description"), o.description); await writeFile(path.join(o.gitDir, "url"), `${o.cloneUrl}\n`); - const held = o.cacheDir ? await holdHistoryCache(o.cacheDir) : false; - if (o.cacheDir && !held) o.onLog("[source] history: the render cache is in use by another publish; rendering without it"); + 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 { - if (!held || !o.cacheDir) { - const work = path.join(o.scratch, "history"); - return { ...(await renderOnce(o, work, null)), cached: false }; - } - const dir = o.cacheDir; const keyFile = path.join(dir, "key.json"); const cacheFile = path.join(dir, "stagit.cache"); const work = path.join(dir, "out"); - let 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 }); + 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 writeFile(keyFile, JSON.stringify({ key: o.cacheKey }) + "\n"); + await writeKey(o, keyFile); return { ...r, cached: usable }; } catch (err) { if (!(err instanceof HistoryProblem) || !usable) throw err; - // A cache that looked sound and did not render: once more from nothing. - o.onLog(`[source] history: ${err.message} from the cache; rendering every page again`); - usable = false; - await emptyCache(dir); + // 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 writeFile(keyFile, JSON.stringify({ key: o.cacheKey }) + "\n"); + await writeKey(o, keyFile); return { ...r, cached: false }; } } catch (err) { - if (held && o.cacheDir) await emptyCache(o.cacheDir); + await emptyCache(dir).catch(() => {}); throw err; } finally { - if (held && o.cacheDir) await releaseHistoryCache(o.cacheDir); + 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`); } } @@ -581,7 +710,12 @@ async function renderOnce( work: string, cacheFile: string | null, ): Promise<Omit<RenderedHistory, "cached">> { - await mkdir(work, { recursive: true }); + 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); @@ -594,7 +728,7 @@ async function renderOnce( 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 }); + 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}` : ""}`); @@ -610,6 +744,7 @@ async function renderOnce( themeScript: o.themeScript, stylesheet: o.stylesheet, commits, + treeFiles: o.treeFiles, }); const after = await countCommitPages(work); return { ...staged, shown, rendered: cacheFile ? after - before : after };