Each file page on the default branch gets a blame.json beside it: the commit that last changed each line, and the commits that changed the file, each with the review it landed in. The page draws them on demand, blame as a column before the line numbers, history as a panel. A build runs git blame only for files commits touched since the last one.
- commit
- 350f662f8f446a2db9dd091c655f66a2a37abeb4
- parent
- a88eb729a2600eb60b38bf93007dc04680c4d996
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 127s, signed by cat
- kind
- feat risk score 1.4 of 3, Jev's estimate
- review
- #33 CI metrics, approval rules, agent reviews, blame
ci attestation
forge-attestation 1 type ci commit 350f662f8f446a2db9dd091c655f66a2a37abeb4 tree fd6952c39ea653ed5a3e14dba8174a2b64248c75 script .forge/ci a33e712a3272cd9537a815e211adf41cb6697023 result pass exit 0 started 2026-11-04T05:10:45Z duration 127s runner (not shown here; refs/notes/ci has it) system Darwin 27.2.0 arm64 env host log sha256:67d6e38241fdde13dd90b74d67572751e40e16f4b6877bb1883c6d3fe9427e84 342 lines | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |3b25da7 fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |3fa0c29 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |3fa0c29 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 2e3a35a -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |48df1dc running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |48df1dc pass in 0s, signed |pushed main |d6b97a1 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |d6b97a1 pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |27a5223 running .forge/ci: approval rules, and a reviewer for forge critique | | sh: /bin/sh | | ok greet |27a5223 pass in 0s, signed |(node:57384) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 2s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.xGfn10/wt/demo/root/.forge/www |#7 d6b97a1 reviewing everything since main: Regional notes |#7: 2 finding(s), 2 posted; 1 blocking open; signed (demo-rules) |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed cbf1d83 -> /sites/dami/forge/guide/ |(node:59483) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.xGfn10/wt/demo/root/.forge/www |(node:59667) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.xGfn10/wt/demo/root/.forge/www |(node:59859) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.xGfn10/wt/demo/root/.forge/www |(node:60107) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.xGfn10/wt/demo/root/.forge/www |(node:60517) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.xGfn10/wt/demo/root/.forge/www |240 of 240 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| M | .forge/ | 4 | +- |
| M | README.md | 14 | +++ |
| M | guide/ | 1 | + |
| A | lib/ | 164 | ++++++++++++++++++++++++++++++ |
| M | lib/ | 16 | +++- |
| A | site/ | 158 | +++++++++++++++++++++++++++++ |
| M | site/ | 21 | +++- |
| A | test/ | 66 | ++++++++++++ |
@@ -33,10 +33,12 @@ grep -q 'data-search="/dami/greet/search.json"' $www/dami/greet/index.html
3333 # the insights pages; the forge's leave out nothing public.3434 grep -q '"scope":"dami/greet".*"landed":1,"closed":1,' $www/dami/greet/insights/insights.json3535 grep -q '"ci":18,"ci_pass":17,' $www/dami/greet/insights/insights.json36-# CI metrics, approval rules and an agent review (demo/make-demo.sh).36+# CI metrics, approval rules, an agent review and blame (demo/make-demo.sh).3737 grep -q '"metrics":\[\["script_bytes","B",' $www/dami/greet/insights/insights.json3838 grep -q '<span class="tag blocking">blocking</span>' $www/dami/greet/reviews/7/index.html3939 grep -q '<b>agent review</b><span class="has">: demo-rules, signed by ana, 2 findings, <b>1 blocking open</b>' $www/dami/greet/reviews/7/index.html40+grep -q '"ranges":\[\[1,' $www/dami/greet/blob/main/greet.sh/blame.json41+grep -q 'data-blame-src="/dami/greet/blob/main/greet.sh/blame.json"' $www/dami/greet/blob/main/greet.sh/index.html4042 grep -q '"repos":\[\["ana/dotfiles",' $www/insights/insights.json4143 for f in events builds reviews ci; do test "$(head -c 4 $www/insights/$f.parquet)" = PAR1; done4244 # Templates render the same without 11ty (lib/render-page.ts), with the@@ -45,6 +45,9 @@ It adds what stagit doesn't have, borrowing from [args.io](https://args.io):
4545 puts a tag's binaries on `refs/releases/v1.0` with a `SHA256SUMS` signed4646 by your key; the forge checks both and serves the files.4747 - **Keyboard navigation and clean URLs**, e.g. `/cat/greet/tree/main/greet.sh/`.48+- **Blame and history.** A file on the default branch shows who last changed49+ each line and every commit that changed it, each with the review it50+ landed in.4851 - **Search and syntax highlighting.** `/` searches a repo's commits, reviews,4952 wiki and file paths from a static index, and file contents from a SQLite5053 index the browser reads in byte ranges; code is highlighted at build time.@@ -1600,6 +1603,17 @@ relative ("3h ago", then "Sep 1") by the browser, with the local time on
16001603 hover, so static pages don't go stale. On phones, long code lines wrap unless16011604 you turn it off with the Wrap button, which is remembered.16021605 1606+A file on the default branch has **blame** and **history** buttons. Blame1607+puts the commit that last changed each line, and when, in a column before1608+the line numbers, one band per run of lines from a commit; clicking it1609+opens that commit. History lists the commits that changed the file, newest1610+first (the latest 200), each with the review it landed in. Both come from1611+the file's `blame.json` beside its page (`lib/blame.ts`), fetched the1612+first time either is asked for, so a file adds no pages. A build computes1613+it with `git blame` and `git log` for the files commits touched since the1614+last one, and keeps the rest in `$FORGE_CACHE/blame/`. Blame stays on from1615+file to file once turned on.1616+16031617 Keyboard shortcuts (`?` lists them):16041618 16051619 | Key | Does |@@ -109,6 +109,7 @@
109109 <li><strong>Live.</strong> An open page updates itself after a push, without losing your place.</li>110110 <li><strong>Feeds.</strong> Every repo has an Atom feed of its newest commits.</li>111111 <li><strong>Diagrams.</strong> <code>```mermaid</code> fences in any markdown file are drawn as diagrams, without JavaScript.</li>112+<li><strong>Blame and history.</strong> A file on <code>main</code> shows who last changed each line, and every commit that changed it with the review it landed in.</li>112113 </ul>113114 </div>114115 </section>@@ -0,0 +1,164 @@
1+// Blame and history for the files on a repo's default branch: for each file,2+// which commit last changed each line, and the commits that changed the3+// file, newest first, each with the review it landed in. The file page4+// fetches its blame.json and draws them (site/assets/blame.ts), so no page5+// is added per file.6+//7+// { "v": 1,8+// "commits": [[sha, subject, author, time (s), review id | null, review title | null, has a page], ...],9+// "ranges": [[first line, lines, commit index], ...], in line order, covering the file10+// "history": [commit index, ...], newest first, at most HISTORY11+// "more": true } when the history is longer12+//13+// A file's blame only changes when a commit changes it, so each one is kept14+// in $FORGE_CACHE/blame/<owner>/<repo>/ by path, with the blob and the tip it15+// was computed at. A build reuses it while that tip is still in the branch's16+// history and no commit since touched the file; otherwise it runs git blame (fast17+// with the commit-graph's changed-path filters, which builds write) and git18+// log for that file alone. A push recomputes only the files it changed.19+import fs from 'node:fs';20+import path from 'node:path';21+import { createHash } from 'node:crypto';22+import { isAncestor, tryRun } from './git.ts';23+import { MAX_HIGHLIGHT } from './highlight.ts';24+import { IMAGE } from './repo.ts';25+import type { Config, Repo, TreeEntry } from './types.ts';26+27+const VERSION = 2;28+export const HISTORY = 200;29+// The most files a repo's first build blames; the rest wait for later builds.30+const FIRST = Number(process.env.FORGE_BLAME_MAX || 3000);31+32+// [sha, subject, author, time (s), review id, review title, has a page (1 or 0)]33+type CommitRow = [string, string, string, number, number | null, string | null, number];34+export interface BlameFile {35+ v: number;36+ commits: CommitRow[];37+ ranges: [number, number, number][];38+ history: number[];39+ more?: boolean;40+}41+42+interface Cached {43+ v: number;44+ oid: string;45+ tip: string;46+ data: BlameFile;47+}48+49+// The porcelain of git blame --incremental: groups of "sha src dst n", with50+// a commit's headers the first time it appears.51+export function parseIncremental(text: string): { ranges: [number, number, string][]; info: Map<string, { author: string; time: number; summary: string }> } {52+ const ranges: [number, number, string][] = [];53+ const info = new Map<string, { author: string; time: number; summary: string }>();54+ let cur: string | null = null;55+ for (const line of text.split('\n')) {56+ const m = /^([0-9a-f]{40}) \d+ (\d+) (\d+)$/.exec(line);57+ if (m) {58+ cur = m[1];59+ ranges.push([Number(m[2]), Number(m[3]), cur]);60+ if (!info.has(cur)) info.set(cur, { author: '', time: 0, summary: '' });61+ continue;62+ }63+ if (!cur) continue;64+ const i = info.get(cur)!;65+ if (line.startsWith('author ')) i.author = line.slice(7);66+ else if (line.startsWith('author-time ')) i.time = Number(line.slice(12));67+ else if (line.startsWith('summary ')) i.summary = line.slice(8);68+ }69+ ranges.sort((a, b) => a[0] - b[0]);70+ return { ranges, info };71+}72+73+function compute(dir: string, tip: string, file: string, review: (sha: string) => [number, string] | null, hasPage: (sha: string) => boolean): BlameFile | null {74+ const out = tryRun(dir, ['blame', '--incremental', tip, '--', file]);75+ if (out == null) return null;76+ const { ranges, info } = parseIncremental(out);77+ const log = tryRun(dir, ['log', '-z', `-n${HISTORY + 1}`, '--format=%H%x1f%an%x1f%at%x1f%s', tip, '--', file]) ?? '';78+ const hist = log.split('\0').filter(Boolean).map((r) => r.replace(/^\n/, '').split('\x1f'));79+ const commits: CommitRow[] = [];80+ const index = new Map<string, number>();81+ const add = (sha: string, subject: string, author: string, time: number): number => {82+ let i = index.get(sha);83+ if (i == null) {84+ const rv = review(sha);85+ i = commits.push([sha, subject, author, time, rv?.[0] ?? null, rv?.[1] ?? null, hasPage(sha) ? 1 : 0]) - 1;86+ index.set(sha, i);87+ }88+ return i;89+ };90+ const history = hist.slice(0, HISTORY).map(([sha, author, at, subject]) => add(sha, subject ?? '', author ?? '', Number(at)));91+ const merged: [number, number, number][] = [];92+ for (const [start, n, sha] of ranges) {93+ const c = info.get(sha)!;94+ const i = add(sha, c.summary, c.author, c.time);95+ const last = merged.at(-1);96+ if (last && last[2] === i && last[0] + last[1] === start) last[1] += n;97+ else merged.push([start, n, i]);98+ }99+ return { v: VERSION, commits, ranges: merged, history, ...(hist.length > HISTORY ? { more: true } : {}) };100+}101+102+// Which files get a blame: text the file page shows as code.103+export const blamable = (e: TreeEntry, maxBlob: number): boolean =>104+ e.size != null && e.size > 0 && e.size <= Math.min(maxBlob, MAX_HIGHLIGHT) && !IMAGE.test(e.path) && e.mode !== '120000' && e.mode !== '160000';105+106+// The blame of every blamable file on the default branch, by path, as the107+// bytes of its blame.json. Returns how many were computed (not reused).108+export function blames(cfg: Config, repo: Repo, entries: TreeEntry[]): { files: Map<string, Buffer>; computed: number; skipped: number } {109+ const files = new Map<string, Buffer>();110+ const tip = repo.branches.find((b) => b.isDefault)?.sha;111+ if (!tip) return { files, computed: 0, skipped: 0 };112+ const dir = path.join(cfg.cacheDir, 'blame', repo.owner, repo.name);113+ fs.mkdirSync(dir, { recursive: true });114+ // Commits that landed in a review: its number and title.115+ const landed = new Map<string, [number, string]>();116+ for (const r of repo.reviews) if (r.status === 'merged') for (const c of r.commits) if (!landed.has(c)) landed.set(c, [r.id, r.title]);117+ const review = (sha: string): [number, string] | null => landed.get(sha) ?? null;118+ // The paths commits changed since a cached tip, once per tip; null when119+ // that tip has left the branch's history (a force push), so all is stale.120+ const since = new Map<string, Set<string> | null>();121+ const changedSince = (t: string): Set<string> | null => {122+ if (!since.has(t)) {123+ const ok = t === tip || isAncestor(repo.dir, t, tip);124+ const names = ok && t !== tip ? tryRun(repo.dir, ['log', '-z', '--format=', '--name-only', '--no-renames', `${t}..${tip}`]) : '';125+ since.set(t, ok ? new Set((names ?? '').split('\0').map((n) => n.replace(/^\n/, '')).filter(Boolean)) : null);126+ }127+ return since.get(t)!;128+ };129+ let computed = 0;130+ let skipped = 0;131+ const keep = new Set<string>();132+ for (const e of entries) {133+ if (!blamable(e, cfg.maxBlob)) continue;134+ const key = createHash('sha1').update(e.path).digest('hex');135+ keep.add(`${key}.json`);136+ const file = path.join(dir, `${key}.json`);137+ let c: Cached | null = null;138+ try {139+ c = JSON.parse(fs.readFileSync(file, 'utf8')) as Cached;140+ } catch {141+ c = null;142+ }143+ const changed = c && c.v === VERSION && c.oid === e.oid ? changedSince(c.tip) : null;144+ // A commit since may have changed the file and changed it back.145+ if (c && changed && !changed.has(e.path)) {146+ files.set(e.path, Buffer.from(JSON.stringify(c.data)));147+ continue;148+ }149+ if (computed >= FIRST) {150+ skipped++;151+ continue;152+ }153+ const data = compute(repo.dir, tip, e.path, review, (sha) => repo.commits.has(sha));154+ if (!data) continue;155+ computed++;156+ const tmp = `${file}.${process.pid}`;157+ fs.writeFileSync(tmp, JSON.stringify({ v: VERSION, oid: e.oid, tip, data } satisfies Cached));158+ fs.renameSync(tmp, file);159+ files.set(e.path, Buffer.from(JSON.stringify(data)));160+ }161+ // Files that left the branch.162+ for (const f of fs.readdirSync(dir)) if (f.endsWith('.json') && !keep.has(f)) fs.rmSync(path.join(dir, f), { force: true });163+ return { files, computed, skipped };164+}@@ -18,6 +18,7 @@ import { Spans, build, codeVersion } from './metrics.ts';
1818 import { RepoCache } from './cache.ts';1919 import { repoIndex, reposIndex } from './search.ts';2020 import { codeIndex } from './code-index.ts';21+import { blames } from './blame.ts';2122 import { labelCommits, labelKey, releases, repoTopic } from './jev-commits.ts';2223 import { annotateReviews, answersStamp, isMechanical, pairChanges } from './jev-reviews.ts';2324 import { HighlightCache } from './highlight.ts';@@ -316,6 +317,10 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
316317 for (const kind of ['tree', 'log']) pages.branchMenus.push({ permalink: u.branchMenu(kind).path, repo: head, kind, branches: repo.branches });317318 318319 const wantBlobs = new Set<string>();320+ // Blame and history of the default branch's files (lib/blame.ts), a321+ // blame.json beside each file page, which the page fetches.322+ const blame = rootTree ? spans.time('blame', () => blames(cfg, repo, rootTree.blobs)) : null;323+ if (blame) spans.count('blamed', blame.computed);319324 for (const b of repo.branches) {320325 const commits = (repo.logs.get(b.name) ?? []).map((sha) => repo.commits.get(sha)).filter((c): c is Commit => !!c);321326 add(pages.logs, page(u.log(b.name).path, `Log - ${b.name} - ${repo.slug}`, 'log', { branch: b, commits }),@@ -346,8 +351,17 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
346351 filesHref: u.files(b.name).href,347352 sourceHref: markdown ? u.blobSource(b.name, e.path).href : null,348353 renderedHref: markdown ? u.blob(b.name, e.path).href : null,354+ blameHref: null as string | null,355+ commitBase: u.home.href + 'commit/',356+ reviewBase: u.reviews.href,349357 };350- const key = `blob ${b.name} ${e.path} ${e.oid} ${e.mode}`;358+ const bl = b.isDefault ? blame?.files.get(e.path) : undefined;359+ if (bl) {360+ const at = `${u.blob(b.name, e.path).path}blame.json`;361+ info.blameHref = `${u.blob(b.name, e.path).href}blame.json`;362+ if (want(at, `blame ${fingerprint(bl.toString('utf8'))}`)) raw.push({ path: at, data: bl });363+ }364+ const key = `blob ${b.name} ${e.path} ${e.oid} ${e.mode}${bl ? ' blame.v1' : ''}`;351365 // Markdown files render as documents; their source is a page of its own.352366 const p = page(u.blob(b.name, e.path).path, `${e.path} - ${b.name} - ${repo.slug}`, 'files', { ...info, markdown });353367 if (want(p.permalink, key)) {@@ -0,0 +1,158 @@
1+// Blame and history on a file page (site/repo/blob.njk), from the file's2+// blame.json (lib/blame.ts), fetched the first time either is asked for.3+// Blame puts a column before each line: the commit that last changed it, on4+// the first line of each run, and when. Clicking it opens the commit (lines5+// are links to themselves, so the column can't hold a link of its own; the6+// history panel has the same commits as links). History lists the commits7+// that changed the file, newest first, with the review each landed in.8+(() => {9+ const viewer = document.querySelector<HTMLElement>('[data-blame-src]');10+ const code = viewer?.querySelector<HTMLElement>('.code.blob');11+ const blameBtn = document.querySelector<HTMLButtonElement>('button[data-blame]');12+ const historyBtn = document.querySelector<HTMLButtonElement>('button[data-history]');13+ const panel = document.querySelector<HTMLElement>('#history');14+ if (!viewer || !code || !blameBtn || !historyBtn || !panel) return;15+ const src = viewer.dataset.blameSrc!;16+ const commitBase = viewer.dataset.commitBase!;17+ const reviewBase = viewer.dataset.reviewBase!;18+19+ // [sha, subject, author, time (s), review, review title, has a page]20+ type Row = [string, string, string, number, number | null, string | null, number];21+ interface Data {22+ commits: Row[];23+ ranges: [number, number, number][];24+ history: number[];25+ more?: boolean;26+ }27+ let data: Promise<Data> | null = null;28+ const load = (): Promise<Data> => (data ??= fetch(src).then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))));29+30+ // Compact, as times in a column are elsewhere (ui.ts); the full time on hover.31+ const full = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });32+ const day = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });33+ const compact = (t: Date): string => {34+ const s = (Date.now() - t.getTime()) / 1000;35+ if (s < 60) return 'now';36+ if (s < 3600) return `${Math.floor(s / 60)}m`;37+ if (s < 86400) return `${Math.floor(s / 3600)}h`;38+ if (s < 7 * 86400) return `${Math.floor(s / 86400)}d`;39+ if (s < 28 * 86400) return `${Math.floor(s / (7 * 86400))}w`;40+ if (t.getFullYear() === new Date().getFullYear()) return day.format(t);41+ return t.toISOString().slice(0, 10);42+ };43+ const time = (secs: number): HTMLTimeElement => {44+ const t = new Date(secs * 1000);45+ const el = document.createElement('time');46+ el.dateTime = t.toISOString();47+ el.title = full.format(t);48+ el.textContent = compact(t);49+ return el;50+ };51+ const href = (c: Row): string | null => (c[6] ? `${commitBase}${c[0]}/` : null);52+53+ const failed = (btn: HTMLButtonElement): void => {54+ btn.disabled = true;55+ btn.title = 'blame.json could not be loaded';56+ };57+58+ let drawn = false;59+ function drawBlame(d: Data): void {60+ if (drawn) return;61+ drawn = true;62+ const lines = code!.querySelectorAll<HTMLElement>('.l');63+ d.ranges.forEach(([start, n, ci], band) => {64+ const c = d.commits[ci];65+ for (let k = 0; k < n; k++) {66+ const l = lines[start - 1 + k];67+ if (!l) continue;68+ const s = document.createElement('span');69+ s.className = `bl${band % 2 ? ' alt' : ''}${k === 0 ? ' first' : ''}`;70+ s.dataset.c = String(ci);71+ // The line is a link to itself; its name stays the line. History has the same facts as links.72+ s.setAttribute('aria-hidden', 'true');73+ s.title = `${c[0].slice(0, 7)} ${c[1]}, ${c[2]}${c[4] != null ? `, review #${c[4]}` : ''}`;74+ if (k === 0) {75+ s.append(`${c[0].slice(0, 7)} `, time(c[3]));76+ }77+ l.prepend(s);78+ }79+ });80+ code!.addEventListener('click', (e) => {81+ const s = (e.target as HTMLElement).closest<HTMLElement>('.bl');82+ if (!s || !code!.classList.contains('blamed')) return;83+ const c = d.commits[Number(s.dataset.c)];84+ const to = c && href(c);85+ if (!to) return;86+ e.preventDefault();87+ location.href = to;88+ });89+ }90+91+ function drawHistory(d: Data): void {92+ const body = panel!.querySelector('tbody')!;93+ if (body.childElementCount) return;94+ for (const ci of d.history) {95+ const c = d.commits[ci];96+ const tr = document.createElement('tr');97+ const when = document.createElement('td');98+ when.className = 'date';99+ when.appendChild(time(c[3]));100+ const subj = document.createElement('td');101+ subj.className = 'main';102+ const to = href(c);103+ const a = document.createElement(to ? 'a' : 'span');104+ if (to) (a as HTMLAnchorElement).href = to;105+ a.textContent = c[1];106+ subj.appendChild(a);107+ const who = document.createElement('td');108+ who.className = 'hide-sm';109+ who.textContent = c[2];110+ const rv = document.createElement('td');111+ rv.className = 'rv';112+ if (c[4] != null) {113+ const r = document.createElement('a');114+ r.href = `${reviewBase}${c[4]}/`;115+ r.textContent = `#${c[4]}`;116+ r.title = c[5] ?? '';117+ rv.appendChild(r);118+ }119+ const sha = document.createElement('td');120+ sha.className = 'sha hide-sm';121+ sha.textContent = c[0].slice(0, 7);122+ tr.append(when, subj, who, rv, sha);123+ body.appendChild(tr);124+ }125+ const grow = panel!.querySelector('.panel-head .grow');126+ if (grow) grow.textContent = `${d.history.length}${d.more ? '+' : ''} ${d.history.length === 1 ? 'commit' : 'commits'}, newest first${d.more ? '; older ones are in the log' : ''}`;127+ }128+129+ const KEY = 'forge.blame';130+ const setBlame = (on: boolean): void => {131+ blameBtn.setAttribute('aria-pressed', String(on));132+ if (!on) {133+ code.classList.remove('blamed');134+ localStorage.removeItem(KEY);135+ return;136+ }137+ localStorage.setItem(KEY, '1');138+ load().then((d) => {139+ drawBlame(d);140+ code.classList.add('blamed');141+ }, () => failed(blameBtn));142+ };143+ blameBtn.addEventListener('click', () => setBlame(blameBtn.getAttribute('aria-pressed') !== 'true'));144+ historyBtn.addEventListener('click', () => {145+ const on = historyBtn.getAttribute('aria-pressed') !== 'true';146+ historyBtn.setAttribute('aria-pressed', String(on));147+ if (!on) {148+ panel.hidden = true;149+ return;150+ }151+ load().then((d) => {152+ drawHistory(d);153+ panel.hidden = false;154+ }, () => failed(historyBtn));155+ });156+ // Blame stays on from page to page, like wrap lines.157+ if (localStorage.getItem(KEY) === '1') setBlame(true);158+})();@@ -5,18 +5,35 @@ pagination:
55 {% extends "base.njk" %}66 {% import "macros.njk" as m with context %}77 {% import "ui.njk" as ui %}8+{% block scripts %}{% if p.blameHref and not p.markdown %}<script src="{{ assets.blame }}" defer></script>{% endif %}{% endblock %}89 {% block content %}910 {% css %}1011 .image-view { padding: var(--space-4); text-align: center; }1112 .image-view img { max-width: 100%; height: auto; }13+/* Blame (blame.ts): a column before the line numbers, a band per run of14+ lines from one commit; its first line names the commit and when. */15+.code:not(.blamed) .bl { display: none; }16+.code .bl { flex: none; width: 17ch; padding: 0 var(--space-2); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-xs); color: var(--muted); border-right: var(--hairline) solid var(--border); background: var(--raised); }17+.code .bl.alt { background: var(--tint); }18+.code .bl.first { border-top: var(--hairline) solid var(--border); }19+.code .bl a { color: var(--link); }20+.code .bl time { font-size: inherit; color: var(--faint); }21+@media (max-width: 480px) { .code .bl { width: 9ch; } .code .bl time { display: none; } }22+.history { margin-bottom: var(--space-4); }23+.history td.rv { white-space: nowrap; }1224 {% endcss %}1325 <div class="bar">{{ m.branches(p.repo, p.branch.name, 'tree') }}{{ m.crumbs(p.repo, p.branch.name, p.crumbs) }}</div>1426 {% set head %}1527 <span class="grow"><b class="mono">{{ p.path.split('/') | last }}</b> <span class="n">{{ p.size | size }}</span>{% if p.mode == '100755' %} executable{% endif %}</span>1628 <span class="end">{% if p.markdown %}<a href="{{ p.sourceHref }}">source</a>{% elif p.isSource %}<a href="{{ p.renderedHref }}">rendered</a>{% endif %}17-{% if not p.markdown and not p.image %}{{ ui.checkbox('wrap lines', false, 'data-wrap') }}{{ ui.button('copy', false, 'data-copy-code') }}{% endif %}</span>29+{% if not p.markdown and not p.image %}{% if p.blameHref %}{{ ui.button('blame', false, 'data-blame aria-pressed="false" title="who last changed each line"') }}{{ ui.button('history', false, 'data-history aria-pressed="false" title="the commits that changed this file"') }}{% endif %}{{ ui.checkbox('wrap lines', false, 'data-wrap') }}{{ ui.button('copy', false, 'data-copy-code') }}{% endif %}</span>1830 {% endset %}19-{% call ui.panel(head, 'viewer') %}31+{% set battrs %}{% if p.blameHref and not p.markdown %}data-blame-src="{{ p.blameHref }}" data-commit-base="{{ p.commitBase }}" data-review-base="{{ p.reviewBase }}"{% endif %}{% endset %}32+{% if p.blameHref and not p.markdown %}33+{# The commits that changed the file (blame.ts fills it from blame.json). #}34+<section class="panel history" id="history" hidden><header class="panel-head"><b>history</b><span class="grow"></span></header><table class="list nav"><tbody></tbody></table></section>35+{% endif %}36+{% call ui.panel(head, 'viewer', battrs) %}2037 {% if p.markdown %}<div class="markdown">{{ p | markdownHtml | safe }}</div>{% else %}{{ p | blobHtml | safe }}{% endif %}2138 {% endcall %}2239 {% endblock %}@@ -0,0 +1,66 @@
1+# Blame and history (lib/blame.ts): each file on the default branch gets a2+# blame.json beside its page, saying which commit last changed each line3+# and which commits changed the file, with the review each landed in. A4+# build recomputes only the files commits touched since.5+. "$(dirname "$0")/../lib.sh"6+user dami7+repo dami/greet8+clone dami dami/greet "$T/w/d"9+printf 'one\ntwo\nthree\n' >"$T/w/d/a.txt"10+printf '# hi\n' >"$T/w/d/README.md"11+at "$T/w/d" commit "first" b.txt12+c1=$(git -C "$T/w/d" rev-parse HEAD)13+ok push_as dami "$T/w/d" origin main14+# Line 2 changes in a review that lands.15+at "$T/w/d" git switch -q -c dami/two16+printf 'one\nTWO\nthree\n' >"$T/w/d/a.txt"17+at "$T/w/d" git commit -q -am "shout two"18+c2=$(git -C "$T/w/d" rev-parse HEAD)19+ok push_as dami "$T/w/d" origin dami/two -o "title=Shout"20+ok push_as dami "$T/w/d" origin dami/two:main21+build dami/greet22+j=$FORGE_WWW/dami/greet/blob/main/a.txt/blame.json23+exists "$j"24+node -e '25+ const d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));26+ const sha = (i) => d.commits[i][0];27+ const [c1, c2] = process.argv.slice(2);28+ const by = d.ranges.map(([s, n, i]) => `${s}+${n}:${sha(i) === c1 ? "c1" : sha(i) === c2 ? "c2" : "?"}`).join(" ");29+ if (by !== "1+1:c1 2+1:c2 3+1:c1") throw new Error("ranges: " + by);30+ if (d.history.map(sha).join(" ") !== `${c2} ${c1}`) throw new Error("history: " + d.history.map(sha));31+ const two = d.commits.find((c) => c[0] === c2);32+ if (two[1] !== "shout two" || two[4] !== 1 || two[5] !== "Shout" || two[6] !== 1) throw new Error("commit row: " + JSON.stringify(two));33+' "$j" "$c1" "$c2" || fail "blame.json is wrong: $(cat "$j")"34+page=$FORGE_WWW/dami/greet/blob/main/a.txt/index.html35+has "$page" 'data-blame-src="/dami/greet/blob/main/a.txt/blame.json" data-commit-base="/dami/greet/commit/" data-review-base="/dami/greet/reviews/"'36+has "$page" '<button type="button" data-blame aria-pressed="false"'37+has "$page" '<script src="/assets/blame\.[A-Za-z0-9_-]{10}\.js" defer>'38+# Markdown: the rendered page has no blame; its source page does.39+lacks "$FORGE_WWW/dami/greet/blob/main/README.md/index.html" 'data-blame'40+has "$FORGE_WWW/dami/greet/blob/main/README.md/source/index.html" 'data-blame-src="/dami/greet/blob/main/README.md/blame.json"'41+# Other branches' file pages have none.42+at "$T/w/d" git switch -q -c dami/side43+at "$T/w/d" commit "side" c.txt44+ok push_as dami "$T/w/d" origin dami/side45+build dami/greet46+lacks "$FORGE_WWW/dami/greet/blob/dami/side/a.txt/index.html" 'data-blame'47+48+# Changed and changed back: same blob, but the lines are the revert's now.49+at "$T/w/d" git switch -q main50+at "$T/w/d" git reset -q --hard origin/main51+printf 'one\ntwo\nthree\n' >"$T/w/d/a.txt"52+at "$T/w/d" git commit -q -am "lower two"53+printf 'one\nTWO\nthree\n' >"$T/w/d/a.txt"54+at "$T/w/d" git commit -q -am "shout two again"55+c4=$(git -C "$T/w/d" rev-parse HEAD)56+ok push_as dami "$T/w/d" origin main57+build dami/greet58+node -e '59+ const d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));60+ const i = d.ranges.find(([s]) => s === 2)[2];61+ if (d.commits[i][0] !== process.argv[2]) throw new Error("line 2 is " + d.commits[i][0]);62+ if (d.history.length !== 4) throw new Error("history " + d.history.length);63+' "$j" "$c4" || fail "a file changed and back keeps an old blame: $(cat "$j")"64+# A file no commit touched since keeps its cached blame.65+cache=$(ls "$FORGE_CACHE/blame/dami/greet/" | wc -l | tr -d ' ')66+[ "$cache" -ge 3 ] || fail "expected cached blames, got $cache"