From 5fdc5aa28035ed98d5d539b1ad4504538e220dda Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 2 Aug 2026 12:08:48 +0200 Subject: [PATCH] fame.py: also generate FAME.svg, a chart of the top contributors git-fame's own --format=svg renders the whole table as monospaced text, which works for a handful of contributors but not for a few hundred, so draw a bar chart of the top 20 (--svg-top) instead. FAME.md embeds it. The chart follows the reader's colour scheme, sizes its viewport in user units plus a viewBox (em on the root would resolve against the wrong font size) and escapes author names, which are commit-controlled input. Bots keep their row in the table, but do not take a chart slot away from a person. The workflow now also runs when FAME.md changes on master, so that the two files cannot drift apart. --- .github/workflows/fame.yml | 21 +++++-- FAME.md | 92 ++++++++++++++-------------- FAME.svg | 81 +++++++++++++++++++++++++ scripts/fame.py | 119 +++++++++++++++++++++++++++++++++---- 4 files changed, 251 insertions(+), 62 deletions(-) create mode 100644 FAME.svg diff --git a/.github/workflows/fame.yml b/.github/workflows/fame.yml index 6d72c66f5..917cb7407 100644 --- a/.github/workflows/fame.yml +++ b/.github/workflows/fame.yml @@ -1,4 +1,5 @@ -# Refresh FAME.md, the contributor summary generated by scripts/fame.py. +# Refresh FAME.md and FAME.svg, the contributor summary generated by +# scripts/fame.py. # # The script needs the full history (it uses `git blame`), so this checks out # with fetch-depth 0 and it always looks at master, no matter what triggered it. @@ -6,14 +7,22 @@ # quiet week does not produce a pull request that only bumps the date. # # The pull request is created with GITHUB_TOKEN, so CI does not run on it - that -# is fine for a generated markdown file, but keep it in mind if this workflow -# ever starts touching code. +# is fine for generated files, but keep it in mind if this workflow ever starts +# touching code. +# +# Merging such a pull request pushes FAME.md and triggers this workflow again; +# that run regenerates the same files, finds no diff and opens nothing, so it +# just costs a minute of runner time. name: FAME on: schedule: - cron: '17 4 * * 1' # Mondays at 04:17 UTC + push: + branches: [ master ] + paths: + - 'FAME.md' # keep the chart in sync when the table changes workflow_dispatch: # Allow manual trigger concurrency: @@ -51,13 +60,15 @@ jobs: python -m pip install --upgrade pip pip install 'git-fame ~= 3.1' - - name: Regenerate FAME.md + - name: Regenerate FAME.md and FAME.svg run: python scripts/fame.py - name: Create pull request uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - add-paths: FAME.md + add-paths: | + FAME.md + FAME.svg branch: auto/update-fame delete-branch: true commit-message: "FAME.md: update contributor statistics" diff --git a/FAME.md b/FAME.md index b810dc903..2492e98e2 100644 --- a/FAME.md +++ b/FAME.md @@ -1,9 +1,11 @@ # Contributors -370 people have contributed to Borg, with 10,581 commits in total. +370 people have contributed to Borg, with 10,585 commits in total. Thanks to everyone who helped! -Generated on 2026-08-01 by `scripts/fame.py`, which computes the statistics with +![Contributors by commit count](FAME.svg) + +Generated on 2026-08-02 by `scripts/fame.py`, which computes the statistics with [git-fame](https://github.com/casperdcl/git-fame), from the `master` branch only - commits that exist solely on other branches or in unmerged pull requests are not counted. @@ -19,64 +21,64 @@ show up there, so the number understates early contributions. Generated files | Contributor | Commits | Lines | Files | |:---|---:|---:|---:| -| Thomas Waldmann | 7,020 | 73,353 | 451 | -| Marian Beermann | 1,140 | 9,016 | 158 | -| Jonas Borgström | 560 | 1,080 | 38 | -| Antoine Beaupré | 285 | 456 | 35 | +| Thomas Waldmann | 7,024 | 75,119 | 454 | +| Marian Beermann | 1,140 | 8,400 | 157 | +| Jonas Borgström | 560 | 1,029 | 38 | +| Antoine Beaupré | 285 | 441 | 33 | | Mrityunjay Raj | 198 | 7,988 | 102 | -| Andrey Bienkowski | 72 | 243 | 17 | +| Andrey Bienkowski | 72 | 210 | 16 | | Ted Lawson | 71 | 4,606 | 39 | -| Thalian | 63 | 771 | 33 | -| Martin Hostettler | 46 | 2,589 | 10 | -| Milkey Mouse | 42 | 253 | 14 | +| Thalian | 63 | 594 | 32 | +| Martin Hostettler | 46 | 2,559 | 9 | +| Milkey Mouse | 42 | 248 | 14 | | Dan Christensen | 40 | 5 | 2 | | dependabot[bot] | 37 | 46 | 7 | | Rayyan Ansari | 34 | 91 | 12 | | Abdel-Rahman | 30 | 4 | 3 | -| Manuel Riel | 28 | 107 | 7 | -| Nehalenniæ Oudin | 26 | 55 | 11 | +| Manuel Riel | 28 | 94 | 6 | +| Nehalenniæ Oudin | 26 | 49 | 11 | | anarcat | 24 | 34 | 4 | | Hugo Wallenburg | 23 | 1,189 | 13 | -| Michael Hanselmann | 22 | 151 | 3 | +| Michael Hanselmann | 22 | 71 | 3 | | Björn Ketelaars | 17 | 33 | 6 | | SanskritFritz | 17 | 258 | 1 | -| Lee Bousfield | 14 | 169 | 10 | -| Alan Jenkins | 14 | 47 | 2 | +| Lee Bousfield | 14 | 160 | 9 | +| Alan Jenkins | 14 | 38 | 2 | | Robin Schneider | 13 | 19 | 8 | | Daniel Rudolf | 13 | 230 | 14 | -| Jürg Rast | 12 | 78 | 11 | -| Elmar Hoffmann | 12 | 98 | 9 | -| Ronny Pfannschmidt | 12 | 22 | 4 | +| Jürg Rast | 12 | 73 | 11 | +| Elmar Hoffmann | 12 | 90 | 6 | +| Ronny Pfannschmidt | 12 | 20 | 4 | | mh4ckt3mh4ckt1c4s | 11 | 0 | 0 | | Teemu Toivanen | 10 | 16 | 2 | | Lauri Niskanen | 10 | 5 | 2 | | finefoot | 10 | 18 | 3 | -| James Buren | 10 | 10 | 2 | +| James Buren | 10 | 2 | 2 | | Per Guth | 9 | 1 | 1 | | rugk | 9 | 13 | 5 | -| Ed Blackman | 9 | 14 | 3 | +| Ed Blackman | 9 | 13 | 3 | | Carlo Teubner | 9 | 3 | 3 | -| Emmo Emminghaus | 9 | 87 | 11 | +| Emmo Emminghaus | 9 | 80 | 10 | | Josh Soref | 8 | 3 | 3 | | Jakob Schnitzer | 8 | 35 | 1 | | Gianfranco Costamagna | 7 | 10 | 6 | -| Frank Sachsenheim | 7 | 3 | 1 | +| Frank Sachsenheim | 7 | 2 | 1 | | Hartmut Goebel | 7 | 0 | 0 | | Michael Deyaso | 6 | 170 | 14 | | Alf Mikula | 6 | 19 | 1 | | William D. Jones | 6 | 250 | 6 | -| Felix Schwarz | 6 | 5 | 2 | -| Peter Gerber | 6 | 105 | 10 | +| Felix Schwarz | 6 | 4 | 2 | +| Peter Gerber | 6 | 103 | 9 | | Alexander-N | 6 | 0 | 0 | | Paul D | 5 | 185 | 46 | -| Mark Edgington | 5 | 11 | 3 | +| Mark Edgington | 5 | 10 | 3 | | Dominik Stadler | 5 | 77 | 1 | | infectormp | 5 | 18 | 2 | | remyabel | 5 | 25 | 3 | -| Alexander 'Leo' Bergolth | 5 | 26 | 3 | -| Simon Frei | 5 | 213 | 3 | +| Alexander 'Leo' Bergolth | 5 | 24 | 3 | +| Simon Frei | 5 | 174 | 3 | | elandorr | 5 | 40 | 3 | -| step21 | 5 | 2 | 2 | +| step21 | 5 | 1 | 1 | | Radu Ciorba | 5 | 0 | 0 | | Suryansh Pal | 4 | 36 | 9 | | 8bit | 4 | 30 | 5 | @@ -92,7 +94,7 @@ show up there, so the number understates early contributions. Generated files | Artem Sheremet | 4 | 10 | 1 | | Narendra Vardi | 4 | 3 | 1 | | Alexander Pyhalov | 4 | 0 | 0 | -| Will | 3 | 22 | 4 | +| Will | 3 | 21 | 4 | | Lukas Fleischer | 3 | 23 | 2 | | Daniel Reichelt | 3 | 25 | 5 | | Stephen | 3 | 10 | 2 | @@ -106,7 +108,7 @@ show up there, so the number understates early contributions. Generated files | Michael Gajda | 3 | 23 | 2 | | jungle-boogie | 3 | 13 | 1 | | Thomas Harold | 3 | 1 | 1 | -| Gregor Kleen | 3 | 10 | 4 | +| Gregor Kleen | 3 | 8 | 3 | | kmq | 3 | 15 | 1 | | Luke Dashjr | 3 | 4 | 1 | | Divyesh | 3 | 1 | 1 | @@ -134,7 +136,7 @@ show up there, so the number understates early contributions. Generated files | Ioannis Cherouvim | 2 | 32 | 2 | | AJ Jordan | 2 | 2 | 1 | | Markus Engelbrecht | 2 | 3 | 1 | -| Thomas Portmann | 2 | 257 | 5 | +| Thomas Portmann | 2 | 206 | 5 | | Leo Famulari | 2 | 1 | 1 | | Fabian Fröhlich | 2 | 4 | 1 | | Andreas Gruhler | 2 | 3 | 1 | @@ -143,7 +145,7 @@ show up there, so the number understates early contributions. Generated files | Kurt Yoder | 2 | 8 | 1 | | Jonas Schäfer | 2 | 13 | 1 | | a1346054 | 2 | 4 | 3 | -| Sophie Herold | 2 | 3 | 2 | +| Sophie Herold | 2 | 2 | 2 | | ReethuVinta | 2 | 20 | 2 | | textshell | 2 | 1 | 1 | | Saurav Sachidanand | 2 | 2 | 1 | @@ -153,7 +155,7 @@ show up there, so the number understates early contributions. Generated files | Matthew Glazar | 2 | 18 | 3 | | Max-Julian Pogner | 2 | 15 | 1 | | Atharva Varpe | 2 | 23 | 1 | -| Lapinot | 2 | 30 | 4 | +| Lapinot | 2 | 24 | 4 | | Soumik Dutta | 2 | 85 | 3 | | Eric Wolf | 2 | 51 | 4 | | snsmac | 2 | 6 | 2 | @@ -192,9 +194,9 @@ show up there, so the number understates early contributions. Generated files | Daniel Dent | 1 | 2 | 1 | | Lars K.W. Gohlke | 1 | 1 | 1 | | Kevin Yin | 1 | 3 | 1 | -| Benedikt Seidl | 1 | 117 | 2 | | Eddie Carswell | 1 | 4 | 1 | | vancheese | 1 | 28 | 8 | +| Benedikt Seidl | 1 | 116 | 1 | | Michael Bauer | 1 | 2 | 1 | | Aleksey Korol | 1 | 95 | 1 | | Coenraad Loubser | 1 | 1 | 1 | @@ -236,7 +238,7 @@ show up there, so the number understates early contributions. Generated files | Stefan Majewsky | 1 | 1 | 1 | | Atemu | 1 | 11 | 2 | | Dominik Tugend | 1 | 3 | 1 | -| Reiko Asakura | 1 | 12 | 2 | +| Reiko Asakura | 1 | 11 | 2 | | Mher Kazandjian | 1 | 6 | 1 | | Mike | 1 | 23 | 1 | | Ceesjan Luiten | 1 | 1 | 1 | @@ -247,9 +249,8 @@ show up there, so the number understates early contributions. Generated files | Jim Paris | 1 | 2 | 1 | | vhadzhiev | 1 | 2 | 1 | | Tarrailt | 1 | 371 | 8 | -| Romain Vimont | 1 | 4 | 3 | -| Sam H | 1 | 27 | 2 | -| Leo Antunes | 1 | 8 | 1 | +| Sam H | 1 | 24 | 2 | +| Leo Antunes | 1 | 5 | 1 | | Matt Van Horn | 1 | 1 | 1 | | Mike Mason | 1 | 38 | 3 | | edvatar | 1 | 16 | 2 | @@ -258,18 +259,16 @@ show up there, so the number understates early contributions. Generated files | nyuszika7h | 1 | 3 | 1 | | Divyansh Singh | 1 | 1 | 1 | | Phil Kulin | 1 | 4 | 1 | -| Félix Sipma | 1 | 14 | 1 | -| Graham Stockton | 1 | 13 | 1 | +| Félix Sipma | 1 | 13 | 1 | +| Graham Stockton | 1 | 7 | 1 | | Peter Zangerl | 1 | 1 | 1 | | lexa-a | 1 | 31 | 1 | | Nikolaus Rath | 1 | 7 | 1 | -| axapaxa | 1 | 117 | 2 | -| Swanand01 | 1 | 1 | 1 | +| axapaxa | 1 | 105 | 2 | | Abhijeet | 1 | 1 | 1 | -| Dark Dragon | 1 | 7 | 1 | -| Emil M George | 1 | 2 | 1 | +| Dark Dragon | 1 | 6 | 1 | +| Emil M George | 1 | 1 | 1 | | Hauke Rehfeld | 1 | 1 | 1 | -| Henry-Joseph Audéoud | 1 | 1 | 1 | | Ivan Shapovalov | 1 | 1 | 1 | | Zhou Qiankang | 1 | 3 | 1 | | Adam Kouse | 1 | 0 | 0 | @@ -304,6 +303,7 @@ show up there, so the number understates early contributions. Generated files | Flachz | 1 | 0 | 0 | | Florian Klink | 1 | 0 | 0 | | Hans-Peter Jansen | 1 | 0 | 0 | +| Henry-Joseph Audéoud | 1 | 0 | 0 | | Jakub Wilk | 1 | 0 | 0 | | Jan | 1 | 0 | 0 | | Jens Rantil | 1 | 0 | 0 | @@ -345,9 +345,11 @@ show up there, so the number understates early contributions. Generated files | RR | 1 | 0 | 0 | | Ricardo M. Correia | 1 | 0 | 0 | | Roberto Polli | 1 | 0 | 0 | +| Romain Vimont | 1 | 0 | 0 | | Ruben Rodriguez | 1 | 0 | 0 | | Sebastiaan Lokhorst | 1 | 0 | 0 | | Serghei Iakovlev | 1 | 0 | 0 | +| Swanand01 | 1 | 0 | 0 | | Thiago Macieira | 1 | 0 | 0 | | Thomas Deutschmann | 1 | 0 | 0 | | Thomas Schwinge | 1 | 0 | 0 | diff --git a/FAME.svg b/FAME.svg new file mode 100644 index 000000000..08a108e82 --- /dev/null +++ b/FAME.svg @@ -0,0 +1,81 @@ + + + +Borg contributors +top 20 of 369 by commits on the master branch +Thomas Waldmann + +7,024 +Marian Beermann + +1,140 +Jonas Borgström + +560 +Antoine Beaupré + +285 +Mrityunjay Raj + +198 +Andrey Bienkowski + +72 +Ted Lawson + +71 +Thalian + +63 +Martin Hostettler + +46 +Milkey Mouse + +42 +Dan Christensen + +40 +Rayyan Ansari + +34 +Abdel-Rahman + +30 +Manuel Riel + +28 +Nehalenniæ Oudin + +26 +anarcat + +24 +Hugo Wallenburg + +23 +Michael Hanselmann + +22 +Björn Ketelaars + +17 +SanskritFritz + +17 +generated by scripts/fame.py using git-fame + diff --git a/scripts/fame.py b/scripts/fame.py index 3a18a2b91..e7ffa282c 100644 --- a/scripts/fame.py +++ b/scripts/fame.py @@ -1,7 +1,13 @@ #!/usr/bin/env python3 -"""Regenerate FAME.md, a contributor summary produced by git-fame. +"""Regenerate FAME.md and FAME.svg, a contributor summary produced by git-fame. -Usage: python scripts/fame.py [--top N] +FAME.md is the full table, FAME.svg a bar chart of the busiest contributors that +FAME.md embeds. The chart is drawn here rather than taken from git-fame's own +`--format=svg`, which renders the whole table as monospaced text - fine for a +handful of contributors, unreadable for a few hundred. It leaves out bots, +which do appear in the table. + +Usage: python scripts/fame.py [--top N] [--svg-top N] Requires `git-fame` (pip install git-fame) and a full (non-shallow) clone, because the line counts come from `git blame` over the whole history. @@ -17,9 +23,9 @@ later refactored away, so it is not what we rank by. Author identities are merged via .mailmap, so fix duplicates there rather than here. -FAME.md is left alone if only the generation date would change, so that running -this regularly (see .github/workflows/fame.yml) does not produce a diff for a -week in which the statistics did not move. +Both files are left alone if nothing but the generation date would change, so +that running this regularly (see .github/workflows/fame.yml) does not produce a +diff for a week in which the statistics did not move. """ import argparse @@ -28,11 +34,18 @@ import re import subprocess from datetime import date from pathlib import Path +from xml.sax.saxutils import escape BRANCH = "master" DATE_LINE = re.compile(r"^Generated on \d{4}-\d{2}-\d{2} ", re.MULTILINE) +# Bots keep their row in the table - they did make those commits - but they +# would take a slot away from a person in the chart, which only has room for a +# few. GitHub appends "[bot]" to the account name of a bot, e.g. +# "dependabot[bot]"; matching that is safer than guessing from the name. +BOT = re.compile(r"\[bot\]$") + # Generated and binary-ish files would drown out hand-written code. Excalidraw # scenes are the worst offenders: they are pretty-printed JSON, so a single # diagram of a few boxes and arrows is over 10000 lines. Image formats are @@ -48,6 +61,8 @@ PREAMBLE = """\ {count} people have contributed to Borg, with {commits} commits in total. Thanks to everyone who helped! +![Contributors by commit count](FAME.svg) + Generated on {date} by `scripts/fame.py`, which computes the statistics with [git-fame](https://github.com/casperdcl/git-fame), from the `{branch}` branch only - commits that exist solely on other branches or in unmerged pull requests @@ -65,6 +80,39 @@ show up there, so the number understates early contributions. Generated files """ +# Chart geometry, in user units. The viewport is given in user units plus a +# viewBox rather than in em, because em on the root resolves against the +# font size that element inherits, not against the font size of the text inside. +SVG_TOP = 20 # a chart of all 300+ contributors would be unreadable +SVG_WIDTH = 760 +SVG_ROW_H = 22 +SVG_PAD = 14 +SVG_LABEL_W = 240 # x where the bars start, i.e. width of the name column +SVG_COUNT_W = 70 # right hand column holding the commit count +SVG_HEAD_H = 58 +SVG_FOOT_H = 26 +SVG_NAME_MAX = 32 # characters that fit into the name column + +# Fills are set per class so that the chart can follow the reader's colour +# scheme; GitHub renders the file in an , where the media query applies. +SVG_STYLE = """\ +""" + + def collect(): """Run git-fame and return (rows, total), all contributors, most commits first.""" out = subprocess.check_output( @@ -91,28 +139,75 @@ def render(rows, total, contributors): return preamble + "\n".join(table) + "\n" +def shorten(name): + """Truncate `name` to what fits into the name column of the chart.""" + return name if len(name) <= SVG_NAME_MAX else name[: SVG_NAME_MAX - 1] + "…" + + +def render_svg(rows, contributors): + """Return an SVG bar chart of `rows`, the busiest out of `contributors` people.""" + height = SVG_HEAD_H + len(rows) * SVG_ROW_H + SVG_FOOT_H + bars = SVG_WIDTH - SVG_LABEL_W - SVG_COUNT_W - SVG_PAD + most_commits = rows[0][1] + out = [ + f'', + SVG_STYLE, + f'', + f'Borg contributors', + f'' + f"top {len(rows)} of {contributors:,} by commits on the {BRANCH} branch", + ] + for i, (name, commits, _loc, _files) in enumerate(rows): + y = SVG_HEAD_H + i * SVG_ROW_H + baseline = y + int(SVG_ROW_H * 0.7) + # the scale is linear, but a bar should stay visible however short it is + width = max(2, round(bars * commits / most_commits)) + # names are commit-controlled input, so & and < would break the XML + out.append(f'{escape(shorten(name))}') + out.append( + f'' + ) + out.append(f'{commits:,}') + out.append( + f'generated by scripts/fame.py using git-fame' + ) + out.append("") + return "\n".join(out) + "\n" + + def same_statistics(old, new): """Return True if the two FAME.md contents differ only in the generation date.""" return DATE_LINE.sub("Generated on ", old) == DATE_LINE.sub("Generated on ", new) +def update(path, new, unchanged=str.__eq__): + """Write `new` to `path` unless `unchanged` says it is already there.""" + if path.exists() and unchanged(path.read_text(encoding="utf-8"), new): + print(f"{path} is up to date") + return + path.write_text(new, encoding="utf-8") + print(f"wrote {path}") + + def main(): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--top", type=int, default=0, metavar="N", help="only list the top N contributors") + parser.add_argument( + "--svg-top", type=int, default=SVG_TOP, metavar="N", help="chart the top N contributors [default: %(default)s]" + ) args = parser.parse_args() repo = Path(subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()) rows, total = collect() contributors = len(rows) + people = [row for row in rows if not BOT.search(row[0])] if args.top: rows = rows[: args.top] - output = repo / "FAME.md" - new = render(rows, total, contributors) - if output.exists() and same_statistics(output.read_text(encoding="utf-8"), new): - print(f"{output} is up to date ({len(rows)} of {contributors} contributors)") - return - output.write_text(new, encoding="utf-8") - print(f"wrote {output} ({len(rows)} of {contributors} contributors)") + print(f"{len(rows)} of {contributors} contributors, {contributors - len(people)} of them bots") + update(repo / "FAME.md", render(rows, total, contributors), unchanged=same_statistics) + update(repo / "FAME.svg", render_svg(people[: args.svg_top], len(people))) if __name__ == "__main__":