使用 Go 撰寫 Git 統計分析 CLI 工具的教程

幾年前,我使用 Electron + Meteor.js + gitlog 桌面應用程式掃描了我的本地 Git 存儲庫,並為我提供了一個漂亮的貢獻圖,就像 GitHub.com 上顯示的那樣:

那是在每個應用程序都使用 Electron 之前,由於生成的應用程序大小,我非常不喜歡這種方法,如果與基於 WebKit 的 MacGap 相比較,它的大小要大 50 倍。不管怎樣,它看起來像這樣,具有類似 GitHub 的用戶界面:

我發現它很有用,因為不是所有的項目都在 GitHub 上,一些項目位於 BitBucket 或 GitLab 上,但我所工作的所有代碼都在我的筆記本電腦上,所以這便是“事實的單一來源”。

該應用程序仍在運行,但尚未釋放給大眾使用。

今天我決定將其作為 Go 控制台命令進行移植,因為我仍認為這個概念很好。

本文中要建立的內容 🎉

一個類似下圖的CLI命令,生成類似的圖表

在哪裡可以找到這段程式碼

該程式碼在此 Gist 鏈接上: https://gist.github.com/flaviocopes/bf2f982ee8f2ae3f455b06c7b2b03695

首先的步驟

我將任務分為兩部分:

1.獲取要掃描的文件夾列表 2.生成統計信息

我將使用 Go 命令行標記解析 來讓一個單一命令執行這兩個任務。當傳遞 -add 標記時,該命令將添加新的文件夾到列表中。在沒有標記的情況下執行該命令將生成圖表。為了避免一次性為用戶輸出過多數據,我將限制數據集的時間範圍在過去的 6 個月內。

讓我們為此分層概念撰寫一個簡單的外殼:

package main

import (
 "flag"
)

// scan given a path crawls it and its subfolders
// searching for Git repositories
func scan(path string) {
 print("scan")
}

// stats generates a nice graph of your Git contributions
func stats(email string) {
 print("stats")
}

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]](/cdn-cgi/l/email-protection)", "the email to scan")
 flag.Parse()

 if folder != "" {
 scan(folder)
 return
 }

 stats(email)
}

第 1 部分:獲取要掃描的文件夾列表

我將遵循的算法非常簡單:

這部分程序分為 2 個子部分。在第一部分中,我將遞迴掃描作為引數傳遞的文件夾,搜索存儲庫。我將在家目錄下存儲文件的存儲庫文件夾列表中存儲存儲庫文件夾列表。

讓我們看看如何填寫 scan() 函數。它基本上僅僅是三行代碼,還有一些用於輸出的代碼:

// 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")
}

這是工作流程:

1.我們從 recursiveScanFolder() 那裡獲得一個字符串片段 2.我們獲得我們將要寫入的點文件的路徑 3.我們將片段的內容寫入文件中

讓我們先從掃描文件夾開始。如果您想了解更多有關可用選項的詳細信息,我寫了一篇詳細的使用Go掃描文件夾的教程。我將不使用 filepath.Walk,因為它將進入每個文件夾。使用 ioutil.Readdir 可以更好地控制。我將跳過 vendornode_modules 文件夾,因為它們可能包含大量文件夾,而我對此不感興趣,同時我也將跳過 .git 文件夾,但當我找到 .git 文件夾時,我將它添加到片段中:

// 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
}

它明確地避免進入名為 vendornode_modules 的文件夾,因為這些文件夾可能很大,通常不會將 Git 存儲庫放在這裡,所以我們可以放心地忽略它們。

根據上述代碼,這是一個遞迴函數,需要一個空的字符串片段作為起始點:

// recursiveScanFolder starts the recursive search of git repositories
// living in the `folder` subtree
func recursiveScanFolder(folder string) []string {
 return scanGitFolders(make([]string, 0), folder)
}

工作流的第 2 部分是獲取包含存儲庫路徑數據庫的點文件的路徑:

// 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
}

該函數使用 os/user 包的 Current 函數獲取當前用戶,這是一個結構,定義如下'

// User represents a user account.
type User struct {
 // Uid is the user ID.
 // On POSIX systems, this is a decimal number representing the uid.
 // On Windows, this is a security identifier (SID) in a string format.
 // On Plan 9, this is the contents of /dev/user.
 Uid string
 // Gid is the primary group ID.
 // On POSIX systems, this is a decimal number representing the gid.
 // On Windows, this is a SID in a string format.
 // On Plan 9, this is the contents of /dev/user.
 Gid string
 // Username is the login name.
 Username string
 // Name is the user's real or display name.
 // It might be blank.
 // On POSIX systems, this is the first (or only) entry in the GECOS field
 // list.
 // On Windows, this is the user's display name.
 // On Plan 9, this is the contents of /dev/user.
 Name string
 // HomeDir is the path to the user's home directory (if they have one).
 HomeDir string
}

我們感興趣的是 HomeDir 屬性,以獲取我們的點文件的完整路徑:

dotFile := usr.HomeDir + "/.gogitlocalstats"

所以,現在我們有了一個存儲庫列表,一個要寫入的文件,而 scan() 的下一步是存儲它們,而不會添加重複的行。

過程是:

1.將現有存儲庫解析為文件內容的片段 2.將新項目添加到片段中,而不添加重複的項目 3.將片段存儲到文件中,覆蓋現有內容

這就是 addNewSliceElementsToFile() 的工作:

// addNewSliceElementsToFile given a slice of strings representing paths, stores them
// to the filesystem
// 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)
}

首先,該函數調用 parseFileLinesToSlice(),它接受文件路徑字符串並返回一個字符串切片,其中包含文件的內容。這沒什麼特別的:

// 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
}

它調用 openFile(),根據文件路徑字符串打開文件並返回它。

// 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\_WRONLY, 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
}

在這種情況下,它嘗試打開我們的點文件。如果有錯誤,並且錯誤告訴我們文件不存在(使用os.IsNotExist()),我們使用 os.Create() 創建文件,以便我們可以開始填充掃描的存儲庫。它返回打開的文件描述符。

addNewSliceElementsToFile() 在獲得文件描述符後立即延遲 f.Close() 以在函數完成後關閉文件。然後它調用 parseFileLinesToSlice(),這是一個實用程序函數,將文件的每一行解析為字符串片段。

然後,joinSlices() 將給定的2個片段添加到第二個中,只有在內容尚不存在的情況下。這可以防止重複的行。

放入:

// 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
}

// 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
}

最後一步是調用 dumpStringsSliceToFile(),給定一個字符串切片和文件路徑,將該切片寫入文件,每個字符串都在新行上:

// 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)
}

以下是此第一部分的工作示例的完整內容:

我將其放在一個單獨的文件中,以提高清晰度,名為 scan.go(與 main.go 相同的文件夾中)

第 2 部分:生成統計信息

現在是第二部分:生成統計信息!

在這個單獨的文件中,我將使用一個名為 go-git 的庫,該庫位於 https://github.com/src-d/go-git。它抽象了處理 Git 提交的內部表示的細節,提供了一個方便的 API,並且是自包含的(不需要像 libgit2 綁定 那樣的外部庫),對於我的程序來說是一個很好的折衷方案。

現在讓我們使用兩個函數來實現 stats()

// stats calculates and prints the stats.
func stats(email string) {
 commits := processRepositories(email)
 printCommitsStats(commits)
}

1.獲取提交列表 2.根據提交生成圖表

看起來很簡單。

// processRepositories given a 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
}

非常容易:

1.獲得點文件路徑 2.將點文件的每一行解析為存儲庫列表(切片) 3.將 commits 映射填充為 0 的整數值 4.遍歷存儲庫並填充 commits 映射

我將從 scan.go 文件重用 getDotFilePath()parseFileLinesToSlice()。由於包是相同的,我不需要進行任何處理,它們可以供使用。

這是 fillCommits() 的實現:

// 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
}

daysInLastSixMonths 是一個常量,定義為 const daysInLastSixMonths = 183

outOfRange 也是一個常量,定義為 const outOfRange = 99999,與 daysInLastSixMonths 相反,它沒有實際意義。它被設置為當提交比 6 個月前更新的那一刻,我們的數據分析時間間隔之前的日期,此值是 countDaysSinceDate() 的返回值。

object 是由 go-git 包提供的,通過導入 gopkg.in/src-d/go-git.v4/plumbing/object

我在列出日期之前將偏移量添加到 “daysAgo” 計算中,因為類似 GitHub 的圖表工作方式:每行代表一個日期(從星期日開始),每行表示一個星期。我可以填充當前一周的“假數據”。

countDaysSinceDate() 返回距離提交日期的天數。我重置當前日期為(00:00:00)來避免時間被考慮進計算。時區從系統中猜測。

// 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
}

calcOffset() 用於確定提交在我們的提交映射中的正確位置,以便在控制台輸出時能正確顯示。

// 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
}

現在我們處理完了提交。我們現在有了一個提交映射,我們可以打印它。這是操作中心:

// printCommitsStats prints the commits stats
func printCommitsStats(commits map[int]int) {
 keys := sortMapIntoSlice(commits)
 cols := buildCols(keys, commits)
 printCells(cols)
}

1.對映射排序 2.生成列 3.打印每列

對映射排序

// 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
}

sortMapIntoSlice() 接受一個映射,返回按鍵的整數值排序的切片。這用於正確打印映射排序。

生成列

// 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
}

buildCols() 接受我們從 sortMapIntoSlice() 獲得的鍵片段,以及該映射。它創建了一個新的映射,而不是使用天數作為鍵,而是使用星期。column 類型被定義為整數的切片:type column []int

離開的是一個簡單的結對映射,根據一周中的哪一天,這都可以通過除以 7 獲得。而在第幾周的哪一天,根據 k % 7 獲得。當一天是星期日時,我們創建一個新的列並填充它;當一天是星期六時,我們將這一周添加到列映射中。

打印單元格

// 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")
 }
}

printCells() 首先調用 printMonths() 來打印月份名稱行。然後對於每個不同的後續行(每周的一天),它處理每周並調用 printCell(),傳遞值以及它是否是今天。如果是第一列,它會調用 printDayCol() 打印日期名稱。

// 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")
}

這是 printMonths()。它從分析的歷史的開始處開始並以每週增量遞增。如果在下一週時,月份發生更改,則打印它。當我超過當前日期時中斷。

printDayCol() 非常簡單,給定一個日期行索引,打印日期名稱:

// 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)
}

printCell() 如下所示,根據單元格中的提交數量計算出正確的轉義序列,並根據要打印數字的位數標准化單元格寬度:並以參考 io.Stdout 打印單元格:

// 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)
}

這是 stats.go 的完整代碼,包含了程序的第二部分內容:

這是執行該程序時所獲得的結果: