зеркало из https://github.com/golang/build.git
577 строки
14 KiB
Go
577 строки
14 KiB
Go
// Copyright 2017 The Go Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package maintner
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os/exec"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/build/internal/envutil"
|
|
"golang.org/x/build/internal/foreach"
|
|
"golang.org/x/build/maintner/maintpb"
|
|
)
|
|
|
|
// GitHash is a git commit in binary form (NOT hex form).
|
|
// They are currently always 20 bytes long. (for SHA-1 refs)
|
|
// That may change in the future.
|
|
type GitHash string
|
|
|
|
func (h GitHash) String() string { return fmt.Sprintf("%x", string(h)) }
|
|
|
|
// requires c.mu be held for writing
|
|
func (c *Corpus) gitHashFromHexStr(s string) GitHash {
|
|
if len(s) != 40 {
|
|
panic(fmt.Sprintf("bogus git hash %q", s))
|
|
}
|
|
var buf [40]byte
|
|
copy(buf[:], s)
|
|
_, err := hex.Decode(buf[:20], buf[:]) // aliasing is safe
|
|
if err != nil {
|
|
panic(fmt.Sprintf("bogus git hash %q: %v", s, err))
|
|
}
|
|
return GitHash(c.strb(buf[:20]))
|
|
}
|
|
|
|
// requires c.mu be held for writing
|
|
func (c *Corpus) gitHashFromHex(s []byte) GitHash {
|
|
if len(s) != 40 {
|
|
panic(fmt.Sprintf("bogus git hash %q", s))
|
|
}
|
|
var buf [20]byte
|
|
_, err := hex.Decode(buf[:], s)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("bogus git hash %q: %v", s, err))
|
|
}
|
|
return GitHash(c.strb(buf[:20]))
|
|
}
|
|
|
|
// placeholderCommitter is a sentinel value for GitCommit.Committer to
|
|
// mean that the GitCommit is a placeholder. It's used for commits we
|
|
// know should exist (because they're referenced as parents) but we
|
|
// haven't yet seen in the log.
|
|
var placeholderCommitter = new(GitPerson)
|
|
|
|
// GitCommit represents a single commit in a git repository.
|
|
type GitCommit struct {
|
|
Hash GitHash
|
|
Tree GitHash
|
|
Parents []*GitCommit
|
|
Author *GitPerson
|
|
AuthorTime time.Time
|
|
Committer *GitPerson
|
|
Reviewer *GitPerson
|
|
CommitTime time.Time
|
|
Msg string // Commit message subject and body
|
|
Files []*maintpb.GitDiffTreeFile
|
|
GerritMeta *GerritMeta // non-nil if it's a Gerrit NoteDB meta commit
|
|
}
|
|
|
|
func (gc *GitCommit) String() string {
|
|
if gc == nil {
|
|
return "<nil *GitCommit>"
|
|
}
|
|
return fmt.Sprintf("{GitCommit %s}", gc.Hash)
|
|
}
|
|
|
|
// HasAncestor reports whether gc contains the provided ancestor
|
|
// commit in gc's history.
|
|
func (gc *GitCommit) HasAncestor(ancestor *GitCommit) bool {
|
|
return gc.hasAncestor(ancestor, make(map[*GitCommit]bool))
|
|
}
|
|
|
|
func (gc *GitCommit) hasAncestor(ancestor *GitCommit, checked map[*GitCommit]bool) bool {
|
|
if v, ok := checked[gc]; ok {
|
|
return v
|
|
}
|
|
checked[gc] = false
|
|
for _, pc := range gc.Parents {
|
|
if pc == nil {
|
|
panic("nil parent")
|
|
}
|
|
if pc.Committer == placeholderCommitter {
|
|
log.Printf("WARNING: hasAncestor(%q, %q) found parent %q with placeholder parent", gc.Hash, ancestor.Hash, pc.Hash)
|
|
}
|
|
if pc.Hash == ancestor.Hash || pc.hasAncestor(ancestor, checked) {
|
|
checked[gc] = true
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Summary returns the first line of the commit message.
|
|
func (gc *GitCommit) Summary() string {
|
|
s := gc.Msg
|
|
if i := strings.IndexByte(s, '\n'); i != -1 {
|
|
s = s[:i]
|
|
}
|
|
s = strings.TrimSpace(s)
|
|
return s
|
|
}
|
|
|
|
// SameDiffStat reports whether gc has the same diff stat numbers as b.
|
|
// If either is unknown, false is returned.
|
|
func (gc *GitCommit) SameDiffStat(b *GitCommit) bool {
|
|
if len(gc.Files) != len(b.Files) {
|
|
return false
|
|
}
|
|
for i, af := range gc.Files {
|
|
bf := b.Files[i]
|
|
if af == nil || bf == nil {
|
|
return false
|
|
}
|
|
if *af != *bf {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// GitPerson is a person in a git commit.
|
|
type GitPerson struct {
|
|
Str string // "Foo Bar <foo@bar.com>"
|
|
}
|
|
|
|
// Email returns the GitPerson's email address only, without the name
|
|
// or angle brackets.
|
|
func (p *GitPerson) Email() string {
|
|
lt := strings.IndexByte(p.Str, '<')
|
|
gt := strings.IndexByte(p.Str, '>')
|
|
if lt < 0 || gt < lt {
|
|
return ""
|
|
}
|
|
return p.Str[lt+1 : gt]
|
|
}
|
|
|
|
func (p *GitPerson) Name() string {
|
|
i := strings.IndexByte(p.Str, '<')
|
|
if i < 0 {
|
|
return p.Str
|
|
}
|
|
return strings.TrimSpace(p.Str[:i])
|
|
}
|
|
|
|
// String implements fmt.Stringer.
|
|
func (p *GitPerson) String() string { return p.Str }
|
|
|
|
// requires c.mu be held for writing.
|
|
func (c *Corpus) enqueueCommitLocked(h GitHash) {
|
|
if _, ok := c.gitCommit[h]; ok {
|
|
return
|
|
}
|
|
if c.gitCommitTodo == nil {
|
|
c.gitCommitTodo = map[GitHash]bool{}
|
|
}
|
|
c.gitCommitTodo[h] = true
|
|
}
|
|
|
|
// syncGitCommits polls for git commits in a directory.
|
|
func (c *Corpus) syncGitCommits(ctx context.Context, conf polledGitCommits, loop bool) error {
|
|
cmd := exec.CommandContext(ctx, "git", "show-ref", "refs/remotes/origin/master")
|
|
envutil.SetDir(cmd, conf.dir)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
outs := strings.TrimSpace(string(out))
|
|
if outs == "" {
|
|
return fmt.Errorf("no remote found for refs/remotes/origin/master")
|
|
}
|
|
ref := strings.Fields(outs)[0]
|
|
c.mu.Lock()
|
|
refHash := c.gitHashFromHexStr(ref)
|
|
c.enqueueCommitLocked(refHash)
|
|
c.mu.Unlock()
|
|
|
|
idle := false
|
|
for {
|
|
hash := c.gitCommitToIndex()
|
|
if hash == "" {
|
|
if !loop {
|
|
return nil
|
|
}
|
|
if !idle {
|
|
log.Printf("All git commits index for %v; idle.", conf.repo)
|
|
idle = true
|
|
}
|
|
time.Sleep(5 * time.Second)
|
|
continue
|
|
}
|
|
if err := c.indexCommit(conf, hash); err != nil {
|
|
log.Printf("Error indexing %v: %v", hash, err)
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
// TODO: temporary vs permanent failure? reschedule? fail hard?
|
|
// For now just loop with a sleep.
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// returns nil if no work.
|
|
func (c *Corpus) gitCommitToIndex() GitHash {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
for hash := range c.gitCommitTodo {
|
|
if _, ok := c.gitCommit[hash]; !ok {
|
|
return hash
|
|
}
|
|
log.Printf("Warning: git commit %v in todo map, but already known; ignoring", hash)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var (
|
|
nlnl = []byte("\n\n")
|
|
parentSpace = []byte("parent ")
|
|
authorSpace = []byte("author ")
|
|
committerSpace = []byte("committer ")
|
|
treeSpace = []byte("tree ")
|
|
golangHgSpace = []byte("golang-hg ")
|
|
gpgSigSpace = []byte("gpgsig ")
|
|
encodingSpace = []byte("encoding ")
|
|
space = []byte(" ")
|
|
)
|
|
|
|
func parseCommitFromGit(dir string, hash GitHash) (*maintpb.GitCommit, error) {
|
|
cmd := exec.Command("git", "cat-file", "commit", hash.String())
|
|
envutil.SetDir(cmd, dir)
|
|
catFile, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("git cat-file -p %v: %v", hash, err)
|
|
}
|
|
cmd = exec.Command("git", "diff-tree", "--numstat", hash.String())
|
|
envutil.SetDir(cmd, dir)
|
|
diffTreeOut, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("git diff-tree --numstat %v: %v", hash, err)
|
|
}
|
|
|
|
diffTree := &maintpb.GitDiffTree{}
|
|
bs := bufio.NewScanner(bytes.NewReader(diffTreeOut))
|
|
lineNum := 0
|
|
for bs.Scan() {
|
|
line := strings.TrimSpace(bs.Text())
|
|
lineNum++
|
|
if lineNum == 1 && line == hash.String() {
|
|
continue
|
|
}
|
|
f := strings.Fields(line)
|
|
// A line is like: <added> WS+ <deleted> WS+ <filename>
|
|
// Where <added> or <deleted> can be '-' to mean binary.
|
|
// The filename could contain spaces.
|
|
// 49 8 maintner/maintner.go
|
|
// Or:
|
|
// 49 8 some/name with spaces.txt
|
|
if len(f) < 3 {
|
|
continue
|
|
}
|
|
binary := f[0] == "-" || f[1] == "-"
|
|
added, _ := strconv.ParseInt(f[0], 10, 64)
|
|
deleted, _ := strconv.ParseInt(f[1], 10, 64)
|
|
file := strings.TrimPrefix(line, f[0])
|
|
file = strings.TrimSpace(file)
|
|
file = strings.TrimPrefix(file, f[1])
|
|
file = strings.TrimSpace(file)
|
|
|
|
diffTree.File = append(diffTree.File, &maintpb.GitDiffTreeFile{
|
|
File: file,
|
|
Added: added,
|
|
Deleted: deleted,
|
|
Binary: binary,
|
|
})
|
|
}
|
|
if err := bs.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
commit := &maintpb.GitCommit{
|
|
Raw: catFile,
|
|
DiffTree: diffTree,
|
|
}
|
|
switch len(hash) {
|
|
case 20:
|
|
commit.Sha1 = hash.String()
|
|
default:
|
|
return nil, fmt.Errorf("unsupported git hash %q", hash.String())
|
|
}
|
|
return commit, nil
|
|
}
|
|
|
|
func (c *Corpus) indexCommit(conf polledGitCommits, hash GitHash) error {
|
|
if conf.repo == nil {
|
|
panic("bogus config; nil repo")
|
|
}
|
|
commit, err := parseCommitFromGit(conf.dir, hash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m := &maintpb.Mutation{
|
|
Git: &maintpb.GitMutation{
|
|
Repo: conf.repo,
|
|
Commit: commit,
|
|
},
|
|
}
|
|
c.addMutation(m)
|
|
return nil
|
|
}
|
|
|
|
// c.mu is held for writing.
|
|
func (c *Corpus) processGitMutation(m *maintpb.GitMutation) {
|
|
commit := m.Commit
|
|
if commit == nil {
|
|
return
|
|
}
|
|
// TODO: care about m.Repo?
|
|
c.processGitCommit(commit)
|
|
}
|
|
|
|
// c.mu is held for writing.
|
|
func (c *Corpus) processGitCommit(commit *maintpb.GitCommit) (*GitCommit, error) {
|
|
if c.gitCommit == nil {
|
|
c.gitCommit = map[GitHash]*GitCommit{}
|
|
}
|
|
if len(commit.Sha1) != 40 {
|
|
return nil, fmt.Errorf("bogus git sha1 %q", commit.Sha1)
|
|
}
|
|
hash := c.gitHashFromHexStr(commit.Sha1)
|
|
|
|
catFile := commit.Raw
|
|
i := bytes.Index(catFile, nlnl)
|
|
if i == 0 {
|
|
return nil, fmt.Errorf("commit %v lacks double newline", hash)
|
|
}
|
|
hdr, msg := catFile[:i], catFile[i+2:]
|
|
gc := &GitCommit{
|
|
Hash: hash,
|
|
Parents: make([]*GitCommit, 0, bytes.Count(hdr, parentSpace)),
|
|
Msg: c.strb(msg),
|
|
}
|
|
|
|
// The commit message contains the reviewer email address. Sample commit message:
|
|
// Update patch set 1
|
|
//
|
|
// Patch Set 1: Code-Review+2
|
|
//
|
|
// Patch-set: 1
|
|
// Reviewer: Ian Lance Taylor <5206@62eb7196-b449-3ce5-99f1-c037f21e1705>
|
|
// Label: Code-Review=+2
|
|
if reviewer := lineValue(c.strb(msg), "Reviewer: "); reviewer != "" {
|
|
gc.Reviewer = &GitPerson{Str: reviewer}
|
|
}
|
|
|
|
if commit.DiffTree != nil {
|
|
gc.Files = commit.DiffTree.File
|
|
}
|
|
for _, f := range gc.Files {
|
|
f.File = c.str(f.File) // intern the string
|
|
}
|
|
sort.Slice(gc.Files, func(i, j int) bool { return gc.Files[i].File < gc.Files[j].File })
|
|
parents := 0
|
|
err := foreach.Line(hdr, func(ln []byte) error {
|
|
if bytes.HasPrefix(ln, parentSpace) {
|
|
parents++
|
|
parentHash := c.gitHashFromHex(ln[len(parentSpace):])
|
|
parent := c.gitCommit[parentHash]
|
|
if parent == nil {
|
|
// Install a placeholder to be filled in later.
|
|
parent = &GitCommit{
|
|
Hash: parentHash,
|
|
Committer: placeholderCommitter,
|
|
}
|
|
c.gitCommit[parentHash] = parent
|
|
}
|
|
gc.Parents = append(gc.Parents, parent)
|
|
c.enqueueCommitLocked(parentHash)
|
|
return nil
|
|
}
|
|
if bytes.HasPrefix(ln, authorSpace) {
|
|
p, t, err := c.parsePerson(ln[len(authorSpace):])
|
|
if err != nil {
|
|
return fmt.Errorf("unrecognized author line %q: %v", ln, err)
|
|
}
|
|
gc.Author = p
|
|
gc.AuthorTime = t
|
|
return nil
|
|
}
|
|
if bytes.HasPrefix(ln, committerSpace) {
|
|
p, t, err := c.parsePerson(ln[len(committerSpace):])
|
|
if err != nil {
|
|
return fmt.Errorf("unrecognized committer line %q: %v", ln, err)
|
|
}
|
|
gc.Committer = p
|
|
gc.CommitTime = t
|
|
return nil
|
|
}
|
|
if bytes.HasPrefix(ln, treeSpace) {
|
|
gc.Tree = c.gitHashFromHex(ln[len(treeSpace):])
|
|
return nil
|
|
}
|
|
if bytes.HasPrefix(ln, golangHgSpace) {
|
|
if c.gitOfHg == nil {
|
|
c.gitOfHg = map[string]GitHash{}
|
|
}
|
|
c.gitOfHg[string(ln[len(golangHgSpace):])] = hash
|
|
return nil
|
|
}
|
|
if bytes.HasPrefix(ln, gpgSigSpace) || bytes.HasPrefix(ln, space) {
|
|
// Jessie Frazelle is a unique butterfly.
|
|
return nil
|
|
}
|
|
if bytes.HasPrefix(ln, encodingSpace) {
|
|
// Also ignore this. In practice this has only
|
|
// been seen to declare that a commit's
|
|
// metadata is utf-8 when the author name has
|
|
// non-ASCII.
|
|
return nil
|
|
}
|
|
log.Printf("in commit %s, unrecognized line %q", hash, ln)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
log.Printf("Unparseable commit %q: %v", hash, err)
|
|
return nil, fmt.Errorf("Unparseable commit %q: %v", hash, err)
|
|
}
|
|
if ph, ok := c.gitCommit[hash]; ok {
|
|
// Update placeholder.
|
|
*ph = *gc
|
|
} else {
|
|
c.gitCommit[hash] = gc
|
|
}
|
|
if c.gitCommitTodo != nil {
|
|
delete(c.gitCommitTodo, hash)
|
|
}
|
|
if c.verbose {
|
|
now := time.Now()
|
|
if now.After(c.lastGitCount.Add(time.Second)) {
|
|
c.lastGitCount = now
|
|
log.Printf("Num git commits = %v", len(c.gitCommit))
|
|
}
|
|
}
|
|
return gc, nil
|
|
}
|
|
|
|
// parsePerson parses an "author" or "committer" value from "git cat-file -p COMMIT"
|
|
// The values are like:
|
|
//
|
|
// Foo Bar <foobar@gmail.com> 1488624439 +0900
|
|
//
|
|
// c.mu must be held for writing.
|
|
func (c *Corpus) parsePerson(v []byte) (*GitPerson, time.Time, error) {
|
|
v = bytes.TrimSpace(v)
|
|
|
|
lastSpace := bytes.LastIndexByte(v, ' ')
|
|
if lastSpace < 0 {
|
|
return nil, time.Time{}, errors.New("failed to match person")
|
|
}
|
|
tz := v[lastSpace+1:] // "+0800"
|
|
v = v[:lastSpace] // now v is "Foo Bar <foobar@gmail.com> 1488624439"
|
|
|
|
lastSpace = bytes.LastIndexByte(v, ' ')
|
|
if lastSpace < 0 {
|
|
return nil, time.Time{}, errors.New("failed to match person")
|
|
}
|
|
unixTime := v[lastSpace+1:]
|
|
nameEmail := v[:lastSpace] // now v is "Foo Bar <foobar@gmail.com>"
|
|
|
|
ut, err := strconv.ParseInt(string(unixTime), 10, 64)
|
|
if err != nil {
|
|
return nil, time.Time{}, err
|
|
}
|
|
t := time.Unix(ut, 0).In(c.gitLocation(tz))
|
|
|
|
p, ok := c.gitPeople[string(nameEmail)]
|
|
if !ok {
|
|
p = &GitPerson{Str: string(nameEmail)}
|
|
if c.gitPeople == nil {
|
|
c.gitPeople = map[string]*GitPerson{}
|
|
}
|
|
c.gitPeople[p.Str] = p
|
|
}
|
|
return p, t, nil
|
|
|
|
}
|
|
|
|
// GitCommit returns the provided git commit, or nil if it's unknown.
|
|
func (c *Corpus) GitCommit(hash string) *GitCommit {
|
|
if len(hash) != 40 {
|
|
// TODO: support prefix lookups. build a trie. But
|
|
// for now just avoid panicking in gitHashFromHexStr.
|
|
return nil
|
|
}
|
|
var buf [20]byte
|
|
_, err := decodeHexStr(buf[:], hash)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return c.gitCommit[GitHash(buf[:])]
|
|
}
|
|
|
|
// v is like '[+-]hhmm'
|
|
// c.mu must be held for writing.
|
|
func (c *Corpus) gitLocation(v []byte) *time.Location {
|
|
if loc, ok := c.zoneCache[string(v)]; ok {
|
|
return loc
|
|
}
|
|
s := string(v)
|
|
h, _ := strconv.Atoi(s[1:3])
|
|
m, _ := strconv.Atoi(s[3:5])
|
|
east := 1
|
|
if v[0] == '-' {
|
|
east = -1
|
|
}
|
|
loc := time.FixedZone(s, east*(h*3600+m*60))
|
|
if c.zoneCache == nil {
|
|
c.zoneCache = map[string]*time.Location{}
|
|
}
|
|
c.zoneCache[s] = loc
|
|
return loc
|
|
}
|
|
|
|
func decodeHexStr(dst []byte, src string) (int, error) {
|
|
if len(src)%2 == 1 {
|
|
return 0, hex.ErrLength
|
|
}
|
|
|
|
for i := 0; i < len(src)/2; i++ {
|
|
a, ok := fromHexChar(src[i*2])
|
|
if !ok {
|
|
return 0, hex.InvalidByteError(src[i*2])
|
|
}
|
|
b, ok := fromHexChar(src[i*2+1])
|
|
if !ok {
|
|
return 0, hex.InvalidByteError(src[i*2+1])
|
|
}
|
|
dst[i] = (a << 4) | b
|
|
}
|
|
|
|
return len(src) / 2, nil
|
|
}
|
|
|
|
// fromHexChar converts a hex character into its value and a success flag.
|
|
func fromHexChar(c byte) (byte, bool) {
|
|
switch {
|
|
case '0' <= c && c <= '9':
|
|
return c - '0', true
|
|
case 'a' <= c && c <= 'f':
|
|
return c - 'a' + 10, true
|
|
case 'A' <= c && c <= 'F':
|
|
return c - 'A' + 10, true
|
|
}
|
|
|
|
return 0, false
|
|
}
|