Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save JackZhang2022/6223bff4a57b1d6c9cbcbc261aa6e27a to your computer and use it in GitHub Desktop.
Save JackZhang2022/6223bff4a57b1d6c9cbcbc261aa6e27a 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"
"fmt"
)
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", "", "the email to scan")
flag.Parse()
if folder != "" {
scan(folder)
return
}
if email != "" {
stats(email)
} else {
fmt.Print("Please pass a email by `-email`!")
}
}
package main
import (
"bufio"
"fmt"
"io"
"log"
"os"
"os/user"
"strings"
)
const rwxrxrx = 0755
// Scans a folder for Git repositories recursively.
func scan(folder string) {
fmt.Printf("Found folders:\n\n")
repos := recursiveScanFolder(folder)
addNewReposToFile(repos)
fmt.Printf("\n\nSuccessfully added\n\n")
}
// Starts the recursive search of git repositories in the `folder`.
func recursiveScanFolder(folder string) []string {
return scanGitFolders(make([]string, 0), folder)
}
// Returns the base folder of the repo, the .git folder parent.
func scanGitFolders(repos []string, folder string) []string {
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)
repos = append(repos, path)
continue
}
if file.Name() == "vendor" || file.Name() == "node_modules" {
continue
}
repos = scanGitFolders(repos, path)
}
}
return repos
}
// Add newRepos to ReposFile.
func addNewReposToFile(newRepos []string) {
filePath := getReposFilePath()
existedRepos := parseRepos(filePath)
repos := joinNewRepo(newRepos, existedRepos)
dumpReposToFile(repos, filePath)
}
// Return file path that stores folder paths which exist repos.
func getReposFilePath() string {
usr, err := user.Current()
if err != nil {
log.Fatal(err)
}
return usr.HomeDir + "/.gogitlocalstats"
}
// Parse all repo paths to string array.
func parseRepos(filePath string) []string {
f := openFile(filePath)
defer f.Close()
var repos []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
repos = append(repos, scanner.Text())
}
if err := scanner.Err(); err != nil {
if err != io.EOF {
panic(err)
}
}
return repos
}
// Open the file located at `filePath`. Create it if not existed.
func openFile(filePath string) *os.File {
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_RDWR, rwxrxrx)
if err != nil {
if os.IsNotExist(err) {
_, err = os.Create(filePath)
if err != nil {
panic(err)
}
return openFile(filePath)
} else {
panic(err)
}
}
return f
}
// Appends `newRepo` when it's not in `existedRepos`.
func joinNewRepo(newRepos []string, existedRepos []string) []string {
for _, newRepo := range newRepos {
if !isExisted(existedRepos, newRepo) {
existedRepos = append(existedRepos, newRepo)
}
}
return existedRepos
}
// Return true if `newRepo` in existedRepos, otherwise false.
func isExisted(existedRepos []string, newRepo string) bool {
for _, existedRepo := range existedRepos {
if existedRepo == newRepo {
return true
}
}
return false
}
// Writes content to the file in path `filePath` (overwriting existed content).
func dumpReposToFile(repos []string, filePath string) {
content := strings.Join(repos, "\n")
os.WriteFile(filePath, []byte(content), rwxrxrx)
}
package main
import (
"errors"
"fmt"
"sort"
"time"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing/object"
)
// Calculates and prints the stats.
func stats(email string) {
commits := calcCommitsStats(email)
table := fillTable(commits)
printTable(table)
}
// Given an user email, returns the commits made in the last 6 months.
func calcCommitsStats(email string) map[int]int {
filePath := getReposFilePath()
repos := parseRepos(filePath)
totalDays := calcDaysInLastSixMonths()
commits := make(map[int]int, totalDays)
for i := 0; i < totalDays; i++ {
commits[i] = 0
}
for _, path := range repos {
commits = fillCommits(email, path, commits)
}
return commits
}
func calcDaysInLastSixMonths() int {
// 获取当前日期
currentDate := time.Now()
currentYear, currentMonth, currentDay := currentDate.Date()
// 计算总天数
totalDays := 0
for i := 0; i < 6; i++ {
// 计算月份
month := int(currentMonth) - i
year := currentYear
// 如果月份小于1,则年份减1,月份加上12
if month < 1 {
month += 12
year--
}
// 计算该月的天数
daysInMonth := daysInMonth(year, int(month))
// 如果是当前月,则只计算到当前日期的天数
if i == 0 {
daysInMonth = currentDay
}
// 累加天数
totalDays += daysInMonth
}
return totalDays
}
func daysInMonth(year int, month int) int {
daysPerMonth := [12]int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
if month == 2 && ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) {
return 29
}
return daysPerMonth[month - 1]
}
// Counts commits by email and daysAgo in the repo.
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
err = iterator.ForEach(func(c *object.Commit) error {
if c.Author.Email != email {
return nil
}
days, err := countDaysSinceDate(c.Author.When)
if err == nil {
commits[days]++
}
return nil
})
if err != nil {
panic(err)
}
return commits
}
// Counts how many days passed since the passed `date`
func countDaysSinceDate(date time.Time) (int, error) {
days := 0
daysInLastSixMonths := calcDaysInLastSixMonths()
nowBegin := getBeginningOfDay(time.Now())
for date.Before(nowBegin) {
date = date.Add(time.Hour * 24)
days++
if days >= daysInLastSixMonths {
return -1, errors.New("日期超出统计范围")
}
}
return days, nil
}
// 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
}
type column []int
func fillTable(commits map[int]int) map[int]column {
totalWeeks := calcWeeksInLastSixMonths()
table := make(map[int]column, totalWeeks)
for i := 0; i < totalWeeks; i++ {
table[i] = make(column, 7)
for j := 0; j < 7; j++ {
table[i][j] = 0
}
}
keys := sortKeys(commits)
for daysAgo := range keys {
weekId := calcWeekId(daysAgo, totalWeeks)
weekday := calcWeekday(daysAgo)
table[weekId][weekday] = commits[daysAgo]
}
return table
}
func calcWeeksInLastSixMonths() int {
currentWeekday := time.Now().Weekday()
weeksInLastSixMonths := 1 // 当前日期占1周
totalDays := calcDaysInLastSixMonths()
weeksInLastSixMonths += (totalDays - int(currentWeekday)) / 7
if (totalDays - int(currentWeekday)) % 7 > 0 {
weeksInLastSixMonths++;
}
return weeksInLastSixMonths
}
// Returns ordered indexes of the map.
func sortKeys(m map[int]int) []int {
var keys []int
for k := range m {
keys = append(keys, k)
}
sort.Ints(keys)
return keys
}
func calcWeekId(daysAgo int, totalWeeks int) int {
currentWeekday := time.Now().Weekday()
if daysAgo < int(currentWeekday) {
return totalWeeks - 1
} else {
daysAgo -= int(currentWeekday)
weeksToCurrent := daysAgo / 7
if daysAgo % 7 > 0 {
weeksToCurrent++
}
return totalWeeks - 1 - weeksToCurrent
}
}
func calcWeekday(daysAgo int) int {
currentWeekday := time.Now().Weekday()
if daysAgo <= int(currentWeekday) {
return int(currentWeekday) - daysAgo
} else {
reverseWeekday := (daysAgo - int(currentWeekday) - 1) % 7
return 6 - reverseWeekday
}
}
func printTable(table map[int]column) {
totalWeeks := calcWeeksInLastSixMonths()
currentWeekday := time.Now().Weekday()
printMonths()
for weekday := 6; weekday >= 0; weekday-- {
printWeekdayName(weekday)
for weekId := 0; weekId < totalWeeks; weekId++ {
if weekday == int(currentWeekday) && weekId == totalWeeks - 1 {
printCell(table[weekId][weekday], true)
} else {
printCell(table[weekId][weekday], false)
}
}
fmt.Printf("\n")
}
}
// Prints the last 6 month names in the first line
func printMonths() {
totalDays := calcDaysInLastSixMonths()
currentWeekday := time.Now().Weekday()
offset := 7 - ((totalDays - int(currentWeekday)) % 7)
date := getBeginningOfDay(time.Now()).Add(-(time.Duration(totalDays + offset - 1) * time.Hour * 24))
realDate := getBeginningOfDay(time.Now()).Add(-(time.Duration(totalDays - 1) * time.Hour * 24))
month := realDate.Month()
fmt.Printf(" ")
fmt.Printf("%s ", date.Month().String()[:3])
date = date.Add(7 * time.Hour * 24)
for {
if date.Month() != month {
fmt.Printf("%s ", date.Month().String()[:3])
month = date.Month()
} else {
fmt.Printf(" ")
}
date = date.Add(7 * time.Hour * 24)
if date.After(time.Now()) {
break
}
}
fmt.Printf("\n")
}
// Given the day number (0 is Sunday), prints the day name.
// alternating the rows (prints just 2,4,6)
func printWeekdayName(day int) {
out := " "
switch day {
case 1:
out = " Mon "
case 3:
out = " Wed "
case 5:
out = " Fri "
}
fmt.Print(out)
}
// 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)
}

Requirements:

  • Go. Download and install go installer from official website.
  • 3rd-party Modules. Run commands below:
    go mod init gogitlocalstats
    go mod tidy

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 sure to pass an email as param makes it possible to scan repos for collaborators activity as well.

License: CC BY-SA 4.0

@JackZhang2022
Copy link
Author

go version 1.22.5

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