Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save garvit-exe/8bca1b94099991b4e1bf32cbe51c871b to your computer and use it in GitHub Desktop.
Save garvit-exe/8bca1b94099991b4e1bf32cbe51c871b to your computer and use it in GitHub Desktop.
Visualize your local Git contributions with Go. https://flaviocopes.com/go-git-contributions/
package main
import (
"flag"
)
func main() {
var folder string
var email string
flag.StringVar(&folder, "add", "", "add a new folder to scan for Git repositories")
flag.StringVar(&email, "email", "[email protected]", "the email to scan")
flag.Parse()
if folder != "" {
scan(folder)
return
}
stats(email)
}
package main
import (
"bufio"
"fmt"
"io"
"log"
"os"
"os/user"
"strings"
)
// getDotFilePath returns the dot file for the repos list.
// Creates it and the enclosing folder if it does not exist.
func getDotFilePath() string {
usr, err := user.Current()
if err != nil {
log.Fatal(err)
}
dotFile := usr.HomeDir + "/.gogitlocalstats"
return dotFile
}
// openFile opens the file located at `filePath`. Creates it if not existing.
func openFile(filePath string) *os.File {
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_RDWR, 0755)
if err != nil {
if os.IsNotExist(err) {
// file does not exist
_, err = os.Create(filePath)
if err != nil {
panic(err)
}
} else {
// other error
panic(err)
}
}
return f
}
// parseFileLinesToSlice given a file path string, gets the content
// of each line and parses it to a slice of strings.
func parseFileLinesToSlice(filePath string) []string {
f := openFile(filePath)
defer f.Close()
var lines []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
if err != io.EOF {
panic(err)
}
}
return lines
}
// sliceContains returns true if `slice` contains `value`
func sliceContains(slice []string, value string) bool {
for _, v := range slice {
if v == value {
return true
}
}
return false
}
// joinSlices adds the element of the `new` slice
// into the `existing` slice, only if not already there
func joinSlices(new []string, existing []string) []string {
for _, i := range new {
if !sliceContains(existing, i) {
existing = append(existing, i)
}
}
return existing
}
// dumpStringsSliceToFile writes content to the file in path `filePath` (overwriting existing content)
func dumpStringsSliceToFile(repos []string, filePath string) {
content := strings.Join(repos, "\n")
os.WriteFile(filePath, []byte(content), 0755)
}
// addNewSliceElementsToFile given a slice of strings representing paths, stores them
// to the filesystem
func addNewSliceElementsToFile(filePath string, newRepos []string) {
existingRepos := parseFileLinesToSlice(filePath)
repos := joinSlices(newRepos, existingRepos)
dumpStringsSliceToFile(repos, filePath)
}
// recursiveScanFolder starts the recursive search of git repositories
// living in the `folder` subtree
func recursiveScanFolder(folder string) []string {
return scanGitFolders(make([]string, 0), folder)
}
// scan scans a new folder for Git repositories
func scan(folder string) {
fmt.Printf("Found folders:\n\n")
repositories := recursiveScanFolder(folder)
filePath := getDotFilePath()
addNewSliceElementsToFile(filePath, repositories)
fmt.Printf("\n\nSuccessfully added\n\n")
}
// scanGitFolders returns a list of subfolders of `folder` ending with `.git`.
// Returns the base folder of the repo, the .git folder parent.
// Recursively searches in the subfolders by passing an existing `folders` slice.
func scanGitFolders(folders []string, folder string) []string {
// trim the last `/`
folder = strings.TrimSuffix(folder, "/")
f, err := os.Open(folder)
if err != nil {
log.Fatal(err)
}
files, err := f.Readdir(-1)
f.Close()
if err != nil {
log.Fatal(err)
}
var path string
for _, file := range files {
if file.IsDir() {
path = folder + "/" + file.Name()
if file.Name() == ".git" {
path = strings.TrimSuffix(path, "/.git")
fmt.Println(path)
folders = append(folders, path)
continue
}
if file.Name() == "vendor" || file.Name() == "node_modules" {
continue
}
folders = scanGitFolders(folders, path)
}
}
return folders
}
package main
import (
"fmt"
"sort"
"time"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing/object"
)
const OUT_OF_RANGE = 99999
const DAYS_IN_LAST_SIX_MONTHS = 183
const WEEKS_IN_LAST_SIX_MONTHS = 26
type column []int
// stats calculates and prints the stats.
func stats(email string) {
commits := processRepositories(email)
printCommitsStats(commits)
}
// getBeginningOfDay given a time.Time calculates the start time of that day
func getBeginningOfDay(t time.Time) time.Time {
year, month, day := t.Date()
startOfDay := time.Date(year, month, day, 0, 0, 0, 0, t.Location())
return startOfDay
}
// countDaysSinceDate counts how many days passed since the passed `date`
func countDaysSinceDate(date time.Time) int {
days := 0
now := getBeginningOfDay(time.Now())
for date.Before(now) {
date = date.Add(time.Hour * 24)
days++
if days > DAYS_IN_LAST_SIX_MONTHS {
return OUT_OF_RANGE
}
}
return days
}
// fillCommits given a repository found in `path`, gets the commits and
// puts them in the `commits` map, returning it when completed
func fillCommits(email string, path string, commits map[int]int) map[int]int {
// instantiate a git repo object from path
repo, err := git.PlainOpen(path)
if err != nil {
panic(err)
}
// get the HEAD reference
ref, err := repo.Head()
if err != nil {
panic(err)
}
// get the commits history starting from HEAD
iterator, err := repo.Log(&git.LogOptions{From: ref.Hash()})
if err != nil {
panic(err)
}
// iterate the commits
offset := calcOffset()
err = iterator.ForEach(func(c *object.Commit) error {
daysAgo := countDaysSinceDate(c.Author.When) + offset
if c.Author.Email != email {
return nil
}
if daysAgo != OUT_OF_RANGE {
commits[daysAgo]++
}
return nil
})
if err != nil {
panic(err)
}
return commits
}
// processRepositories given an user email, returns the
// commits made in the last 6 months
func processRepositories(email string) map[int]int {
filePath := getDotFilePath()
repos := parseFileLinesToSlice(filePath)
daysInMap := DAYS_IN_LAST_SIX_MONTHS
commits := make(map[int]int, daysInMap)
for i := daysInMap; i > 0; i-- {
commits[i] = 0
}
for _, path := range repos {
commits = fillCommits(email, path, commits)
}
return commits
}
// calcOffset determines and returns the amount of days missing to fill
// the last row of the stats graph
func calcOffset() int {
var offset int
weekday := time.Now().Weekday()
switch weekday {
case time.Sunday:
offset = 7
case time.Monday:
offset = 6
case time.Tuesday:
offset = 5
case time.Wednesday:
offset = 4
case time.Thursday:
offset = 3
case time.Friday:
offset = 2
case time.Saturday:
offset = 1
}
return offset
}
// printCell given a cell value prints it with a different format
// based on the value amount, and on the `today` flag.
func printCell(val int, today bool) {
escape := "\033[0;37;30m"
switch {
case val > 0 && val < 5:
escape = "\033[1;30;47m"
case val >= 5 && val < 10:
escape = "\033[1;30;43m"
case val >= 10:
escape = "\033[1;30;42m"
}
if today {
escape = "\033[1;37;45m"
}
if val == 0 {
fmt.Printf(escape + " - " + "\033[0m")
return
}
str := " %d "
switch {
case val >= 10:
str = " %d "
case val >= 100:
str = "%d "
}
fmt.Printf(escape+str+"\033[0m", val)
}
// printCommitsStats prints the commits stats
func printCommitsStats(commits map[int]int) {
keys := sortMapIntoSlice(commits)
cols := buildCols(keys, commits)
printCells(cols)
}
// sortMapIntoSlice returns a slice of indexes of a map, ordered
func sortMapIntoSlice(m map[int]int) []int {
// order map
// To store the keys in slice in sorted order
var keys []int
for k := range m {
keys = append(keys, k)
}
sort.Ints(keys)
return keys
}
// buildCols generates a map with rows and columns ready to be printed to screen
func buildCols(keys []int, commits map[int]int) map[int]column {
cols := make(map[int]column)
col := column{}
for _, k := range keys {
week := int(k / 7) //26,25...1
dayinweek := k % 7 // 0,1,2,3,4,5,6
if dayinweek == 0 { //reset
col = column{}
}
col = append(col, commits[k])
if dayinweek == 6 {
cols[week] = col
}
}
return cols
}
// printCells prints the cells of the graph
func printCells(cols map[int]column) {
printMonths()
for j := 6; j >= 0; j-- {
for i := WEEKS_IN_LAST_SIX_MONTHS + 1; i >= 0; i-- {
if i == WEEKS_IN_LAST_SIX_MONTHS+1 {
printDayCol(j)
}
if col, ok := cols[i]; ok {
//special case today
if i == 0 && j == calcOffset()-1 {
printCell(col[j], true)
continue
} else {
if len(col) > j {
printCell(col[j], false)
continue
}
}
}
printCell(0, false)
}
fmt.Println()
}
}
// printMonths prints the month names in the first line, determining when the month
// changed between switching weeks
func printMonths() {
week := getBeginningOfDay(time.Now()).Add(-(DAYS_IN_LAST_SIX_MONTHS * time.Hour * 24))
month := week.Month()
fmt.Print(" ")
for {
if week.Month() != month {
fmt.Printf("%s ", week.Month().String()[:3])
month = week.Month()
} else {
fmt.Print(" ")
}
week = week.Add(7 * time.Hour * 24)
if week.After(time.Now()) {
break
}
}
fmt.Println()
}
// printDayCol given the day number (0 is Sunday) prints the day name,
// alternating the rows (prints just 2,4,6)
func printDayCol(day int) {
out := " "
switch day {
case 1:
out = " Mon "
case 3:
out = " Wed "
case 5:
out = " Fri "
}
fmt.Print(out)
}
Run `go install` and

- `gogitlocalstats -add /path/to/folder` will scan that folder and its subdirectories for repositories to scan
- `gogitlocalstats -email [email protected]` will generate a CLI stats graph representing the last 6 months of activity for the passed email. You can configure the default in `main.go`, so you can run `gogitlocalstats` without parameters.

Being able to pass an email as param makes it possible to scan repos for collaborators activity as well.

Above statements are not working.

  • Use go run *.go -add "/path/to/folder" and go run *.go -email "[email protected]" instead.

  • github.com/go-git/go-git/v5 doesn't seem to work, thus, it still uses gopkg.in/src-d/go-git.v4.

  • You may need to setup a Go module using go mod init you-folder-name inorder to install go-git the recommended way, i.e., go get gopkg.in/src-d/go-git.v4. This is because of the deprecation of go get for installing packages outside of a Go module.

  • Uses os.WriteFile instead of ioutil.WriteFile due to it being deprecated.

License: CC BY-SA 4.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment