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  }