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.19import fs from 'node:fs';20import path from 'node:path';21import { createHash } from 'node:crypto';22import { isAncestor, tryRun } from './git.ts';23import { MAX_HIGHLIGHT } from './highlight.ts';24import { IMAGE } from './repo.ts';25import type { Config, Repo, TreeEntry } from './types.ts';2627const VERSION = 2;28export const HISTORY = 200;29// The most files a repo's first build blames; the rest wait for later builds.30const FIRST = Number(process.env.FORGE_BLAME_MAX || 3000);3132// [sha, subject, author, time (s), review id, review title, has a page (1 or 0)]33type CommitRow = [string, string, string, number, number | null, string | null, number];34export interface BlameFile {35 v: number;36 commits: CommitRow[];37 ranges: [number, number, number][];38 history: number[];39 more?: boolean;40}4142interface Cached {43 v: number;44 oid: string;45 tip: string;46 data: BlameFile;47}4849// The porcelain of git blame --incremental: groups of "sha src dst n", with50// a commit's headers the first time it appears.51export 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}7273function 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}101102// Which files get a blame: text the file page shows as code.103export 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';105106// 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).108export 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}