One git heatmap to rule them all
9 Apr 26
GitHub’s contribution heatmap gives me mixed feelings. A year of work, reduced to a grid of green squares. But if your work is spread across multiple git forges—as mine is, between GitHub and two self-hosted GitLabs at ANU (one for teaching, one for research, though the boundary is a bit blurry)—then no single profile page tells the whole story. My GitHub heatmap has gaps that aren’t actually gaps; they’re just weeks where the commits landed somewhere else.
Mostly I was just curious as to what my developer history since I joined GitHub in 2010 towards the end of my PhD looked like. So (Claude and) I wrote a script to find out. It pulls contribution data from all three forges, merges it, and renders a single self-contained SVG that covers my entire git history. Here’s what it looks like:
Accurate as of 2026-04-09
This isn’t a live visualisation—the SVG is a static snapshot generated by running the script below. It includes data up to the date it was last run.
The whole thing is a single Python file—about 900 lines, with
httpx as the only dependency. It uses
uv’s inline script metadata
so you can run it with uv run contribution_heatmap.py without setting up a
virtual environment. GitHub data comes via the
GraphQL contributions API,
which gives you a nice per-day breakdown by year. GitLab is more work—the
events API only retains a year or two of history on most self-hosted instances,
so the script scans all your member projects and queries their commit history
directly via the
repository commits endpoint.
The tiles are week-aggregated rather than daily, because 16 years of daily tiles would produce an unreadably wide image. Each year gets its own row with 53 columns, which keeps things compact while still letting you spot seasonal patterns.
The colour scaling uses global quantile normalisation—non-zero weeks are split into quartiles, so a few massive weeks don’t wash out everything else. The palette matches GitHub’s dark theme, because this site is dark-mode only and I didn’t fancy debugging a light-mode variant.
The SVG is fully self-contained: inline CSS, inline JavaScript for the hover
popovers, no external dependencies at view time. Hover over any tile and you get
a breakdown by source, a day-of-week mini bar chart for that week, and the top
event types. It’s all embedded in foreignObject elements, which is one of
those SVG features that feels slightly transgressive but works well in
practice.1
Caching is simple but effective. For GitHub, past years get saved as JSON files
and only the current (incomplete) year gets refetched. For GitLab, commits are
cached per project and only rescanned when a project’s last_activity_at
changes. The initial run takes a few minutes (scanning 1,000+ projects), but
subsequent runs are near-instant.
Configuration is all via environment variables—GITHUB_USER, GITHUB_TOKEN,
GITLAB1_URL, GITLAB1_USER, GITLAB1_TOKEN, and so on for a second GitLab
instance. There’s a --dry-run flag that shows what would be fetched without
actually hitting any APIs, which was helpful while getting the request counts
right.
#The full script
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx"]
# ///
"""Generate a multi-source contribution heatmap as a self-contained interactive SVG."""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from pathlib import Path
import httpx # ty: ignore[unresolved-import]
log = logging.getLogger(__name__)
# ── Constants ──────────────────────────────────────────────────────────────────
TILE = 14
GAP = 2
CELL = TILE + GAP
LEFT_MARGIN = 54
TOP_MARGIN = 72
RIGHT_MARGIN = 180
BOTTOM_MARGIN = 44
MAX_WEEKS = 53
PALETTE = ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"]
SOURCE_COLORS = {"github": "#6e5494", "gitlab1": "#fc6d26", "gitlab2": "#1f9e8e"}
SOURCE_LABELS = {"github": "GitHub", "gitlab1": "Teaching GitLab", "gitlab2": "Research GitLab"}
POPOVER_LABELS = {"github": "GitHub", "gitlab1": "Teaching", "gitlab2": "Research"}
TEXT_COLOR = "#c9d1d9"
MUTED_COLOR = "#8b949e"
BG_COLOR = "#0d1117"
POPOVER_BG = "#1b1f23"
POPOVER_BORDER = "#30363d"
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
MAX_RETRIES = 3
RETRY_DELAYS = [1, 2, 4]
GITLAB_REQUEST_DELAY = 0.1
# ── Data types ─────────────────────────────────────────────────────────────────
@dataclass
class DayData:
count: int = 0
event_types: dict[str, int] = field(default_factory=dict)
@dataclass
class WeekData:
week_start: date
total: int = 0
by_source: dict[str, int] = field(default_factory=dict)
by_day: list[int] = field(default_factory=lambda: [0] * 7)
top_event_types: list[tuple[str, int]] = field(default_factory=list)
@dataclass
class Stats:
total: int = 0
longest_streak: int = 0
current_streak: int = 0
most_active_week: tuple[date, int] = field(default_factory=lambda: (date.today(), 0))
most_active_dow: str = "Monday"
per_year: dict[int, dict[str, int]] = field(default_factory=dict)
sources_available: list[str] = field(default_factory=list)
sources_failed: list[str] = field(default_factory=list)
# ── HTTP helpers ───────────────────────────────────────────────────────────────
def _request(client: httpx.Client, method: str, url: str, **kwargs) -> httpx.Response:
for attempt in range(MAX_RETRIES):
try:
resp = client.request(method, url, **kwargs)
if resp.status_code == 429:
delay = RETRY_DELAYS[min(attempt, len(RETRY_DELAYS) - 1)]
log.warning("Rate limited on %s, sleeping %ds", url, delay)
time.sleep(delay)
continue
resp.raise_for_status()
return resp
except httpx.TransportError as e:
if attempt == MAX_RETRIES - 1:
raise
delay = RETRY_DELAYS[min(attempt, len(RETRY_DELAYS) - 1)]
log.warning("Request failed (%s), retrying in %ds", e, delay)
time.sleep(delay)
raise RuntimeError(f"Failed after {MAX_RETRIES} retries: {method} {url}")
def _graphql(client: httpx.Client, query: str, variables: dict) -> dict:
resp = _request(client, "POST", "https://api.github.com/graphql",
json={"query": query, "variables": variables})
data = resp.json()
if "errors" in data:
raise RuntimeError(f"GraphQL errors: {data['errors']}")
rate = data.get("data", {}).get("rateLimit", {})
if rate and rate.get("remaining", 100) < 10:
reset_at = datetime.fromisoformat(rate["resetAt"].replace("Z", "+00:00"))
wait = max(0, (reset_at - datetime.now(reset_at.tzinfo)).total_seconds())
if wait > 0:
log.info("GitHub rate limit low (%d remaining), sleeping %.0fs",
rate["remaining"], wait)
time.sleep(wait)
return data
# ── GitHub fetcher ─────────────────────────────────────────────────────────────
GITHUB_CREATED_QUERY = """
query($login: String!) {
rateLimit { remaining resetAt }
user(login: $login) { createdAt }
}"""
GITHUB_CONTRIB_QUERY = """
query($login: String!, $from: DateTime!, $to: DateTime!) {
rateLimit { remaining resetAt }
user(login: $login) {
contributionsCollection(from: $from, to: $to) {
contributionCalendar {
weeks {
contributionDays {
date
contributionCount
}
}
}
}
}
}"""
def fetch_github(user: str, token: str, start_year: int | None,
end_date: date, cache_dir: Path, no_cache: bool) -> tuple[dict[date, DayData], int]:
client = httpx.Client(
headers={"Authorization": f"bearer {token}", "Content-Type": "application/json"},
timeout=30,
)
try:
resp = _graphql(client, GITHUB_CREATED_QUERY, {"login": user})
created = datetime.fromisoformat(
resp["data"]["user"]["createdAt"].replace("Z", "+00:00"))
join_year = created.year
if start_year is None:
start_year = join_year
result: dict[date, DayData] = {}
for year in range(start_year, end_date.year + 1):
cache_file = cache_dir / f"github_{year}.json"
if not no_cache and year < end_date.year and cache_file.exists():
log.info("GitHub %d: using cache", year)
cal = json.loads(cache_file.read_text())
else:
log.info("GitHub %d: fetching", year)
from_dt = f"{year}-01-01T00:00:00Z"
to_year = year + 1 if year < end_date.year else end_date.year
to_month = 1 if year < end_date.year else end_date.month
to_day = 1 if year < end_date.year else end_date.day
to_dt = f"{to_year}-{to_month:02d}-{to_day:02d}T00:00:00Z"
if year == end_date.year:
to_dt = f"{(end_date + timedelta(days=1)).isoformat()}T00:00:00Z"
data = _graphql(client, GITHUB_CONTRIB_QUERY,
{"login": user, "from": from_dt, "to": to_dt})
cal = data["data"]["user"]["contributionsCollection"]["contributionCalendar"]
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(cal))
for week in cal["weeks"]:
for day in week["contributionDays"]:
d = date.fromisoformat(day["date"])
if d <= end_date:
result[d] = DayData(count=day["contributionCount"])
return result, start_year
finally:
client.close()
# ── GitLab fetcher ─────────────────────────────────────────────────────────────
def _list_gitlab_projects(client: httpx.Client,
base_url: str) -> list[dict]:
projects: list[dict] = []
page = 1
while True:
resp = _request(client, "GET", f"{base_url}/api/v4/projects",
params={"membership": "true", "per_page": 100,
"page": page, "simple": "true"})
batch = resp.json()
if not batch:
break
projects.extend(batch)
if len(batch) < 100:
break
page += 1
time.sleep(GITLAB_REQUEST_DELAY)
return projects
def _fetch_project_user_commits(client: httpx.Client, base_url: str,
proj_id: int,
author_emails: list[str]) -> list[str]:
seen: set[str] = set()
dates: list[str] = []
for email in author_emails:
page = 1
while True:
try:
resp = _request(client, "GET",
f"{base_url}/api/v4/projects/{proj_id}/repository/commits",
params={"author": email, "per_page": 100, "page": page})
except Exception:
break
batch = resp.json()
if not isinstance(batch, list) or not batch:
break
for c in batch:
sha = c.get("id", "")
if sha and sha not in seen:
seen.add(sha)
dates.append(c["created_at"][:10])
if len(batch) < 100:
break
page += 1
time.sleep(GITLAB_REQUEST_DELAY)
return dates
def fetch_gitlab(base_url: str, username: str, token: str,
author_emails: list[str], start_year: int | None,
end_date: date, cache_dir: Path, no_cache: bool,
source_name: str) -> tuple[dict[date, DayData], int]:
base_url = base_url.rstrip("/")
client = httpx.Client(headers={"PRIVATE-TOKEN": token}, timeout=30)
try:
projects = _list_gitlab_projects(client, base_url)
log.info("%s: %d member projects to scan", source_name, len(projects))
cache_file = cache_dir / f"{source_name}_project_commits.json"
cached: dict[str, dict] = {}
if not no_cache and cache_file.exists():
cached = json.loads(cache_file.read_text())
result: dict[date, DayData] = {}
scanned = 0
for i, proj in enumerate(projects):
proj_id = str(proj["id"])
proj_path = proj.get("path_with_namespace", proj_id)
last_activity = proj.get("last_activity_at", "")
if proj_id in cached and not no_cache:
if cached[proj_id].get("last_activity") == last_activity:
for d_str in cached[proj_id].get("dates", []):
_add_commit_date(result, d_str, start_year, end_date)
continue
commit_dates = _fetch_project_user_commits(
client, base_url, proj["id"], author_emails)
scanned += 1
cached[proj_id] = {
"last_activity": last_activity,
"path": proj_path,
"dates": commit_dates,
}
for d_str in commit_dates:
_add_commit_date(result, d_str, start_year, end_date)
if commit_dates:
log.info("%s: %s — %d commits", source_name, proj_path,
len(commit_dates))
if scanned % 100 == 0:
log.info("%s: scanned %d/%d projects…",
source_name, i + 1, len(projects))
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(cached))
log.info("%s: done — scanned %d projects (%d from cache)",
source_name, scanned, len(projects) - scanned)
if result and start_year is None:
start_year = min(d.year for d in result)
if start_year is None:
start_year = end_date.year
return result, start_year
finally:
client.close()
def _add_commit_date(result: dict[date, DayData], d_str: str,
start_year: int | None, end_date: date) -> None:
try:
d = date.fromisoformat(d_str)
except ValueError:
return
if d > end_date:
return
if start_year and d.year < start_year:
return
if d not in result:
result[d] = DayData()
result[d].count += 1
result[d].event_types["commits"] = result[d].event_types.get("commits", 0) + 1
# ── Data processing ────────────────────────────────────────────────────────────
def _week_start(d: date) -> date:
return d - timedelta(days=d.weekday())
def _year_week_mondays(year: int) -> list[date]:
"""All week-start Mondays whose Thursday falls in the given calendar year."""
jan1 = date(year, 1, 1)
monday = jan1 - timedelta(days=jan1.weekday())
if (monday + timedelta(days=3)).year < year:
monday += timedelta(weeks=1)
weeks = []
while (monday + timedelta(days=3)).year == year:
weeks.append(monday)
monday += timedelta(weeks=1)
return weeks
def merge_sources(sources: dict[str, dict[date, DayData]]) -> dict[date, dict[str, DayData]]:
daily: dict[date, dict[str, DayData]] = {}
for src, days in sources.items():
for d, dd in days.items():
if d not in daily:
daily[d] = {}
daily[d][src] = dd
return daily
def aggregate_weeks(daily: dict[date, dict[str, DayData]],
start_year: int, end_date: date
) -> dict[int, list[tuple[date, WeekData]]]:
flat: dict[date, WeekData] = {}
for d, sources in daily.items():
ws = _week_start(d)
if ws not in flat:
flat[ws] = WeekData(week_start=ws)
wd = flat[ws]
dow = d.weekday()
for src, dd in sources.items():
wd.total += dd.count
wd.by_source[src] = wd.by_source.get(src, 0) + dd.count
wd.by_day[dow] += dd.count
all_event_types: dict[date, dict[str, int]] = {}
for d, sources in daily.items():
ws = _week_start(d)
if ws not in all_event_types:
all_event_types[ws] = {}
for dd in sources.values():
for et, cnt in dd.event_types.items():
all_event_types[ws][et] = all_event_types[ws].get(et, 0) + cnt
for ws, ets in all_event_types.items():
if ws in flat:
flat[ws].top_event_types = sorted(ets.items(), key=lambda x: -x[1])[:3]
by_year: dict[int, list[tuple[date, WeekData]]] = {}
for year in range(start_year, end_date.year + 1):
mondays = _year_week_mondays(year)
by_year[year] = [(m, flat.get(m, WeekData(week_start=m))) for m in mondays]
return by_year
# ── Statistics ─────────────────────────────────────────────────────────────────
def compute_stats(daily: dict[date, dict[str, DayData]],
weeks_by_year: dict[int, list[tuple[date, WeekData]]],
end_date: date,
sources_ok: list[str],
sources_fail: list[str]) -> Stats:
stats = Stats(sources_available=sources_ok, sources_failed=sources_fail)
if not daily:
return stats
all_dates = sorted(daily.keys())
start = all_dates[0]
stats.total = sum(dd.count for sources in daily.values() for dd in sources.values())
longest = current = 0
d = start
while d <= end_date:
day_total = sum(dd.count for dd in daily.get(d, {}).values())
if day_total > 0:
current += 1
longest = max(longest, current)
else:
current = 0
d += timedelta(days=1)
stats.longest_streak = longest
stats.current_streak = current
dow_totals = [0] * 7
for d, sources in daily.items():
day_total = sum(dd.count for dd in sources.values())
dow_totals[d.weekday()] += day_total
stats.most_active_dow = DAYS[dow_totals.index(max(dow_totals))]
best_week = date.today()
best_count = 0
for year_weeks in weeks_by_year.values():
for monday, wd in year_weeks:
if wd.total > best_count:
best_count = wd.total
best_week = monday
stats.most_active_week = (best_week, best_count)
for year, year_weeks in weeks_by_year.items():
year_by_source: dict[str, int] = {}
for _, wd in year_weeks:
for src, cnt in wd.by_source.items():
year_by_source[src] = year_by_source.get(src, 0) + cnt
year_by_source["_total"] = sum(year_by_source.values())
stats.per_year[year] = year_by_source
return stats
# ── Color levels ───────────────────────────────────────────────────────────────
def _compute_thresholds(values: list[int]) -> list[int]:
non_zero = sorted(v for v in values if v > 0)
if not non_zero:
return [1, 1, 1]
n = len(non_zero)
return [
non_zero[max(0, n * 1 // 4 - 1)],
non_zero[max(0, n * 2 // 4 - 1)],
non_zero[max(0, n * 3 // 4 - 1)],
]
def _level(value: int, thresholds: list[int]) -> int:
if value == 0:
return 0
if value <= thresholds[0]:
return 1
if value <= thresholds[1]:
return 2
if value <= thresholds[2]:
return 3
return 4
# ── SVG builder ────────────────────────────────────────────────────────────────
def _fmt_date(d: date) -> str:
return f"{d.day} {MONTHS[d.month - 1]} {d.year}"
def _fmt_date_short(d: date) -> str:
return f"{d.day} {MONTHS[d.month - 1]}"
def build_svg(weeks_by_year: dict[int, list[tuple[date, WeekData]]],
stats: Stats) -> str:
years = sorted(weeks_by_year.keys())
num_years = len(years)
grid_w = MAX_WEEKS * CELL
grid_h = num_years * CELL
svg_w = LEFT_MARGIN + grid_w + RIGHT_MARGIN
svg_h = TOP_MARGIN + grid_h + BOTTOM_MARGIN
global_totals = [wd.total for yws in weeks_by_year.values() for _, wd in yws]
global_thresh = _compute_thresholds(global_totals)
active_sources = stats.sources_available
parts: list[str] = []
parts.append(f'<svg xmlns="http://www.w3.org/2000/svg" '
f'viewBox="0 0 {svg_w} {svg_h}" '
f'width="100%">')
# Background
parts.append(f'<rect width="{svg_w}" height="{svg_h}" fill="{BG_COLOR}" rx="6"/>')
# CSS
parts.append('<style><![CDATA[')
parts.append(f'text {{ fill: {TEXT_COLOR}; '
f'font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}')
parts.append(f'.year-label {{ font-size: 11px; fill: {MUTED_COLOR}; }}')
parts.append(f'.month-label {{ font-size: 10px; fill: {MUTED_COLOR}; }}')
parts.append(f'.stat-text {{ font-size: 11px; fill: {MUTED_COLOR}; }}')
parts.append(f'.title {{ font-size: 14px; font-weight: 600; fill: {TEXT_COLOR}; }}')
parts.append(f'.year-total {{ font-size: 10px; fill: {MUTED_COLOR}; text-anchor: end; }}')
parts.append(f'.tile {{ rx: 2; ry: 2; }}')
parts.append(']]></style>')
# Title
source_names = " + ".join(SOURCE_LABELS[s] for s in active_sources)
if stats.sources_failed:
source_names += " (" + ", ".join(
f"{SOURCE_LABELS.get(s, s)} unavailable" for s in stats.sources_failed) + ")"
parts.append(f'<text x="{LEFT_MARGIN}" y="20" class="title">'
f'Contributions across {source_names} \u00b7 {years[0]}\u2013{years[-1]}</text>')
# Stats strip
maw_date = _fmt_date_short(stats.most_active_week[0])
stat_line = (f"Total: {stats.total:,} \u00b7 "
f"Longest streak: {stats.longest_streak:,} days \u00b7 "
f"Most active week: {maw_date} ({stats.most_active_week[1]:,}) \u00b7 "
f"Most active day: {stats.most_active_dow}")
parts.append(f'<text x="{LEFT_MARGIN}" y="38" class="stat-text">{stat_line}</text>')
# Month labels (based on last year)
last_year = years[-1]
last_mondays = [m for m, _ in weeks_by_year[last_year]]
for month_idx in range(12):
first_of_month = date(last_year, month_idx + 1, 1)
ws = _week_start(first_of_month)
if ws in last_mondays:
col = last_mondays.index(ws)
else:
closest = min(last_mondays, key=lambda m: abs((m - ws).days))
col = last_mondays.index(closest)
x = LEFT_MARGIN + col * CELL
parts.append(f'<text x="{x}" y="{TOP_MARGIN - 6}" class="month-label">'
f'{MONTHS[month_idx]}</text>')
# Tile grid + year labels + sidebar
js_data: dict[str, dict] = {}
for row, year in enumerate(years):
y_base = TOP_MARGIN + row * CELL
# Year label
parts.append(f'<text x="{LEFT_MARGIN - 6}" y="{y_base + TILE - 2}" '
f'class="year-label" text-anchor="end">{year}</text>')
year_weeks = weeks_by_year[year]
for col, (monday, wd) in enumerate(year_weeks):
lv = _level(wd.total, global_thresh)
x = LEFT_MARGIN + col * CELL
y = y_base
tile_key = f"{year}-{col}"
# Fallback title
source_parts = []
for s in active_sources:
v = wd.by_source.get(s, 0)
short = s.upper().replace("GITLAB", "GL").replace("GITHUB", "GH")
source_parts.append(f"{short}:{v}")
title_text = (f"Week of {_fmt_date(monday)} \u00b7 "
f"{wd.total} contributions ({' '.join(source_parts)})")
parts.append(
f'<rect class="tile" id="t-{tile_key}" data-k="{tile_key}" '
f'x="{x}" y="{y}" width="{TILE}" height="{TILE}" '
f'fill="{PALETTE[lv]}">'
f'<title>{title_text}</title></rect>')
# JS data for popover
js_entry: dict = {
"t": wd.total,
"s": {s: wd.by_source.get(s, 0) for s in active_sources},
"d": wd.by_day,
"l": f"Week of {_fmt_date(monday)}",
}
js_data[tile_key] = js_entry
# Year total + source bar (right sidebar)
year_total = stats.per_year.get(year, {}).get("_total", 0)
sidebar_x = LEFT_MARGIN + MAX_WEEKS * CELL + 10
parts.append(f'<text x="{sidebar_x + 40}" y="{y_base + TILE - 2}" '
f'class="year-total">{year_total:,}</text>')
# Stacked source bar
bar_x = sidebar_x + 48
bar_w = 100
bar_h = 8
bar_y = y_base + (TILE - bar_h) // 2
parts.append(f'<rect x="{bar_x}" y="{bar_y}" width="{bar_w}" '
f'height="{bar_h}" fill="#21262d" rx="2"/>')
if year_total > 0:
offset = 0
for s in active_sources:
s_count = stats.per_year.get(year, {}).get(s, 0)
if s_count == 0:
continue
w = max(1, round(s_count / year_total * bar_w))
w = min(w, bar_w - offset)
parts.append(f'<rect x="{bar_x + offset}" y="{bar_y}" width="{w}" '
f'height="{bar_h}" fill="{SOURCE_COLORS[s]}" rx="2"/>')
offset += w
# Legend
legend_y = TOP_MARGIN + num_years * CELL + 16
legend_x = LEFT_MARGIN
parts.append(f'<text x="{legend_x}" y="{legend_y + 10}" '
f'style="font-size:10px;fill:{MUTED_COLOR}">Less</text>')
for i, color in enumerate(PALETTE):
bx = legend_x + 30 + i * (TILE + 2)
parts.append(f'<rect x="{bx}" y="{legend_y}" width="{TILE}" '
f'height="{TILE}" fill="{color}" rx="2"/>')
parts.append(f'<text x="{legend_x + 30 + 5 * (TILE + 2) + 4}" y="{legend_y + 10}" '
f'style="font-size:10px;fill:{MUTED_COLOR}">More</text>')
# Source legend
src_legend_x = legend_x + 160
for i, s in enumerate(active_sources):
sx = src_legend_x + i * 90
parts.append(f'<rect x="{sx}" y="{legend_y + 2}" width="10" height="10" '
f'fill="{SOURCE_COLORS[s]}" rx="2"/>')
parts.append(f'<text x="{sx + 14}" y="{legend_y + 10}" '
f'style="font-size:10px;fill:{MUTED_COLOR}">{SOURCE_LABELS[s]}</text>')
# Popover foreignObject
parts.append(
f'<foreignObject id="popover" x="0" y="0" width="280" height="320" display="none">'
f'<div xmlns="http://www.w3.org/1999/xhtml" id="popover-content" '
f'style="background:{POPOVER_BG};color:{TEXT_COLOR};padding:10px 12px;'
f'border-radius:6px;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',sans-serif;'
f'font-size:11px;line-height:1.5;border:1px solid {POPOVER_BORDER};'
f'box-shadow:0 4px 12px rgba(0,0,0,0.5);">'
f'</div>'
f'</foreignObject>')
# JS
sources_js = json.dumps([[s, POPOVER_LABELS[s], SOURCE_COLORS[s]]
for s in active_sources])
data_js = json.dumps(js_data, separators=(",", ":"))
js = _build_js(data_js, sources_js, svg_w, svg_h, CELL)
parts.append(f'<script type="text/ecmascript"><![CDATA[\n{js}\n]]></script>')
parts.append('</svg>')
return "\n".join(parts)
def _build_js(data_js: str, sources_js: str,
svg_w: int, svg_h: int, cell: int) -> str:
return f"""
var W={data_js};
var S={sources_js};
var SVG_W={svg_w},SVG_H={svg_h},CELL={cell};
var fo=document.getElementById('popover');
var pc=document.getElementById('popover-content');
document.querySelectorAll('.tile').forEach(function(el){{
el.addEventListener('mouseenter',show);
el.addEventListener('mouseleave',hide);
}});
function show(evt){{
var k=evt.target.dataset.k;
if(!k||!W[k])return;
var d=W[k];
var h='<div style="font-weight:600;margin-bottom:4px">'+d.l+'</div>';
h+='<div style="margin-bottom:8px">'+d.t+' contribution'+(d.t!==1?'s':'')+'</div>';
var maxD=Math.max.apply(null,d.d.concat([1]));
h+='<div style="display:flex;gap:1px;height:28px;align-items:flex-end;margin-bottom:8px">';
var days=['M','T','W','T','F','S','S'];
for(var i=0;i<7;i++){{
var pct=d.d[i]?Math.max(d.d[i]/maxD*100,8):0;
var bg=d.d[i]?'#39d353':'#21262d';
h+='<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:2px">';
h+='<div style="flex:1;width:100%;display:flex;align-items:flex-end">';
h+='<div style="width:100%;background:'+bg+';height:'+pct+'%;border-radius:1px"></div>';
h+='</div>';
h+='<div style="font-size:8px;color:{MUTED_COLOR}">'+days[i]+'</div>';
h+='</div>';
}}
h+='</div>';
var maxS=1;
for(var i=0;i<S.length;i++){{var v=d.s[S[i][0]]||0;if(v>maxS)maxS=v;}}
for(var i=0;i<S.length;i++){{
var src=S[i],v=d.s[src[0]]||0;
var pct=v/maxS*100;
h+='<div style="display:flex;align-items:center;gap:6px;margin:2px 0">';
h+='<span style="width:56px;color:{MUTED_COLOR};font-size:10px">'+src[1]+'</span>';
h+='<div style="flex:1;height:6px;background:#21262d;border-radius:2px">';
h+='<div style="width:'+pct+'%;height:100%;background:'+src[2]+';border-radius:2px;min-width:'+(v?'2px':'0')+'"></div>';
h+='</div>';
h+='<span style="width:24px;text-align:right;font-size:10px">'+v+'</span>';
h+='</div>';
}}
pc.innerHTML=h;
var tx=parseFloat(evt.target.getAttribute('x'));
var ty=parseFloat(evt.target.getAttribute('y'));
var pw=280,ph=320;
var px=tx+CELL+4;
var py=ty-40;
if(px+pw>SVG_W-20)px=tx-pw-4;
if(py<10)py=10;
if(py+ph>SVG_H-10)py=Math.max(10,SVG_H-ph-10);
fo.setAttribute('x',px);
fo.setAttribute('y',py);
fo.setAttribute('display','inline');
}}
function hide(){{
fo.setAttribute('display','none');
}}
"""
# ── CLI ────────────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Multi-source contribution heatmap generator")
p.add_argument("--output", default=None, help="Output SVG path")
p.add_argument("--start-year", type=int, default=None, help="Override earliest year")
p.add_argument("--end-date", type=str, default=None, help="Override end date (YYYY-MM-DD)")
p.add_argument("--cache-dir", type=str, default=".cache", help="Cache directory")
p.add_argument("--no-cache", action="store_true", help="Skip cache, always refetch")
p.add_argument("--dry-run", action="store_true", help="Show what would be fetched")
p.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
return p.parse_args()
def main():
args = parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(levelname)s: %(message)s",
)
import os
end_date = date.fromisoformat(args.end_date) if args.end_date else date.today()
output = args.output or os.environ.get("OUTPUT_PATH", "contributions.svg")
cache_dir = Path(args.cache_dir)
start_year = args.start_year
github_user = os.environ.get("GITHUB_USER")
github_token = os.environ.get("GITHUB_TOKEN")
gl1_url = os.environ.get("GITLAB1_URL")
gl1_user = os.environ.get("GITLAB1_USER")
gl1_token = os.environ.get("GITLAB1_TOKEN")
gl2_url = os.environ.get("GITLAB2_URL")
gl2_user = os.environ.get("GITLAB2_USER")
gl2_token = os.environ.get("GITLAB2_TOKEN")
author_emails = [e.strip() for e in
os.environ.get("AUTHOR_EMAILS", "").split(",") if e.strip()]
fetch_plan = []
if github_user and github_token:
fetch_plan.append(("github", f"GitHub ({github_user})"))
if gl1_url and gl1_user and gl1_token:
fetch_plan.append(("gitlab1", f"GitLab #1 ({gl1_user}@{gl1_url})"))
if gl2_url and gl2_user and gl2_token:
fetch_plan.append(("gitlab2", f"GitLab #2 ({gl2_user}@{gl2_url})"))
if not fetch_plan:
print("No sources configured. Set GITHUB_USER/GITHUB_TOKEN and/or "
"GITLAB1_URL/GITLAB1_USER/GITLAB1_TOKEN environment variables.",
file=sys.stderr)
sys.exit(1)
has_gitlab = any(n.startswith("gitlab") for n, _ in fetch_plan)
if has_gitlab and not author_emails:
print("AUTHOR_EMAILS is required for GitLab sources (comma-separated "
"git author emails).", file=sys.stderr)
sys.exit(1)
if args.dry_run:
print("Would fetch from:")
year_range = f"{start_year or '(auto-detect)'} to {end_date.year}"
for name, desc in fetch_plan:
est = (end_date.year - (start_year or 2010) + 1)
if name == "github":
print(f" {desc}: ~{est} GraphQL queries (years {year_range})")
else:
print(f" {desc}: ~{est * 10} REST requests (years {year_range}, est.)")
print(f"Output: {output}")
return
sources: dict[str, dict[date, DayData]] = {}
sources_ok: list[str] = []
sources_fail: list[str] = []
earliest_year = start_year
# Fetch GitHub first so its join year can serve as the floor for GitLab
if github_user and github_token:
log.info("Fetching GitHub (%s)", github_user)
try:
data, detected_start = fetch_github(
github_user, github_token, start_year, end_date, cache_dir, args.no_cache)
sources["github"] = data
sources_ok.append("github")
if earliest_year is None or detected_start < earliest_year:
earliest_year = detected_start
log.info("GitHub (%s): %d days with activity", github_user, len(data))
except Exception as e:
log.warning("Failed to fetch GitHub: %s", e)
sources_fail.append("github")
for name, desc in fetch_plan:
if name == "github":
continue
log.info("Fetching %s", desc)
try:
if name == "gitlab1":
assert gl1_url and gl1_user and gl1_token
data, detected_start = fetch_gitlab(
gl1_url, gl1_user, gl1_token, author_emails,
earliest_year, end_date, cache_dir, args.no_cache,
"gitlab1")
elif name == "gitlab2":
assert gl2_url and gl2_user and gl2_token
data, detected_start = fetch_gitlab(
gl2_url, gl2_user, gl2_token, author_emails,
earliest_year, end_date, cache_dir, args.no_cache,
"gitlab2")
else:
continue
sources[name] = data
sources_ok.append(name)
if earliest_year is None or detected_start < earliest_year:
earliest_year = detected_start
log.info("%s: %d days with activity", desc, len(data))
except Exception as e:
log.warning("Failed to fetch %s: %s", desc, e)
sources_fail.append(name)
if not sources:
print("All sources failed. Cannot generate heatmap.", file=sys.stderr)
sys.exit(1)
if earliest_year is None:
earliest_year = end_date.year
daily = merge_sources(sources)
weeks_by_year = aggregate_weeks(daily, earliest_year, end_date)
stats = compute_stats(daily, weeks_by_year, end_date, sources_ok, sources_fail)
svg = build_svg(weeks_by_year, stats)
Path(output).write_text(svg)
print(f"Written {len(svg):,} bytes to {output}")
print(f" {stats.total:,} total contributions across {len(sources_ok)} sources, "
f"{earliest_year}-{end_date.year}")
if __name__ == "__main__":
main()
#Footnotes
-
foreignObjectlets you embed arbitrary HTML inside an SVG. It’s been supported in all major browsers for years, but it still feels like you’re getting away with something. ↩
Cite this post
@online{swift2026oneGitHeatmapToRuleThemAll,
author = {Ben Swift},
title = {One git heatmap to rule them all},
url = {https://benswift.me/blog/2026/04/09/one-git-heatmap-to-rule-them-all/},
year = {2026},
month = {04},
note = {AT-URI: at://did:plc:tevykrhi4kibtsipzci76d76/site.standard.document/2026-04-09-one-git-heatmap-to-rule-them-all},
}