github.com/soypat/gitaligned@v0.3.4-0.20221228122414-e435aab44fbc/gitscan.go (about) 1 package main 2 3 import ( 4 "bufio" 5 "errors" 6 "fmt" 7 "io" 8 "os/exec" 9 "strconv" 10 "strings" 11 "time" 12 ) 13 14 type commit struct { 15 alignment 16 User *author `json:"user"` 17 hasEndingPeriod, isMerge bool 18 date time.Time 19 Message string `json:"message"` 20 } 21 type author struct { 22 Commits int `json:"commits"` 23 alignment 24 accumulator alignment 25 Name string `json:"name"` 26 email string 27 } 28 29 type gitOption func([]string) []string 30 31 var doNothing gitOption = func(s []string) []string { return s } 32 33 func optionNoMerges(none bool) gitOption { 34 if none { 35 return func(s []string) []string { return append(s, "--no-merges") } 36 } 37 return doNothing 38 } 39 40 func optionAuthorPattern(author string) gitOption { 41 if author != "" { 42 return func(s []string) []string { return append(s, "--author", author) } 43 } 44 return doNothing 45 } 46 47 func optionMaxCommits(n int) gitOption { 48 return func(s []string) []string { return append(s, "-n", strconv.Itoa(n)) } 49 } 50 51 func optionBranch(b string) gitOption { 52 if b == "" { 53 b = "--all" 54 } 55 return func(s []string) []string { return append([]string{b}, s...) } 56 } 57 58 // Stats return author alignment in human readable format (with newlines) 59 func (a author) Stats() string { 60 return fmt.Sprintf("Author %v is %v\nCommits: %d\nAccumulated:%0.1g\n", 61 a.Name, a.alignment.Format(), a.Commits, a.accumulator) 62 } 63 64 // ScanCWD Scans .git in current working directory using git 65 // command. Scans up to maxCommit messages. 66 func ScanCWD(opts ...gitOption) ([]commit, []author, error) { 67 var args []string 68 for i := range opts { 69 args = opts[i](args) 70 } 71 args = append([]string{"log"}, args...) 72 cmd := exec.Command("git", args...) 73 reader, writer := io.Pipe() 74 cmd.Stdout = writer 75 cmdstderr := &strings.Builder{} 76 cmd.Stderr = cmdstderr 77 go func() { 78 cmd.Run() 79 writer.Close() 80 }() 81 commits, authors, err := GitLogScan(reader) 82 if err == io.EOF { 83 err = nil 84 } 85 errmsg := cmdstderr.String() 86 if err == nil && errmsg != "" { 87 err = errors.New(errmsg) 88 } 89 return commits, authors, err 90 } 91 92 // GitLogScan reads git log results and generates commits 93 func GitLogScan(r io.Reader) (commits []commit, authors []author, err error) { 94 rdr := bufio.NewReader(r) 95 commits = make([]commit, 0, maxCommits) 96 authors = make([]author, maxAuthors) 97 authmap := make(map[string]*author) 98 var c commit 99 var a author 100 var auth *author 101 counter := 0 102 eof := false 103 for !eof { 104 if counter >= maxCommits { 105 break 106 } 107 c, a, err = scanNextCommit(rdr) 108 if err == errSkipCommit { 109 continue 110 } 111 if err == io.EOF { 112 eof, err = true, nil 113 } 114 if err != nil { 115 break 116 } 117 auth, err = processAuthor(a, authors, authmap) 118 if err == errSkipCommit { 119 continue 120 } 121 processCommit(&c, auth) 122 commits = append(commits, c) 123 counter++ 124 } 125 if err == errSkipCommit || err == io.EOF { 126 err = nil 127 } 128 return commits, authors[0:len(authmap)], err 129 } 130 131 func processAuthor(a author, authors []author, authmap map[string]*author) (*author, error) { 132 // if author name is blank, then skip the person 133 if a.Name == "" { 134 return nil, errSkipCommit 135 } 136 // find author in list 137 author, ok := authmap[a.Name] 138 nAuthors := len(authmap) 139 if !ok { 140 if len(authmap) == len(authors) { 141 return author, errSkipCommit 142 } 143 authors[nAuthors] = a 144 author = &authors[nAuthors] 145 authmap[a.Name] = author 146 } 147 return author, nil 148 } 149 150 func processCommit(c *commit, a *author) { 151 if strings.HasSuffix(c.Message, ".") { 152 c.hasEndingPeriod = true 153 c.Message = c.Message[:len(c.Message)-1] 154 } 155 // lowering caps improves verb detection 156 c.Message = strings.ToLower(c.Message) 157 c.User = a 158 } 159 160 // errSkipCommit tells program to ignore commit message 161 var errSkipCommit = errors.New("this commit will be ignored") 162 163 func scanNextCommit(rdr *bufio.Reader) (c commit, a author, err error) { 164 var line string 165 var commitLineScanned bool 166 for { 167 line, err = scanNextLine(rdr) 168 switch { 169 case !commitLineScanned && strings.HasPrefix(line, "commit"): 170 commitLineScanned = true 171 case strings.HasPrefix(line, "Author:"): 172 a, err = parseAuthor(line[len("Author:"):]) 173 case strings.HasPrefix(line, "Date:"): 174 c.date, err = time.Parse("Mon Jan 2 15:04:05 2006 -0700", strings.TrimSpace(line[len("Date:"):])) 175 case strings.HasPrefix(line, "Merge:"): 176 c.isMerge = true 177 case strings.HasPrefix(line, "fatal:"): 178 err = errors.New(line) 179 default: 180 c.Message = appendMessage(c.Message, line) 181 } 182 if err != nil { 183 break 184 } 185 b, err := rdr.Peek(len("\ncommit")) 186 if err != nil || string(b) == "\ncommit" { 187 break 188 } 189 } 190 return c, a, err 191 } 192 193 func scanNextLine(rdr *bufio.Reader) (string, error) { 194 for { 195 b, err := rdr.ReadBytes('\n') 196 if err != nil { 197 return "", err 198 } 199 if len(b) == 1 { // no text on line 200 continue 201 } 202 return string(b[:len(b)-1]), nil 203 } 204 } 205 206 func appendMessage(msg, toAppend string) string { 207 if msg == "" { 208 return strings.TrimSpace(toAppend) 209 } 210 211 return msg + " " + strings.TrimSpace(toAppend) 212 } 213 214 func parseAuthor(s string) (author, error) { 215 mailstart := strings.Index(s, "<") 216 mailend := strings.Index(s, ">") 217 if mailstart < 1 || mailend < 3 { 218 return author{}, errors.New("bad author line:" + s) 219 } 220 return author{Name: strings.TrimSpace(s[:mailstart]), email: s[mailstart+1 : mailend]}, nil 221 }