Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7ffa647
Enrich the header with links, merge count, timestamps, and social counts
tyom May 23, 2026
f48ed2f
Group co-located tags in the timeline hover tooltip
tyom May 23, 2026
ac1d33a
Dim punch-card row separators so the weekend tint stays calm
tyom May 23, 2026
8407e82
Widen the technologies grid column gap
tyom May 23, 2026
74d41d5
Document tag-sync caveat and add a git gc make target
tyom May 23, 2026
6675844
Replace ECharts legends with HTML legends carrying author popovers
tyom May 23, 2026
74e4714
Render commit share as a labelled pie and skip bot profile lookups
tyom May 23, 2026
66c9eb2
Recast the churn chart as diverging bars with author labels
tyom May 23, 2026
419ac6b
Recast net-lines-per-commit as a commit-style scatter and balance the…
tyom May 23, 2026
045efe8
Move contributor commit share into the author popover
tyom May 23, 2026
6b4a687
Label the largest-files title with its active scope
tyom May 23, 2026
fbf6a7b
Fix merge count under filters; harden header and tooltip rendering
tyom May 23, 2026
b356f4b
Split OverallCharts into per-chart card components
tyom May 23, 2026
d8344de
Classify .po as Gettext Catalog and link the language treemap to GitHub
tyom May 23, 2026
a2a1db8
Extract shared legend-selection state into a factory
tyom May 23, 2026
098c22b
Centralize the bot-filter rule into humanContribRows
tyom May 23, 2026
74c9142
Inline the favicon as a base64 data URI
tyom May 23, 2026
09e3bde
Fix Reset button a11y and stale date-keyed tag tooltips
tyom May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,10 @@ install-hooks: ## Point git at the tracked .githooks/ (auto-rebuilds dist on com
git config core.hooksPath .githooks
@echo "core.hooksPath -> .githooks"

.PHONY: help web-build web-dev web-check format format-check build techdata dev install-hooks
gc: ## Repack git history (committed dist/repo-intel deltas down to ~nothing)
@before=$$(git count-objects -vH | awk '/size-pack:/{print $$2 $$3}'); \
git gc --quiet; \
after=$$(git count-objects -vH | awk '/size-pack:/{print $$2 $$3}'); \
echo "pack: $$before -> $$after"

.PHONY: help web-build web-dev web-check format format-check build techdata dev install-hooks gc
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,18 @@ Pushing a `vX.Y.Z` tag by hand still works as a fallback and runs the same
`Release` workflow — but it skips the pre-tag build gate, so prefer _Cut
release_.

**Syncing tags locally.** Because `Release` _force-moves_ the floating `vX`
major tag onto each new release commit, a plain `git fetch --tags` refuses to
update it (`! [rejected] vX -> vX (would clobber existing tag)`). Pull the
realigned tags with:

```sh
git fetch --tags --force --prune origin
```

The `vX.Y.Z` tags are immutable and always fetch cleanly; only the floating
`vX` tag needs `--force`.

### Detection data (`techdata.json`)

Language detection (extension/filename → language, colors, vendored-path noise
Expand Down
90 changes: 81 additions & 9 deletions dist/repo-intel

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions gen_techdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@
"r": "R", "pl": "Perl", "t": "Perl", "l": "Common Lisp", "v": "Verilog",
"f": "Fortran", "for": "Fortran", "cls": "Apex", "pro": "Prolog",
"ts": "TypeScript", "rs": "Rust", "cs": "C#", "sql": "SQL",
# Linguist's "Gettext Catalog" (.po/.pot) is type:prose and ships no color,
# so it'd be dropped and translation catalogs would vanish into "Other".
# Re-pin them under Linguist's own name with the gold GitHub falls back to
# for colorless languages (Primer --bgColor-attention-emphasis; see
# SYNTHETIC_COLORS).
"po": "Gettext Catalog", "pot": "Gettext Catalog",
}

# Generic extensions whose canonical Linguist owner is the colorless "Text"
Expand Down Expand Up @@ -141,7 +147,7 @@

# Colors for synthetic framework groups Linguist doesn't define a language for.
# Purple keeps "Tools" distinct from the grey "Other" bucket on the same page.
SYNTHETIC_COLORS = {"Tools": "#a371f7"}
SYNTHETIC_COLORS = {"Tools": "#a371f7", "Gettext Catalog": "#9e6a03"}

# Backend / non-JS sentinel files: basename (or sub-path) → (framework, language).
# The "Tools" bucket surfaces build/devops tooling that's present as a config
Expand Down Expand Up @@ -254,12 +260,13 @@ def build_language_tables(langs):
ext_meta[key] = (rank, primary)
for fn in info.get("filenames", []):
filename_lang.setdefault(fn.lower(), eff)
name_color.update(SYNTHETIC_COLORS) # synthetic buckets Linguist doesn't color;
# merged before EXT_OVERRIDE so its guard sees them
for ext, lang in EXT_OVERRIDE.items():
if lang in name_color:
ext_lang[ext] = lang
for ext in EXT_EXCLUDE:
ext_lang.pop(ext, None)
name_color.update(SYNTHETIC_COLORS) # synthetic buckets Linguist doesn't color
return name_color, ext_lang, filename_lang


Expand Down
86 changes: 79 additions & 7 deletions repo-intel.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ def classify_path(field, present=None, shebang=None):
return OTHER_LANG


def top_languages(langs, limit=6):
def top_languages(langs, limit=10):
"""Build a sorted language-bar list from {name: [added, deleted, files]}.

Ranks by lines touched (added + deleted); languages past `limit` collapse
Expand Down Expand Up @@ -1017,6 +1017,15 @@ def collect_local(cwd=None, suppress_current_user=False):
"frameworks": detect_frameworks(present, cwd=cwd),
"file_count": len(present),
"branch_count": count_branches(cwd=cwd),
# Whole-repo merge count for the header's "(N merges)" note. The stats
# pipeline runs --no-merges, so the headline total is rebuilt as
# no-merge commits + this. It can't be filtered (merges aren't in
# commits_meta to recount), so main() drops it under any --since/--until/
# --commits filter rather than inflate the now-filtered headline.
"merge_count": int(git("rev-list", "--merges", "--count", "HEAD", cwd=cwd).strip() or 0),
# HEAD's commit date (includes merges) for the header's "updated N ago".
# Whole-repo like the counts above; the browser renders it relative to now.
"last_commit_iso": git("log", "-1", "--format=%cI", cwd=cwd).strip(),
"largest_files": head_file_sizes(cwd=cwd),
"disk_by_path": history_disk_by_path(cwd=cwd),
}
Expand Down Expand Up @@ -1122,6 +1131,33 @@ def probe_remote_total(owner, repo, token):
return total if isinstance(total, int) else None


def fetch_repo_social(github_base, token):
"""Stars / watchers / forks for a github.com repo via REST; None otherwise
(non-GitHub origin, offline, private repo without a token, etc.)."""
m = ORIGIN_RE.match(github_base or "")
host = (m.group("https_host") or m.group("ssh_host")) if m else ""
if not m or not (host == "github.com" or host.endswith(".github.com")):
return None
url = f"https://api.github.com/repos/{m.group('owner')}/{m.group('repo')}"
headers = {"Accept": "application/vnd.github+json", "User-Agent": "repo-intel"}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
with urllib.request.urlopen(
urllib.request.Request(url, headers=headers), timeout=5
) as resp:
body = json.loads(resp.read().decode())
except (urllib.error.URLError, TimeoutError, ValueError):
return None
return {
"stars": body.get("stargazers_count"),
# REST quirk: "Watchers" in GitHub's UI is subscribers_count;
# watchers_count is a legacy alias of the star count.
"watchers": body.get("subscribers_count"),
"forks": body.get("forks_count"),
}


def get_github_token():
try:
token = subprocess.check_output(
Expand Down Expand Up @@ -1196,7 +1232,10 @@ def fetch_user_profiles(logins, token):
unique = []
seen = set()
for login in logins:
if login and login not in seen:
# Bot accounts (e.g. "github-actions[bot]") are GraphQL Bot nodes, not
# User nodes — user(login:) can never resolve them, so querying just
# yields a NOT_FOUND error. Skip them rather than emit a noisy warning.
if login and login not in seen and not login.endswith("[bot]"):
seen.add(login)
unique.append(login)
if not unique:
Expand All @@ -1219,8 +1258,12 @@ def fetch_user_profiles(logins, token):
except urllib.error.URLError as exc:
print(f" warning: profile fetch failed: {exc}", file=sys.stderr)
return {}
if "errors" in body:
print(f" warning: profile fetch errors: {body['errors']}", file=sys.stderr)
# NOT_FOUND just means a login no longer resolves (deleted/renamed account);
# the per-alias `if not node: continue` below already skips those. Only warn
# about other errors (auth, rate limits) that signal a real fetch problem.
real_errors = [e for e in body.get("errors", []) if e.get("type") != "NOT_FOUND"]
if real_errors:
print(f" warning: profile fetch errors: {real_errors}", file=sys.stderr)
data = body.get("data") or {}
out = {}
for i, login in enumerate(unique):
Expand Down Expand Up @@ -1778,6 +1821,12 @@ def build_data(
# Total branch count: a snapshot stat like file_count. None on paths that
# can't determine it.
branch_count = (extras or {}).get("branch_count")
merge_count = (extras or {}).get("merge_count") or 0
# HEAD commit date for "updated N ago"; fall back to the newest commit we
# have (the remote GraphQL path supplies no extras).
last_commit_iso = (extras or {}).get("last_commit_iso") or max(
(m.get("iso") or "" for m in commits_meta.values()), default=""
)
# File-size sunbursts: blob sizes at HEAD and accumulated on-disk size per
# path across history. None on the remote GraphQL path (no tree fetched).
largest_files = (extras or {}).get("largest_files")
Expand Down Expand Up @@ -1920,8 +1969,15 @@ def build_data(
"largestFiles": largest_files,
"diskByPath": disk_by_path,
"dateRange": date_range,
"lastCommit": last_commit_iso,
"generatedAt": datetime.now(timezone.utc).isoformat(),
"totals": {
# Authored (no-merge) commits — equals the sum of contributors'
# commits, so it's the denominator for every share/percentage.
# Merges are tracked separately; the GitHub-comparable headline
# (commits + merges) is composed at display time.
"commits": total_commits,
"merges": merge_count,
"added": total_added,
"deleted": total_deleted,
"contributors": total_contributors,
Expand Down Expand Up @@ -2073,15 +2129,20 @@ def render_markdown(data):
out.append(f"# repo-intel — {f'[{name}]({base})' if base else name}")
out.append("")
n_contrib = t["contributors"]
# Headline commit count matches GitHub (authored + merges); the per-author
# stats below use the merge-free t["commits"].
merges = t.get("merges") or 0
commits_all = t["commits"] + merges
merge_note = f" ({merges:,} merge{'' if merges == 1 else 's'})" if merges else ""
out.append(
f"_{t['commits']:,} commits · {dr['start'] or '?'} — {dr['end'] or '?'} · "
f"_{commits_all:,} commits · {dr['start'] or '?'} — {dr['end'] or '?'} · "
f"{n_contrib:,} contributor{'' if n_contrib == 1 else 's'}_"
)
out.append("")

out.append("## Totals")
out.append("")
out.append(f"- Commits: {t['commits']:,}")
out.append(f"- Commits: {commits_all:,}{merge_note}")
out.append(f"- Lines: +{t['added']:,} / -{t['deleted']:,}")
out.append(f"- Contributors: {t['contributors']:,}")
out.append(f"- Default branch: `{data['defaultBranch']}`")
Expand Down Expand Up @@ -2251,6 +2312,13 @@ def main():
)
if not commits_meta:
sys.exit("error: no commits match the given filters")
# The merge tally is whole-history (collected before filtering, and
# merges aren't in commits_meta to recount), so adding it to the now
# date/subset-filtered authored total would inflate the "N commits"
# headline. Drop it under any filter — the headline then reads as just
# the commits in range.
if extras:
extras.pop("merge_count", None)

tags = filter_tags_to_range(tags, commits_meta)

Expand All @@ -2271,6 +2339,10 @@ def main():

enrich_contributor_profiles(data["contributors"], commits_meta, github_base, token=token)

social = fetch_repo_social(github_base, token or get_github_token())
if social:
data.update(social)

multi = len(formats) > 1
builders = {
"html": lambda: render_html(data),
Expand All @@ -2287,7 +2359,7 @@ def main():
print(f"Wrote {out_path}")

print(
f" {data['totals']['commits']} commits · "
f" {data['totals']['commits'] + (data['totals'].get('merges') or 0)} commits · "
f"{data['dateRange']['start']} — {data['dateRange']['end']} · "
f"{data['totals']['contributors']} contributor"
f"{'' if data['totals']['contributors'] == 1 else 's'}"
Expand Down
3 changes: 3 additions & 0 deletions techdata.json
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@
"Gentoo Ebuild": "#9400ff",
"Gentoo Eclass": "#9400ff",
"Gerber Image": "#d20b00",
"Gettext Catalog": "#9e6a03",
"Gherkin": "#5B2063",
"Git Attributes": "#F44D27",
"Git Commit": "#F44D27",
Expand Down Expand Up @@ -1790,12 +1791,14 @@
"pm6": "Raku",
"pml": "Promela",
"pmod": "Pike",
"po": "Gettext Catalog",
"podsl": "Common Lisp",
"podspec": "Ruby",
"pogo": "PogoScript",
"polar": "Polar",
"por": "Portugol",
"postcss": "CSS",
"pot": "Gettext Catalog",
"pov": "POV-Ray SDL",
"pp": "Puppet",
"pprx": "REXX",
Expand Down
4 changes: 4 additions & 0 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Referenced as a build asset (not a public/ file) so vite-plugin-singlefile
inlines it as a base64 data URI — the generated dashboard is a single HTML
opened over file:// from /tmp, so a sibling/remote icon would never load. -->
<link rel="icon" type="image/png" href="./src/assets/favicon.png" sizes="96x96" />
<title>Repo Intel</title>
<!-- repo-intel.py replaces this marker with `window.__DATA__ = {...};`.
The injectionMarker() Vite plugin guarantees it survives the build. -->
Expand Down
25 changes: 22 additions & 3 deletions web/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
createTimelineTooltip,
buildPunchPoints,
} from "$lib/popovers";
import { fmtTimelineDuration } from "$lib/format";
import { authorUrl, fmtTimelineDuration, relativeTime, fmtDateTime } from "$lib/format";
import { setAuthorTotalCommits } from "$lib/popover-store.svelte";
import { registerEchartsTheme } from "$lib/theme";
import { buildTimeline } from "$lib/timeline";
import { dragScroll, scrollSpy } from "$lib/actions";
Expand Down Expand Up @@ -41,6 +42,10 @@
timelineDur ? `Commit timeline: ${timelineDur}` : "Commit timeline",
);

// "Last commit N ago" shown in the Contributions header.
const lastCommitAgo = $derived(relativeTime(data.lastCommit));
const lastCommitFull = $derived(fmtDateTime(data.lastCommit));

// The author popover (shared by the table and the timeline lane labels) and the
// commit-bucket popover (opened by the punch-card cells) write the shared
// popover store rendered by <AuthorPopover/> / <CommitPopover/>. Created in
Expand All @@ -61,6 +66,7 @@
// The timeline is still rendered imperatively into the container elements below
// (it's a hand-drawn canvas); wire it once the static layout is mounted.
onMount(() => {
setAuthorTotalCommits(data.totals.commits);
authorPopover = createAuthorPopover(data.contributors);
commitPopover = createCommitPopover(data);
buildTimeline(data, authorPopover, createTimelineTooltip());
Expand Down Expand Up @@ -89,7 +95,16 @@
<div class="section" id="contributions">
<div class="card">
<div class="contributions-header">
<h2>Contributions</h2>
<div class="contributions-title">
<h2>Contributions</h2>
{#if lastCommitAgo}
<span class="last-commit"
>Last commit <time datetime={data.lastCommit} title={lastCommitFull}
>{lastCommitAgo}</time
></span
>
{/if}
</div>
<YearToggles {data} onSelect={(mode) => (heatmapMode = mode)} />
</div>
<Heatmap {data} mode={heatmapMode} />
Expand Down Expand Up @@ -119,14 +134,16 @@
</div>
<div class="section" id="overall">
<h2>Overall</h2>
<OverallCharts {data} />
<OverallCharts {data} {authorPopover} />
</div>
<div class="section" id="commit-frequency">
<h2>Commit frequency over time</h2>
<div class="grid-5">
{#each data.contributors as c, i (c.email)}
<ContributorCard
{authorPopover}
contributor={c}
url={authorUrl(data, c)}
index={i}
weeks={data.weeks}
weekly={data.weeklyData[c.email]}
Expand All @@ -139,7 +156,9 @@
<div class="scroll-row" use:dragScroll>
{#each data.contributors as c, i (c.email)}
<PatternCard
{authorPopover}
contributor={c}
url={authorUrl(data, c)}
index={i}
points={punchData[c.email] ?? []}
{commitPopover}
Expand Down
Binary file added web/src/assets/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions web/src/lib/chart-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Shared scaffolding for the repo-wide OverallCharts cards. Contributors are
// aggregated by email, so one person who committed under several addresses (old
// work emails, a noreply alias) shows up as several rows that all share one
// display name. ECharts keys legend selection by the series/slice name, so
// identical names would collapse into a single legend entry and toggle together.
// Every card therefore names its series/slice by the (unique) email under the
// hood, hides ECharts' own legend, and renders the HTML legend (ChartLegend)
// below — plain names, one entry per identity. These helpers map email ↔ name ↔
// original contributor index so the cards can share that mapping.
import { clr } from "$lib/theme";
import type { Contributor } from "$types";

// Legend rows for the weekly-commits line and the commit-share pie. `idx` is the
// original contributor index (so clr() and the author popover line up); the pie
// gains a non-author "Others" row (idx -1 → no popover) when the top-N don't
// cover every commit.
export type LegendItem = { key: string; name: string; color: string; idx: number };

// Email → display name, for tooltips that key series by the unique email.
export const buildNameByEmail = (contributors: Contributor[]): Map<string, string> =>
new Map(contributors.map((c) => [c.email, c.name] as const));

// Email → original contributor index, so a chart that filters/re-sorts a local
// copy (the churn bars) can still resolve the right person's colour and popover.
export const buildEmailToOrig = (contributors: Contributor[]): Map<string, number> =>
new Map(contributors.map((c, i) => [c.email, i] as const));

export const buildContribLegend = (contributors: Contributor[]): LegendItem[] =>
contributors.map((c, i) => ({ key: c.email, name: c.name, color: clr(i), idx: i }));

// Contributors with their original index, minus bots. A `[bot]` login (the ones
// repo-intel.py skips for profiles — Renovate, CI accounts) churns nothing like a
// human and only flattens everyone else's scale, so the churn and commit-style
// charts drop them. `origIdx` is preserved so each row still resolves the right
// identity colour (clr) and author popover after the chart re-sorts its own copy.
export const humanContribRows = (
contributors: Contributor[],
): { c: Contributor; origIdx: number }[] =>
contributors.map((c, origIdx) => ({ c, origIdx })).filter((r) => !r.c.login.endsWith("[bot]"));

// Half-transparent dark inner border on the treemap tiles, so each grey/brand
// tile reads against the gap around it (shared by the languages and files
// treemaps).
export const tileInnerBorder = "rgba(0, 0, 0, 0.6)";
Loading
Loading