Build a Git Commit Statistics CLI
A CLI that parses git log output, reports commit counts per author and per day, tracks insertions and deletions, and highlights the busiest files, all from a local repository.
Project goal
Build a CLI that runs git log with a machine-readable format, parses commits into records, and reports per-author, per-day, and per-file statistics with totals.
Final features
- Run git log with a parseable separator format and parse each commit record.
- Count commits per author with the newest activity first.
- Aggregate commits per day and per weekday to show cadence.
- Sum insertions and deletions per author.
- List the most frequently changed files with a count.
Architecture
A git runner shells out to git log and captures stdout; a parser turns that text into commit records; an aggregator computes several views from one record set; and a formatter prints each view as a table. The parser and aggregator are pure functions over text and records so they test without a repository.
Prerequisites
- Node.js 20 LTS or later
- A local git repository to analyse
- Familiarity with child processes and text parsing
Dependencies
- node >=20 — child_process to run git and the CLI entry point
- vitest ^2.1.0 — Parser and aggregator tests over fixture text
Directory structure
git-stats/
├── src/
│ ├── run.js
│ ├── parse.js
│ ├── aggregate.js
│ ├── format.js
│ └── cli.js
├── test/
│ ├── parse.test.js
│ └── aggregate.test.js
└── package.json
Phases
1. Git invocation
Run git log with a fixed format and numstat, returning stdout as a string and surfacing git errors.
Files: src/run.js
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
export const LOG_ARGS = [
'log',
'--pretty=format:__COMMIT__%H%x09%an%x09%aI%x09%s',
'--numstat',
'--no-merges',
];
export async function gitLog(cwd = process.cwd(), extra = []) {
try {
const { stdout } = await run('git', [...LOG_ARGS, ...extra], { cwd, maxBuffer: 64 * 1024 * 1024 });
return stdout;
} catch (err) {
throw new Error('git log failed: ' + err.message);
}
}Expected behavior: Running in a repository returns a non-empty log string, and running outside one throws a wrapped error rather than crashing with a raw stack trace.
2. Log parser
Split the log into commits and parse the header fields and numstat file lines into records.
Files: src/parse.js
export function parseLog(text) {
return text
.split('__COMMIT__')
.filter((chunk) => chunk.trim())
.map((chunk) => {
const [header, ...fileLines] = chunk.split('\n');
const [hash, author, date, subject] = header.split('\t');
const files = fileLines
.map((l) => l.split('\t'))
.filter((parts) => parts.length === 3)
.map(([insertions, deletions, path]) => ({
path,
insertions: insertions === '-' ? 0 : Number(insertions),
deletions: deletions === '-' ? 0 : Number(deletions),
binary: insertions === '-',
}));
return { hash, author, date, subject, files };
});
}Expected behavior: A fixture with three commits parses into three records with correct fields, and a binary file line yields zero insertions and a binary flag.
3. Aggregations
Compute per-author, per-day, and per-file statistics as pure functions over the records.
Files: src/aggregate.js
export function byAuthor(commits) {
const map = new Map();
for (const c of commits) {
const entry = map.get(c.author) ?? { author: c.author, commits: 0, insertions: 0, deletions: 0 };
entry.commits++;
for (const f of c.files) { entry.insertions += f.insertions; entry.deletions += f.deletions; }
map.set(c.author, entry);
}
return [...map.values()].sort((a, b) => b.commits - a.commits || a.author.localeCompare(b.author));
}
export function byFile(commits) {
const map = new Map();
for (const c of commits) for (const f of c.files) map.set(f.path, (map.get(f.path) ?? 0) + 1);
return [...map.entries()].map(([path, changes]) => ({ path, changes }))
.sort((a, b) => b.changes - a.changes || a.path.localeCompare(b.path));
}Expected behavior: Author totals sum insertions and deletions correctly, and the file list ranks the most frequently changed file first.
4. Date aggregation
Group commits by ISO day and by weekday to show development cadence.
Files: src/dates.js
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function byDay(commits) {
const map = new Map();
for (const c of commits) {
const day = c.date.slice(0, 10);
map.set(day, (map.get(day) ?? 0) + 1);
}
return [...map.entries()].map(([day, commits]) => ({ day, commits })).sort((a, b) => a.day.localeCompare(b.day));
}
export function byWeekday(commits) {
const counts = Object.fromEntries(WEEKDAYS.map((d) => [d, 0]));
for (const c of commits) counts[WEEKDAYS[new Date(c.date).getUTCDay()]]++;
return counts;
}Expected behavior: Day counts group by the ISO date prefix and weekday counts sum to the total commit count.
5. Table formatting
Render each aggregation as a fixed-width table with aligned columns and a header.
Files: src/format.js
export function table(rows, columns) {
const widths = columns.map((col) =>
Math.max(col.label.length, ...rows.map((r) => String(r[col.key]).length)));
const header = columns.map((col, i) => col.label.padEnd(widths[i])).join(' ');
const lines = rows.map((r) => columns.map((col, i) => String(r[col.key]).padEnd(widths[i])).join(' '));
return [header, '-'.repeat(header.length), ...lines].join('\n');
}Expected behavior: A table renders aligned columns sized to the widest cell, and an empty row set still prints the header and separator.
6. CLI and tests
Wire git log, parsing, aggregation, and formatting, and cover the parser and aggregators with tests.
Files: src/cli.js
import { gitLog } from './run.js';
import { parseLog } from './parse.js';
import { byAuthor, byFile } from './aggregate.js';
import { table } from './format.js';
const [view = 'authors', ...extra] = process.argv.slice(2);
const commits = parseLog(await gitLog(process.cwd(), extra));
if (view === 'authors') {
console.log(table(byAuthor(commits), [
{ key: 'author', label: 'Author' }, { key: 'commits', label: 'Commits' },
{ key: 'insertions', label: '+' }, { key: 'deletions', label: '-' },
]));
} else if (view === 'files') {
console.log(table(byFile(commits), [{ key: 'path', label: 'File' }, { key: 'changes', label: 'Changes' }]));
} else {
console.error('usage: git-stats [authors|files|days] [git args]');
process.exit(1);
}Expected behavior: Running in a repository prints an aligned author table, and an unknown view exits with code 1 and a usage line.
Testing
Run npx vitest. Parser tests feed a fixture log excerpt and assert commit fields, numstat parsing, and binary files where insertions and deletions are dashes. Aggregator tests assert per-author totals, per-day counts, and file change counts including a file touched in two commits.
Troubleshooting
Author names differ for the same person
Normalise by email in addition to name, or group on the email field; git records both, and many contributors have several display names.
Binary files report NaN totals
Treat a dash in numstat as zero and mark the file as binary; coercing a dash with Number produces NaN, which poisons any sum it enters.
The log for a large repository is truncated
Raise the child process maxBuffer or stream git output and parse incrementally; the default buffer is far smaller than a large log.
Deployment
Run in CI or locally to produce a periodic activity report; the output is plain text so it can be committed or posted to a chat. A JSON formatter makes the same data available to a dashboard.
Known limitations
- Only numstat totals are used, so renames and moves count as a delete plus an add.
- Merge commits are excluded by default, which undercounts integration-heavy workflows.
Project overview
Git already stores everything you want to measure; the work is asking for it in a parseable shape and turning that shape into answers. This project does exactly that without a heavy dependency.
The tool runs git log with a custom separator format, parses each commit including the numstat lines, and reports author, date, and file statistics from a single record set.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
The log format is chosen so that records are unambiguous to split, which is the foundation the whole parser rests on:
export const LOG_FORMAT = [
'--pretty=format:__COMMIT__%H%x09%an%x09%aI%x09%s',
'--numstat',
].join(' ');
export function parseLog(text) {
return text
.split('__COMMIT__')
.filter((chunk) => chunk.trim())
.map((chunk) => {
const [header, ...fileLines] = chunk.split('\n');
const [hash, author, date, subject] = header.split('\t');
const files = fileLines
.map((l) => l.split('\t'))
.filter((parts) => parts.length === 3 && parts[0] !== '-')
.map(([insertions, deletions, path]) => ({
path,
insertions: Number(insertions),
deletions: Number(deletions),
}));
return { hash, author, date, subject, files };
});
}
Once commits are records with a files array, every report is a different grouping of the same data rather than a new git invocation.
Setup
The tool shells out to the git binary rather than parsing the repository format directly, so it works against any repository and requires no git library.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | child_process to run git and the CLI entry point |
| vitest | ^2.1.0 | Parser and aggregator tests over fixture text |
Create the project and install:
mkdir git-stats && cd git-stats
npm init -y
npm install -D vitest@^2.1.0
Configure the CLI and tests in package.json:
{
"name": "git-stats",
"type": "module",
"scripts": {
"stats": "node src/cli.js",
"test": "vitest run"
}
}
Commit a fixture log excerpt as a test resource so the parser is verified without depending on the host repository.
Phase 1: Git invocation
Run git log with a fixed format and numstat, returning stdout as a string and surfacing git errors. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
export const LOG_ARGS = [
'log',
'--pretty=format:__COMMIT__%H%x09%an%x09%aI%x09%s',
'--numstat',
'--no-merges',
];
export async function gitLog(cwd = process.cwd(), extra = []) {
try {
const { stdout } = await run('git', [...LOG_ARGS, ...extra], { cwd, maxBuffer: 64 * 1024 * 1024 });
return stdout;
} catch (err) {
throw new Error('git log failed: ' + err.message);
}
}
Acceptance for this phase: Running in a repository returns a non-empty log string, and running outside one throws a wrapped error rather than crashing with a raw stack trace.
Phase 2: Log parser
Split the log into commits and parse the header fields and numstat file lines into records. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
export function parseLog(text) {
return text
.split('__COMMIT__')
.filter((chunk) => chunk.trim())
.map((chunk) => {
const [header, ...fileLines] = chunk.split('\n');
const [hash, author, date, subject] = header.split('\t');
const files = fileLines
.map((l) => l.split('\t'))
.filter((parts) => parts.length === 3)
.map(([insertions, deletions, path]) => ({
path,
insertions: insertions === '-' ? 0 : Number(insertions),
deletions: deletions === '-' ? 0 : Number(deletions),
binary: insertions === '-',
}));
return { hash, author, date, subject, files };
});
}
Acceptance for this phase: A fixture with three commits parses into three records with correct fields, and a binary file line yields zero insertions and a binary flag.
Phase 3: Aggregations
Compute per-author, per-day, and per-file statistics as pure functions over the records. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
export function byAuthor(commits) {
const map = new Map();
for (const c of commits) {
const entry = map.get(c.author) ?? { author: c.author, commits: 0, insertions: 0, deletions: 0 };
entry.commits++;
for (const f of c.files) { entry.insertions += f.insertions; entry.deletions += f.deletions; }
map.set(c.author, entry);
}
return [...map.values()].sort((a, b) => b.commits - a.commits || a.author.localeCompare(b.author));
}
export function byFile(commits) {
const map = new Map();
for (const c of commits) for (const f of c.files) map.set(f.path, (map.get(f.path) ?? 0) + 1);
return [...map.entries()].map(([path, changes]) => ({ path, changes }))
.sort((a, b) => b.changes - a.changes || a.path.localeCompare(b.path));
}
Acceptance for this phase: Author totals sum insertions and deletions correctly, and the file list ranks the most frequently changed file first.
Phase 4: Date aggregation
Group commits by ISO day and by weekday to show development cadence. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function byDay(commits) {
const map = new Map();
for (const c of commits) {
const day = c.date.slice(0, 10);
map.set(day, (map.get(day) ?? 0) + 1);
}
return [...map.entries()].map(([day, commits]) => ({ day, commits })).sort((a, b) => a.day.localeCompare(b.day));
}
export function byWeekday(commits) {
const counts = Object.fromEntries(WEEKDAYS.map((d) => [d, 0]));
for (const c of commits) counts[WEEKDAYS[new Date(c.date).getUTCDay()]]++;
return counts;
}
Acceptance for this phase: Day counts group by the ISO date prefix and weekday counts sum to the total commit count.
Phase 5: Table formatting
Render each aggregation as a fixed-width table with aligned columns and a header. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
export function table(rows, columns) {
const widths = columns.map((col) =>
Math.max(col.label.length, ...rows.map((r) => String(r[col.key]).length)));
const header = columns.map((col, i) => col.label.padEnd(widths[i])).join(' ');
const lines = rows.map((r) => columns.map((col, i) => String(r[col.key]).padEnd(widths[i])).join(' '));
return [header, '-'.repeat(header.length), ...lines].join('\n');
}
Acceptance for this phase: A table renders aligned columns sized to the widest cell, and an empty row set still prints the header and separator.
Phase 6: CLI and tests
Wire git log, parsing, aggregation, and formatting, and cover the parser and aggregators with tests. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
import { gitLog } from './run.js';
import { parseLog } from './parse.js';
import { byAuthor, byFile } from './aggregate.js';
import { table } from './format.js';
const [view = 'authors', ...extra] = process.argv.slice(2);
const commits = parseLog(await gitLog(process.cwd(), extra));
if (view === 'authors') {
console.log(table(byAuthor(commits), [
{ key: 'author', label: 'Author' }, { key: 'commits', label: 'Commits' },
{ key: 'insertions', label: '+' }, { key: 'deletions', label: '-' },
]));
} else if (view === 'files') {
console.log(table(byFile(commits), [{ key: 'path', label: 'File' }, { key: 'changes', label: 'Changes' }]));
} else {
console.error('usage: git-stats [authors|files|days] [git args]');
process.exit(1);
}
Acceptance for this phase: Running in a repository prints an aligned author table, and an unknown view exits with code 1 and a usage line.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Parser tests feed a fixture log excerpt and assert commit fields, numstat parsing, and binary files where insertions and deletions are dashes. Aggregator tests assert per-author totals, per-day counts, and file change counts including a file touched in two commits.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
The binary-file test matters because numstat prints dashes for those lines, and a parser that blindly coerces them to numbers would report NaN totals.
Extending the project
A date-range option is a filter before aggregation; a per-path filter is one predicate; and exporting to CSV or JSON is a new formatter over the same records. None of these touch the git runner or the parser.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.