GitHub-style contribution heatmap in your terminal. Scans local Git repos, reads commit history, prints a week/day grid with month labels.
Built by following this tutorial. It has outdated libraries and subtle bugs. This documents what broke, how I approached the fixes, and a few things I added.
- Recursively scans folders for Git repos
- Persists discovered repos to a dotfile
- Reads commit history for the last ~6 months
- Buckets commits into week/day cells
- Prints a terminal heatmap with month/day labels
- Exposes debug flags for inspecting raw bucket state
# Add a folder to scan
go run ./gitcrawl -add ~/code
# Filter by your email
go run ./gitcrawl -email me@example.com
# Show all authors
go run ./gitcrawl -email "*@*.*"
# Debug commit bucketing
go run ./gitcrawl -debug commitsDump
# Debug author counts
go run ./gitcrawl -debug authorsMap
# Clear saved repos
go run ./gitcrawl -clear ygo versionThe project expects a working Go toolchain and a local machine with Git repositories already present on disk. It scans folders you add, so it is not meant for remote GitHub URLs.
If you want to run it with your own repos, the basic flow is:
go run ./gitcrawl -add ~/projects
go run ./gitcrawl -email me@example.comThat keeps the setup simple: add local repos once, then query the graph by email.
The scanned repository paths are stored in a dotfile in your home directory:
~/.gitlocalstatsThat file is what -add and -clear operate on. It lives outside the project folder so the repo list can survive between runs and between different terminal sessions.
The program prints a terminal heatmap, so the output is mostly visual. A typical run looks like this:
The exact values depend on your local repositories and the email you pass in.
If you want to preview the output from a terminal-friendly image tool later, you can also pipe or convert it outside the program, but the core app prints directly to stdout.
- It only scans local folders that you add.
- It depends on the paths still existing on your machine.
- Output alignment depends on terminal font and width.
- Large or unusual repository layouts can still expose edge cases.
- The graph currently focuses on terminal rendering rather than exporting to image files.
The main goal was not to build a polished product UI, but to understand the data flow and the date bucketing well enough to make the output correct.
The article had two flags: -add and -email.
-debug commitsDump and -debug authorsMap let you inspect raw state without touching print statements. -clear y resets the dotfile without opening it manually. Both saved a lot of iteration time.
The article's scan() did everything internally — recursive walk, dotfile write, all in one function. Hard to test, hard to compose with new flags.
Split it: scan() returns []string, and main decides when to persist. So -clear and -add can both work after breaking up the function.
Also added explicit handling for permission denied errors and skipped vendor/ and node_modules/ directories.
The tutorial imported gopkg.in/src-d/go-git.v4, which is abandoned. Current module path is github.com/go-git/go-git/v6. API surface is mostly the same but I had to read the updated docs to find what moved.
The article panicked on any repo.Log() error. Real repo lists include broken clones. idxfile.ErrMalformedIdxFile from go-git/v6/plumbing/format/idxfile lets you detect and skip those without killing the whole run.
The article pattern:
daysAgo := countDaysSinceDate(c.Author.When) + offset
if daysAgo != outOfRange {
commits[daysAgo]++
}Problem: countDaysSinceDate returns sentinel outOfRange for commits outside the window. Adding offset to the sentinel before checking it changes the value, so the guard never fires. You get an index out of range panic.
Fix: check the sentinel before adding the offset, then add an explicit upper-bound guard after:
daysSince := countDaysSince(c.Author.When)
if daysSince != outOfRange {
howManyDaysAgo := daysSince + offset
if howManyDaysAgo <= daysInLastSixMonths {
commits[howManyDaysAgo]++
}
}The article filtered commits by exact email match. Added *@*.* as a wildcard to include all authors. Also track per-author commit counts in a map, which surfaces via -debug authorsMap when something looks off.
switch debug {
case "authorsMap":
fmt.Printf("authors not matching filter: %d\n", len(authors)-1)
fmt.Printf("%v\n", authors)
case "commitsDump":
fmt.Printf("%v\n", commits)
}The heatmap output alone is not enough to validate bucketing. When the graph looked wrong, the raw commits[] array had to be inspected before it hit the rendering logic.
This was the trickiest part. The article's bucketing didn't align with rendering, so today's cell highlighted on the wrong day and month headers were off.
The article used the commit key directly as week and day-of-week:
week := int(k / 7)
dayinweek := k % 7
if dayinweek == 0 { col = column{} }
col = append(col, commits[k])
if dayinweek == 6 { cols[week] = col }The rendering loop then printed rows 7 down to 1. The index math didn't match. I printed raw bucket contents during render to see what was happening:
for week, colm := range cols {
fmt.Printf("Week: %d\tvalues: %v\n", week, colm)
}That confirmed the mismatch. The bucketing was then rebuilt to match the render order:
dayOfWeek := k % 7 // 1:saturday, 2:friday ... 0:sunday
if dayOfWeek == 1 { col = column{} }
col = append(col, commits[k])
if dayOfWeek == 0 {
cols[week_counter] = col
week_counter++
}Now when rendering does colValue[7-row], the indices are actually correct.
Small fixes that followed from the bucketing fix:
- Today highlight:
col == 1 && row == 8-offsetinstead ofi == 0 && j == calcOffset()-1 - Month advancement:
initDate.AddDate(0, 0, 7)instead ofweek.Add(7 * time.Hour * 24)— the latter doesn't account for DST - Day labels on Mon/Wed/Fri only to avoid crowding
It was incomplete in the right ways. The library was outdated but the data structures were sound, so fixes were local. The bugs were real but not impossible. The week/day math wasn't explained, which forced me to think through the rendering logic and actually understand it instead of just running the code.
