Tom Gould

Writing

Counting developer activity from git

How GitHub Auditor counts commits, lines and reviews across many repositories without counting the same work twice, counting lines nobody wrote, or passing off missing data as a quiet week.

GitHub Auditor has a command that reports what each developer did over a date range, across every repository in an account: commits, lines added and removed, pull requests, reviews and tags. Counting commits sounds like a one-line job for git log. Most of the code is about making the numbers mean what they say: no work counted twice, no lines counted that nobody wrote, and no missing data passed off as a quiet week.

Git first, the API second

The report reads commits, line counts and tags from local clones. It only asks the GitHub API for what git doesn’t know: pull requests, issues, reviews and comments. Git is fast and costs no API requests, and the clones are still there after access to an account has ended, so the report still works for it.

Every clone is fetched before the scan, so work pushed since the last checkout is included, and the report’s header says when the data was fetched. If the API login fails, the report carries on from git alone and says so at the top. The pull request and review columns then show “n/a”, not 0, because those numbers are unknown, not zero.

Whole days, in one timezone

A report for 10 to 16 October should run from midnight on the 10th to the last second of the 16th. Git’s --since=2026-10-10 doesn’t mean that. Given a bare date, git takes the current time of day, so a report run at 18:13 started at 18:13 on the 10th and dropped everything before it. The tool now gives git full timestamps in the configured timezone, Europe/London by default:

def git_args(self) -> list[str]:
    last = self.end - timedelta(seconds=1)
    return [
        f"--since={self.start.isoformat(timespec='seconds')}",
        f"--until={last.isoformat(timespec='seconds')}",
    ]

The API’s timestamps are in UTC, so they’re converted to local dates before they’re compared. In British Summer Time, anything that happens between midnight and 1am local time has a UTC date of the day before.

Commits are dated by their committer date, not their author date. Git picks the commits in a range by committer date, so using the same date in the daily table means the days always add up to the total. The cost is that rebased work counts on the day it was rebased.

Lines nobody wrote

A developer who updates package-lock.json can add thousands of lines without writing one. So line counts leave out lock files at any depth, vendored and built folders, minified files, recorded test data such as mocks and fixtures, data files such as CSV exports, and binary files.

Binary files have to be listed by extension. Git treats a file as binary if it finds a NUL byte in its first 8KB. A JPEG without one is diffed as text, and every row of bytes counts as a line.

The exclusions are git pathspecs, but filtering the log with them has a side effect. A commit that only touched excluded files disappears from the log, so adding an exclusion would change the commit count as well as the line count. Instead the tool runs the log twice: once without the pathspecs, for the list of commits and their raw line counts, and once with them, for the lines that count. Then it joins the two by commit hash:

for c in commits:
    raw_add, raw_del = c["additions"], c["deletions"]
    k = counted.get(c["sha"])
    c["additions"] = k["additions"] if k else 0
    c["deletions"] = k["deletions"] if k else 0
    c["excluded_additions"] = max(0, raw_add - c["additions"])
    c["excluded_deletions"] = max(0, raw_del - c["deletions"])

A lock file update still counts as a commit, with no lines. The difference between the two passes goes into a table of what was left out for each developer, so a big import shows up rather than vanishing.

One person, several names

The same person often commits as “Bob Example” on one machine and “bob-example” on another, with different email addresses. Left alone, they’d get two rows in the leaderboard.

The API helps first. GitHub’s list of commits links many email addresses to a login, and GitHub’s noreply addresses contain the login. After that, entries are joined when they share an identity: a name or login reduced to lowercase letters and digits, or the part of the email address before the @. A small union-find groups them, so the links are transitive, and each group folds into the entry with the most commits:

for key, dev in dev_map.items():
    for raw in (dev.name, dev.github_login):
        ident = _normalise_name(raw or "")
        link(ident if len(ident) >= 4 else "", key)
    link(_email_identity(dev.email), key)

Short identities are too generic to merge on. Names and logins under four characters are skipped, and so are email local parts under eight characters, shared mailboxes such as admin or developer, and noreply addresses. Anything the automatic matching can’t settle goes in a developer_aliases list in the config.

Bots are kept apart. Accounts such as renovate[bot] get their own table, outside the leaderboard but inside the totals, so the two still add up.

The same work twice

By default the log covers every branch. With --branches, commits on the default branch are counted from there, and work on other branches is shown in its own section. A rebased or cherry-picked copy of a commit has a new hash, so matching by hash would count it twice. The tool matches by git patch-id --stable instead, a hash of the diff that ignores line numbers and whitespace, so a commit and its rebased copy share an ID. A branch commit whose patch is already on the default branch is dropped. When the same patch is on several branches, it’s credited to the earliest copy, which is the original rather than someone else’s re-application of it. Branches whose pull requests are all closed are skipped, because that work has either landed or been abandoned.

When the API runs out

A GitHub token gets 5,000 requests an hour. Before a scan, the tool prints how many are left. If they run out part way through, the client waits for the reset rather than failing, because a report full of zeros is worse than an hour’s pause. Any call that still fails is listed in a data quality block at the top of the report, with the repository and the call, so a report with gaps can’t be mistaken for a quiet week.

Using it

auditer checkout --account my-org
auditer activity --account my-org --since 2026-10-05 --until 2026-10-18

The report is Markdown, written to reports/my-org/ with the date range in its file name. The code is on GitHub.