git-stats

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 4, 2026 License: MIT

README

git-stats

Local, over-time metrics for how any GitHub repository is actually being downloaded.

Why this exists

GitHub does expose distribution numbers, but not as history:

  • Release asset downloads are a single cumulative counter per asset. There is no "downloads last week" — only "downloads ever". A trend exists only if something writes the counter down repeatedly.
  • Traffic data (views, clones, referrers, popular paths) is kept for 14 days and then deleted. A day not captured inside that window is gone permanently.

So this tool snapshots those endpoints, archives every raw response, and derives trends from the archive. Everything stays on your machine — there is no service, no account and no telemetry.

Quick start

go build -o bin/git-stats ./cmd/git-stats

cp .env.example .env && chmod 600 .env
$EDITOR .env                   # set GIT_STATS_REPO=owner/name

./bin/git-stats collect        # take a snapshot
./bin/git-stats report         # see where things stand

collect works with no token at all for release counters. To get traffic data you need a credential — see below.

Configuration

Everything is configured through environment variables, which may equally live in a .env file next to the binary or in the working directory. Real environment variables always win — .env supplies defaults only, so a one-off GIT_STATS_REPO=other/repo git-stats collect still overrides the file.

Variable Meaning
GIT_STATS_REPO Required. Repository to track, as owner/name. -repo overrides it.
GIT_STATS_TOKEN API token. Falls back to GH_TOKEN, then GITHUB_TOKEN.
GIT_STATS_ASSET_PREFIX Release asset filename prefix. Defaults to the repository name.
GIT_STATS_DIR Data directory holding raw/ and stats.db. Defaults to ./data.

There is deliberately no default repository: an unset GIT_STATS_REPO is an error rather than a silent fallback, so a misconfigured run cannot quietly archive somebody else's numbers.

Release asset names

The per-platform breakdown comes from asset filenames, which GoReleaser and hand-rolled release workflows converge on:

<prefix>-<version>-<goos>-<goarch>.<tar.gz|tgz|tar.xz|tar.bz2|zip|sha256|sha512>

The prefix defaults to the repository name, so owner/widget publishing widget-1.2.3-darwin-arm64.tar.gz needs no configuration. Set GIT_STATS_ASSET_PREFIX if your project publishes under a different name.

Assets that do not match are still counted — their downloads land in the totals, just without an OS, arch or kind. So a project with entirely different naming still gets working download trends, only without the platform charts.

Token setup

Data Credential needed
Release downloads, repo counters none (unauthenticated is fine, 60 req/h)
Views, clones, referrers, paths classic PAT with the public_repo scope
Star history backfill a credential the /stargazers endpoint accepts

Traffic data is owner-only: the token must belong to an account with push access to the repository you are tracking.

The Traffic API does not accept fine-grained tokens. A fine-grained PAT holding the administration=read permission that those endpoints advertise as their requirement is still refused with "Resource not accessible by personal access token" — verified with the same token succeeding on other administration=read endpoints such as /actions/permissions. No permission checkbox fixes it; the endpoint only honours classic tokens.

Create one at Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token (classic) and tick a single scope:

  • public_repo — enough for a public repository. Use the broader repo scope only if you point this at a private one.

Then put it in .env as GIT_STATS_TOKEN. .env is gitignored, and is read from the working directory or failing that from the directory holding the binary, so the tool finds it however you invoke it.

Every collect prints which variable supplied the token and whether it came from .env or the environment — when the traffic endpoints 403, the usual cause is a broadly-scoped fallback GH_TOKEN being picked up instead of the dedicated one.

A run that cannot reach the traffic endpoints still succeeds — it captures releases and prints a warning naming the missing permission. Release counters are cumulative and can be caught up at any time; traffic cannot.

Commands

git-stats collect [-repo R] [-data DIR] [-stars]
git-stats report  [-repo R] [-since 30d] [-per-day] [-html FILE]
git-stats rebuild [-repo R] [-data DIR]
git-stats backfill-stars [-repo R] [-data DIR]
git-stats version
  • report -html stats.html writes a self-contained dashboard — no external requests, works opened straight from disk, light and dark.
  • rebuild deletes the database and replays the whole archive into a new one. It takes -repo even though it fetches nothing, because replaying re-derives the per-platform columns from asset filenames.
  • backfill-stars fetches the full star history; each star carries its own timestamp, so unlike everything else here it is retroactively complete rather than sampled.

Data layout

.env                           configuration and token, gitignored
.env.example                   template, committed
data/
  raw/2026-08-04T07-02-47Z/    immutable archive — the source of truth
    releases.json  repo.json  views.json  clones.json  paths.json  referrers.json
  stats.db                     derived, disposable, rebuildable

Both collect and rebuild write to the database through the same ingest path, so a rebuild reproduces exactly what collection produced. The database is safe to delete; the archive is not. A file is simply absent when that endpoint was unavailable.

data/raw/ is not gitignored, on purpose; data/stats.db is. GitHub deletes traffic data after 14 days, so those files become the only copy that will ever exist — version control is the backup. The archive compresses about 95% (consecutive releases.json are nearly identical), which works out to roughly 15 MB per year of daily collection.

Before pushing an archive to a public repository: views, clones, referrers and paths are owner-only analytics, and committing them publishes them. Add data/ to .gitignore if you would rather keep them private.

Querying it directly

sqlite3 data/stats.db
-- Downloads per platform as of the latest snapshot
SELECT os || '-' || arch AS platform, SUM(download_count) AS total
FROM asset_count
WHERE snapshot_id = (SELECT MAX(id) FROM snapshot)
GROUP BY platform ORDER BY total DESC;

-- Growth of one release between the two most recent snapshots
SELECT a.release_tag, SUM(a.download_count) - SUM(b.download_count) AS delta
FROM asset_count a JOIN asset_count b ON a.asset_id = b.asset_id
WHERE a.snapshot_id = (SELECT MAX(id) FROM snapshot)
  AND b.snapshot_id = (SELECT MAX(id) FROM snapshot WHERE id < (SELECT MAX(id) FROM snapshot))
GROUP BY a.release_tag HAVING delta > 0 ORDER BY delta DESC;

-- Merged daily traffic (only days captured inside the 14-day window exist)
SELECT day, count, uniques FROM traffic_day WHERE metric = 'clones' ORDER BY day;

Tables: snapshot, asset_count, traffic_day, traffic_window, traffic_top, repo_stat, stargazer. The schema is documented inline in internal/store/store.go.

What is and isn't retroactive

Only one of these datasets starts from zero the day you begin collecting:

Data On your very first run Afterwards
Release download counts All-time totals, back to each asset's upload trends accrue from your first snapshot
Views / clones / referrers / paths The previous 14 days, daily buckets window slides; miss >14 days and those days are gone
Stars / forks / watchers current values only trend accrues from your first snapshot
Star history fully retroactive — every star carries its own date complete after one backfill

So the 14-day limit is not "14 days from when you start" — the first call already hands you the preceding fortnight. The constraint is on the gap between runs: as long as you never go more than 14 days without collecting, the daily series stays continuous indefinitely, and traffic_day accumulates it forever.

Release counters behave the opposite way: the all-time total is available instantly and is never lost, but the history of how it got there does not exist and can only be built forward from your first snapshot.

How to read the numbers

  • Deltas are between snapshots, not per day. Collection is manual, so intervals are irregular. The report always labels the interval it measured; -per-day normalises.
  • Unique visitors never sum. Daily buckets each count a returning visitor again, so adding them up overcounts — one real archive's first window reported 248 uniques where the daily figures summed to 306. The de-duplicated number is stored per snapshot in traffic_window and is the only one the report quotes as "unique".
  • The first snapshot is a baseline. Its counters are all-time totals that cannot be attributed to any date, so no interval is derived from it.
  • Counters never go down. A decrease means an asset was replaced or a release deleted, so it contributes zero rather than a negative.
  • An asset first seen in a later snapshot contributes its whole count — it was created inside that interval, so everything it accrued belongs there.
  • A platform reading zero may mean nothing was published for it, not that nobody wants it. Check that the release actually carries that asset before concluding anything about demand.
  • Install scripts and self-updaters fetch an artifact and its checksum together; a browser download usually fetches only the artifact. The smaller count approximates scripted installs, the excess approximates manual ones. This split only appears for projects that publish a checksum per artifact.
  • A self-update cannot be told apart from a fresh install. A self-updater issues exactly the same asset requests as a first-time install script, and GitHub's download_count records no user agent, referrer or IP. Upgrades are therefore counted, but only inside the combined "scripted" figure.
  • Clones and release downloads do not overlap. They count disjoint distribution paths: cloning and source-level package managers fetch no release asset, while install scripts, self-updaters and browser downloads never clone. Clones running many times higher than the all-time download total is expected and is not evidence that either figure is wrong.
  • Source installs have no download counter anywhere, and clone count is a weak stand-in in both directions: a module or package proxy caches each version globally, so many installs can produce a single clone, while CI, mirrors and scanners inflate the same number. Never read clones as a user count.
  • Counts include bots — mirrors, scanners and CI all download releases, and GitHub does not separate them out.

What none of this measures

Every figure here is an acquisition event. Nothing in it reports usage, retention or whether anyone ran your software twice.

  • A git pull is not a clone. Clone traffic counts full clone operations; pulling into an existing checkout is an incremental fetch and is never counted. Someone who cloned once in June and has pulled daily since contributes nothing to today's clone count. GitHub exposes no fetch or pull metric at all.
  • CI re-acquires constantly. actions/checkout performs a fresh clone on every run, so a single active workflow can outweigh many real developers in the same number.
  • Downloads are one-shot too. An artifact download says someone installed that version once. It says nothing about whether it was ever run, kept, or replaced.

The practical consequence: these numbers track how many people obtain your project, and are biased upward by automation and downward by caching. They are not a user count and cannot become one without adding opt-in telemetry to the project itself, which is a separate decision with its own tradeoffs.

Scheduling

Collection is manual by design. If you want it automatic, a launchd agent or a systemd timer calling collect once a day is enough — and unlike cron, launchd fires on wake if the machine was asleep at the scheduled time. Until then: any traffic day not captured within 14 days is lost, while release download counts are cumulative and never lost.

Releases and CI

Tagging v* builds and publishes every platform:

amd64 arm64
Linux ✓ ✓
macOS ✓ (Intel) ✓ (Apple Silicon)
Windows ✓ ✓

All six are cross-compiled from a single Linux runner with CGO_ENABLED=0, which is possible because the only heavyweight dependency — modernc.org/sqlite — is a pure-Go SQLite.

That is a deliberate choice rather than a convenience. macos-13 is GitHub's last Intel macOS runner and it is being retired; any release workflow that builds darwin/amd64 on a native Intel runner stops producing Intel Mac binaries the day that image disappears, and Intel users silently lose their download. Cross-compiling has no such dependency, and the release job asserts each binary's actual architecture with file before publishing, so a silent fallback to the host architecture fails the build instead of shipping a mislabelled binary.

Release assets follow the same <prefix>-<version>-<os>-<arch>.<ext> scheme git-stats parses, so it can track its own releases with no configuration.

CI on every pull request runs tests with the race detector, the same six-target cross-build, golangci-lint, govulncheck, and an architectural regression gate powered by enola — which pins a baseline from the PR's merge base and grades what the change did to the structure. The architecture job is advisory: it reports a verdict into the job summary and never blocks. To make it enforcing, delete the exit 0 at the end of its "Grade the change" step.

License

MIT © Dejan Menges

Directories

Path Synopsis
cmd
git-stats command
Command git-stats collects GitHub distribution metrics for a repository and reports trends over time.
Command git-stats collects GitHub distribution metrics for a repository and reports trends over time.
internal
collect
Package collect performs one metrics collection run: fetch every available endpoint, archive the raw responses, then derive database rows from the archive it just wrote.
Package collect performs one metrics collection run: fetch every available endpoint, archive the raw responses, then derive database rows from the archive it just wrote.
dotenv
Package dotenv loads KEY=VALUE pairs from a .env file into the process environment, so a credential can live in a gitignored file instead of a shell profile.
Package dotenv loads KEY=VALUE pairs from a .env file into the process environment, so a credential can live in a gitignored file instead of a shell profile.
github
Package github is a minimal read-only client for the GitHub REST endpoints that expose a repository's distribution metrics.
Package github is a minimal read-only client for the GitHub REST endpoints that expose a repository's distribution metrics.
report
Package report renders collected metrics as a terminal summary or a self-contained HTML dashboard.
Package report renders collected metrics as a terminal summary or a self-contained HTML dashboard.
store
Package store owns the two storage layers: an immutable archive of raw API responses, and a SQLite database derived from it.
Package store owns the two storage layers: an immutable archive of raw API responses, and a SQLite database derived from it.
version
Package version carries the build's version string.
Package version carries the build's version string.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL