Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 41 You must be signed in to star a gist
  • Fork 11 You must be signed in to fork a gist
  • Save flaviocopes/bf2f982ee8f2ae3f455b06c7b2b03695 to your computer and use it in GitHub Desktop.
Save flaviocopes/bf2f982ee8f2ae3f455b06c7b2b03695 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", "your@email.com", "the email to scan")
flag.Parse()
if folder != "" {
scan(folder)
return
}
stats(email)
}
package main
import (
"bufio"
"fmt"
"io"
"io/ioutil"
"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")
ioutil.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 outOfRange = 99999
const daysInLastSixMonths = 183
const weeksInLastSixMonths = 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 > daysInLastSixMonths {
return outOfRange
}
}
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 != outOfRange {
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 := daysInLastSixMonths
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 := weeksInLastSixMonths + 1; i >= 0; i-- {
if i == weeksInLastSixMonths+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.Printf("\n")
}
}
// printMonths prints the month names in the first line, determining when the month
// changed between switching weeks
func printMonths() {
week := getBeginningOfDay(time.Now()).Add(-(daysInLastSixMonths * time.Hour * 24))
month := week.Month()
fmt.Printf(" ")
for {
if week.Month() != month {
fmt.Printf("%s ", week.Month().String()[:3])
month = week.Month()
} else {
fmt.Printf(" ")
}
week = week.Add(7 * time.Hour * 24)
if week.After(time.Now()) {
break
}
}
fmt.Printf("\n")
}
// 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.Printf(out)
}

Run go install and

  • gogitlocalstats -add /path/to/folder will scan that folder and its subdirectories for repositories to scan
  • gogitlocalstats -email your@email.com 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.

License: CC BY-SA 4.0

@JhndaCoder
Copy link

I didn't understand how to run this project. I did go install but when I tried to use the command gogitlocalstats my terminal don't recognized this.

Try Running it via go run main.go

@JhndaCoder
Copy link

please use github.com/go-git/go-git/v5 instead of gopkg.in/src-d/go-git.v4

can u suggest the changes in the code too are to be made

@JhndaCoder
Copy link

JhndaCoder commented Oct 30, 2022

cannot use (func(c *object.Commit) error literal) (value of type func(c *"github.com/go-git/go-git/plumbing/object".Commit) error) as func(*"github.com/go-git/go-git/v5/plumbing/object".Commit) error value in argument to iterator.ForEach

Is anybody facing this issue ??

Code:

err = iterator.ForEach(func(c *object.Commit) error {
		daysAgo := countDaysSinceDate(c.Author.When) + offset

		if c.Author.Email != email {
			return nil
		}

		if daysAgo != outOfRange {
			commits[daysAgo]++
		}

		return nil
	})

@ylz-at
Copy link

ylz-at commented Nov 2, 2022

@alexiscampan
Copy link

you also need to change github.com/go-git/go-git/plumbing/object into gopkg.in/src-d/go-git.v4/plumbing/object and then it will works

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