blob: 8f887527d031a51411727e7deacbcdcfa35cf03a [file]
// gitinfo enables querying info from a Git repository.
package gitinfo
import (
"fmt"
"os"
"path"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/skia-dev/glog"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/tiling"
"go.skia.org/infra/go/util"
"go.skia.org/infra/go/vcsinfo"
)
// commitLineRe matches one line of commit log and captures hash, author and
// subject groups.
var commitLineRe = regexp.MustCompile(`([0-9a-f]{40}),([^,\n]+),(.+)$`)
// GitInfo allows querying a Git repo.
type GitInfo struct {
dir string
hashes []string
timestamps map[string]time.Time // Key is the hash.
detailsCache map[string]*vcsinfo.LongCommit
// Any access to hashes or timestamps must be protected.
mutex sync.Mutex
}
// NewGitInfo creates a new GitInfo for the Git repository found in directory
// dir. If pull is true then a git pull is done on the repo before querying it
// for history.
func NewGitInfo(dir string, pull, allBranches bool) (*GitInfo, error) {
g := &GitInfo{
dir: dir,
hashes: []string{},
detailsCache: map[string]*vcsinfo.LongCommit{},
}
return g, g.Update(pull, allBranches)
}
// Clone creates a new GitInfo by running "git clone" in the given directory.
func Clone(repoUrl, dir string, allBranches bool) (*GitInfo, error) {
if _, err := exec.RunSimple(fmt.Sprintf("git clone %s %s", repoUrl, dir)); err != nil {
return nil, fmt.Errorf("Failed to clone %s into %s: %s", repoUrl, dir, err)
}
return NewGitInfo(dir, false, allBranches)
}
// CloneOrUpdate creates a new GitInfo by running "git clone" or "git pull"
// depending on whether the repo already exists.
func CloneOrUpdate(repoUrl, dir string, allBranches bool) (*GitInfo, error) {
gitDir := path.Join(dir, ".git")
_, err := os.Stat(gitDir)
if err == nil {
return NewGitInfo(dir, true, allBranches)
}
if os.IsNotExist(err) {
return Clone(repoUrl, dir, allBranches)
}
return nil, err
}
// Update refreshes the history that GitInfo stores for the repo. If pull is
// true then git pull is performed before refreshing.
func (g *GitInfo) Update(pull, allBranches bool) error {
g.mutex.Lock()
defer g.mutex.Unlock()
glog.Info("Beginning Update.")
if pull {
if _, err := exec.RunCwd(g.dir, "git", "pull"); err != nil {
return fmt.Errorf("Failed to sync to HEAD: %s", err)
}
}
glog.Info("Finished pull.")
var hashes []string
var timestamps map[string]time.Time
var err error
if allBranches {
hashes, timestamps, err = readCommitsFromGitAllBranches(g.dir)
} else {
hashes, timestamps, err = readCommitsFromGit(g.dir, "HEAD")
}
glog.Infof("Finished reading commits: %s", g.dir)
if err != nil {
return fmt.Errorf("Failed to read commits from: %s : %s", g.dir, err)
}
g.hashes = hashes
g.timestamps = timestamps
return nil
}
// Details returns more information than ShortCommit about a given commit.
// See the vcsinfo.VCS interface for details.
func (g *GitInfo) Details(hash string, includeBranchInfo bool) (*vcsinfo.LongCommit, error) {
g.mutex.Lock()
defer g.mutex.Unlock()
if c, ok := g.detailsCache[hash]; ok {
return c, nil
}
output, err := exec.RunCwd(g.dir, "git", "log", "-n", "1", "--format=format:%H%n%P%n%an%x20(%ae)%n%s%n%b", hash)
if err != nil {
return nil, fmt.Errorf("Failed to execute Git: %s", err)
}
lines := strings.SplitN(output, "\n", 5)
if len(lines) != 5 {
return nil, fmt.Errorf("Failed to parse output of 'git log'.")
}
branches := map[string]bool{}
if includeBranchInfo {
branches, err = g.getBranchesForCommit(hash)
if err != nil {
return nil, err
}
}
c := vcsinfo.LongCommit{
ShortCommit: &vcsinfo.ShortCommit{
Hash: lines[0],
Author: lines[2],
Subject: lines[3],
},
Parents: strings.Split(lines[1], " "),
Body: lines[4],
Timestamp: g.timestamps[hash],
Branches: branches,
}
g.detailsCache[hash] = &c
return &c, nil
}
func (g *GitInfo) SetToCommit(hash string) error {
_, err := exec.RunCwd(g.dir, "git", "reset", "--hard", hash)
if err != nil {
return fmt.Errorf("Failed to roll back/forward to commit %s: %s", hash, err)
}
return nil
}
func (g *GitInfo) SetToBranch(branch string) error {
if _, err := exec.RunCwd(g.dir, "git", "checkout", branch); err != nil {
return fmt.Errorf("Failed to checkout %s: %s", branch, err)
}
return nil
}
// getBranchesForCommit returns a string set with all the branches that can reach
// the commit with the given hash.
// TODO(stephana): Speed up this method, there are either better ways to do this
// in git or the results can be cached.
func (g *GitInfo) getBranchesForCommit(hash string) (map[string]bool, error) {
output, err := exec.RunCwd(g.dir, "git", "branch", "--list", "--contains", hash)
if err != nil {
return nil, fmt.Errorf("Failed to get branches for commit %s: %s", hash, err)
}
lines := strings.Split(strings.TrimSpace(output), "\n")
ret := map[string]bool{}
for _, line := range lines {
l := strings.TrimSpace(line)
if l != "" {
// Splitting the line to filter out the '*' that marks the active branch.
parts := strings.Split(l, " ")
ret[parts[len(parts)-1]] = true
}
}
return ret, nil
}
// RevList returns the results of "git rev-list".
func (g *GitInfo) RevList(args ...string) ([]string, error) {
g.mutex.Lock()
defer g.mutex.Unlock()
output, err := exec.RunCwd(g.dir, append([]string{"git", "rev-list"}, args...)...)
if err != nil {
return nil, fmt.Errorf("git rev-list failed: %v", err)
}
res := strings.Trim(output, "\n")
if res == "" {
return []string{}, nil
}
return strings.Split(res, "\n"), nil
}
// From returns all commits from 'start' to HEAD.
func (g *GitInfo) From(start time.Time) []string {
g.mutex.Lock()
defer g.mutex.Unlock()
ret := []string{}
for _, h := range g.hashes {
if g.timestamps[h].After(start) {
ret = append(ret, h)
}
}
return ret
}
// LastN returns the last N commits.
func (g *GitInfo) LastN(N int) []string {
g.mutex.Lock()
defer g.mutex.Unlock()
if len(g.hashes) < N {
return g.hashes[0:len(g.hashes)]
} else {
return g.hashes[len(g.hashes)-N:]
}
}
// Timestamp returns the timestamp for the given hash.
func (g *GitInfo) Timestamp(hash string) time.Time {
g.mutex.Lock()
defer g.mutex.Unlock()
return g.timestamps[hash]
}
// Log returns a --name-only short log for every commit in (begin, end].
//
// If end is "" then it returns just the short log for the single commit at
// begin.
//
// Example response:
//
// commit b7988a21fdf23cc4ace6145a06ea824aa85db099
// Author: Joe Gregorio <jcgregorio@google.com>
// Date: Tue Aug 5 16:19:48 2014 -0400
//
// A description of the commit.
//
// perf/go/skiaperf/perf.go
// perf/go/types/types.go
// perf/res/js/logic.js
//
func (g *GitInfo) Log(begin, end string) (string, error) {
command := []string{"git", "log", "--name-only"}
hashrange := begin
if end != "" {
hashrange += ".." + end
command = append(command, hashrange)
} else {
command = append(command, "-n", "1", hashrange)
}
output, err := exec.RunCwd(g.dir, command...)
if err != nil {
return "", err
}
return output, nil
}
// FullHash gives the full commit hash for the given ref.
func (g *GitInfo) FullHash(ref string) (string, error) {
output, err := exec.RunCwd(g.dir, "git", "rev-parse", fmt.Sprintf("%s^{commit}", ref))
if err != nil {
return "", fmt.Errorf("Failed to obtain full hash: %s", err)
}
return strings.Trim(output, "\n"), nil
}
// GetFile returns the contents of the given file at the given commit.
func (g *GitInfo) GetFile(fileName, commit string) (string, error) {
output, err := exec.RunCwd(g.dir, "git", "show", commit+":"+fileName)
if err != nil {
return "", err
}
return output, nil
}
// InitalCommit returns the hash of the initial commit.
func (g *GitInfo) InitialCommit() (string, error) {
output, err := exec.RunCwd(g.dir, "git", "rev-list", "--max-parents=0", "HEAD")
if err != nil {
return "", fmt.Errorf("Failed to determine initial commit: %v", err)
}
return strings.Trim(output, "\n"), nil
}
// GetBranches returns a slice of strings naming the branches in the repo.
func (g *GitInfo) GetBranches() ([]*GitBranch, error) {
return GetBranches(g.dir)
}
// ShortCommits stores a slice of ShortCommit struct.
type ShortCommits struct {
Commits []*vcsinfo.ShortCommit
}
// ShortList returns a slice of ShortCommit for every commit in (begin, end].
func (g *GitInfo) ShortList(begin, end string) (*ShortCommits, error) {
command := []string{"git", "log", "--pretty='%H,%an,%s", begin + ".." + end}
output, err := exec.RunCwd(g.dir, command...)
if err != nil {
return nil, err
}
ret := &ShortCommits{
Commits: []*vcsinfo.ShortCommit{},
}
for _, line := range strings.Split(output, "\n") {
match := commitLineRe.FindStringSubmatch(line)
if match == nil {
// This could happen if the subject has new line, in which case we truncate it and ignore the remainder.
continue
}
commit := &vcsinfo.ShortCommit{
Hash: match[1],
Author: match[2],
Subject: match[3],
}
ret.Commits = append(ret.Commits, commit)
}
return ret, nil
}
// gitHash represents information on a single Git commit.
type gitHash struct {
hash string
timeStamp time.Time
}
type gitHashSlice []*gitHash
func (p gitHashSlice) Len() int { return len(p) }
func (p gitHashSlice) Less(i, j int) bool { return p[i].timeStamp.Before(p[j].timeStamp) }
func (p gitHashSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
// GitBranch represents a Git branch.
type GitBranch struct {
Name string `json:"name"`
Head string `json:"head"`
}
// includeBranchPrefixes is the list of branch prefixes that we should consider in the
// output of the 'git show-ref' command issued in GetBranches below.
var includeBranchPrefixes = []string{
"refs/remotes/",
"refs/heads/",
}
// GetBranches returns the list of branch heads in a Git repository.
// In order to separate local working branches from published branches, only
// remote branches in 'origin' are returned.
func GetBranches(dir string) ([]*GitBranch, error) {
output, err := exec.RunCwd(dir, "git", "show-ref")
if err != nil {
return nil, fmt.Errorf("Failed to get branch list: %v", err)
}
branches := []*GitBranch{}
lines := strings.Split(output, "\n")
for _, line := range lines {
if line == "" {
continue
}
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("Could not parse output of 'git show-ref'.")
}
for _, prefix := range includeBranchPrefixes {
if strings.HasPrefix(parts[1], prefix) {
name := parts[1][len(prefix):]
branches = append(branches, &GitBranch{
Name: name,
Head: parts[0],
})
}
}
}
return branches, nil
}
// readCommitsFromGit reads the commit history from a Git repository.
func readCommitsFromGit(dir, branch string) ([]string, map[string]time.Time, error) {
output, err := exec.RunCwd(dir, "git", "log", "--format=format:%H%x20%ci", branch)
if err != nil {
return nil, nil, fmt.Errorf("Failed to execute git log: %s", err)
}
lines := strings.Split(output, "\n")
gitHashes := make([]*gitHash, 0, len(lines))
timestamps := map[string]time.Time{}
for _, line := range lines {
parts := strings.SplitN(line, " ", 2)
if len(parts) == 2 {
t, err := time.Parse("2006-01-02 15:04:05 -0700", parts[1])
if err != nil {
return nil, nil, fmt.Errorf("Failed parsing Git log timestamp: %s", err)
}
hash := parts[0]
gitHashes = append(gitHashes, &gitHash{hash: hash, timeStamp: t})
timestamps[hash] = t
}
}
sort.Sort(gitHashSlice(gitHashes))
hashes := make([]string, len(gitHashes), len(gitHashes))
for i, h := range gitHashes {
hashes[i] = h.hash
}
return hashes, timestamps, nil
}
func readCommitsFromGitAllBranches(dir string) ([]string, map[string]time.Time, error) {
branches, err := GetBranches(dir)
if err != nil {
return nil, nil, fmt.Errorf("Could not read commits; unable to get branch list: %v", err)
}
timestamps := map[string]time.Time{}
for _, b := range branches {
_, ts, err := readCommitsFromGit(dir, b.Name)
if err != nil {
return nil, nil, err
}
for k, v := range ts {
timestamps[k] = v
}
}
gitHashes := make([]*gitHash, len(timestamps), len(timestamps))
i := 0
for h, t := range timestamps {
gitHashes[i] = &gitHash{hash: h, timeStamp: t}
i++
}
sort.Sort(gitHashSlice(gitHashes))
hashes := make([]string, len(timestamps), len(timestamps))
for i, h := range gitHashes {
hashes[i] = h.hash
}
return hashes, timestamps, nil
}
// SkpCommits returns the indices for all the commits that contain SKP updates.
func (g *GitInfo) SkpCommits(tile *tiling.Tile) ([]int, error) {
// Executes a git log command that looks like:
//
// git log --format=format:%H 32956400b4d8f33394e2cdef9b66e8369ba2a0f3..e7416bfc9858bde8fc6eb5f3bfc942bc3350953a SKP_VERSION
//
// The output should be a \n separated list of hashes that match.
first, last := tile.CommitRange()
output, err := exec.RunCwd(g.dir, "git", "log", "--format=format:%H", first+".."+last, "SKP_VERSION")
if err != nil {
return nil, fmt.Errorf("SkpCommits: Failed to find git log of SKP_VERSION: %s", err)
}
hashes := strings.Split(output, "\n")
ret := []int{}
for i, c := range tile.Commits {
if c.CommitTime != 0 && util.In(c.Hash, hashes) {
ret = append(ret, i)
}
}
return ret, nil
}
// LastSkpCommit returns the time of the last change to the SKP_VERSION file.
func (g *GitInfo) LastSkpCommit() (time.Time, error) {
// Executes a git log command that looks like:
//
// git log --format=format:%ct -n 1 SKP_VERSION
//
// The output should be a single unix timestamp.
output, err := exec.RunCwd(g.dir, "git", "log", "--format=format:%ct", "-n", "1", "SKP_VERSION")
if err != nil {
return time.Time{}, fmt.Errorf("LastSkpCommit: Failed to read git log: %s", err)
}
ts, err := strconv.ParseInt(output, 10, 64)
if err != nil {
return time.Time{}, fmt.Errorf("LastSkpCommit: Failed to parse timestamp: %s", err)
}
return time.Unix(ts, 0), nil
}
// TileAddressFromHash takes a commit hash and time, then returns the Level 0
// tile number that contains the hash, and its position in the tile commit array.
// This assumes that tiles are built for commits since after the given time.
func (g *GitInfo) TileAddressFromHash(hash string, start time.Time) (num, offset int, err error) {
g.mutex.Lock()
defer g.mutex.Unlock()
i := 0
for _, h := range g.hashes {
if g.timestamps[h].Before(start) {
continue
}
if h == hash {
return i / tiling.TILE_SIZE, i % tiling.TILE_SIZE, nil
}
i++
}
return -1, -1, fmt.Errorf("Cannot find hash %s.\n", hash)
}
// NumCommits returns the number of commits in the repo.
func (g *GitInfo) NumCommits() int {
g.mutex.Lock()
defer g.mutex.Unlock()
return len(g.hashes)
}
// RepoMap is a struct used for managing multiple Git repositories.
type RepoMap struct {
repos map[string]*GitInfo
mutex sync.RWMutex
workdir string
}
// NewRepoMap creates and returns a RepoMap which operates within the given
// workdir.
func NewRepoMap(workdir string) *RepoMap {
return &RepoMap{
repos: map[string]*GitInfo{},
workdir: workdir,
}
}
// Repo retrieves a pointer to a GitInfo for the requested repo URL. If the
// repo does not yet exist in the repoMap, it is cloned and added before it is
// returned.
func (m *RepoMap) Repo(r string) (*GitInfo, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
repo, ok := m.repos[r]
if !ok {
var err error
split := strings.Split(r, "/")
repoPath := path.Join(m.workdir, split[len(split)-1])
repo, err = CloneOrUpdate(r, repoPath, true)
if err != nil {
return nil, fmt.Errorf("Failed to check out %s: %v", r, err)
}
m.repos[r] = repo
}
return repo, nil
}
// RepoForCommit attempts to determine which repository the given commit hash
// belongs to and returns the associated repo URL if found. This is fragile,
// because it's possible, though very unlikely, that there may be collisions
// of commit hashes between repositories.
func (m *RepoMap) RepoForCommit(hash string) (string, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
for url, r := range m.repos {
if _, err := r.FullHash(hash); err == nil {
return url, nil
}
}
return "", fmt.Errorf("Could not find commit %s in any repo!", hash)
}
// Update causes all of the repos in the RepoMap to be updated.
func (m *RepoMap) Update() error {
m.mutex.Lock()
defer m.mutex.Unlock()
for _, r := range m.repos {
if err := r.Update(true, true); err != nil {
return err
}
}
return nil
}
// Repos returns the list of repos contained in the RepoMap.
func (m *RepoMap) Repos() []string {
m.mutex.Lock()
defer m.mutex.Unlock()
rv := make([]string, 0, len(m.repos))
for url, _ := range m.repos {
rv = append(rv, url)
}
return rv
}