blob: eb04d4131b45981c3219d56707234757848d3027 [file] [log] [blame]
// Copyright 2021 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 gitrepo provides operations on git repos.
package gitrepo
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/storage/memory"
"golang.org/x/exp/event"
"golang.org/x/tools/txtar"
"golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/worker/log"
)
// Clone returns a bare repo by cloning the repo at repoURL.
func Clone(ctx context.Context, repoURL string) (repo *git.Repository, err error) {
defer derrors.Wrap(&err, "gitrepo.Clone(%q)", repoURL)
ctx = event.Start(ctx, "gitrepo.Clone")
defer event.End(ctx)
return CloneAt(ctx, repoURL, plumbing.HEAD)
}
// Clone returns a bare repo by cloning the repo at repoURL at the given ref.
func CloneAt(ctx context.Context, repoURL string, ref plumbing.ReferenceName) (repo *git.Repository, err error) {
log.Infof(ctx, "Cloning repo %q at %s", repoURL, ref)
return git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
URL: repoURL,
ReferenceName: ref,
SingleBranch: true,
Depth: 1,
Tags: git.NoTags,
})
}
// PlainClone returns a (non-bare) repo with its history by cloning the repo at repoURL.
func PlainClone(ctx context.Context, dir, repoURL string) (repo *git.Repository, err error) {
defer derrors.Wrap(&err, "gitrepo.PlainClone(%q)", repoURL)
ctx = event.Start(ctx, "gitrepo.PlainClone")
defer event.End(ctx)
log.Infof(ctx, "Plain cloning repo %q at HEAD", repoURL)
return git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
URL: repoURL,
ReferenceName: plumbing.HEAD,
SingleBranch: true, // allow branches other than master
Depth: 0, // pull in history
Tags: git.NoTags,
})
}
// Open returns a repo by opening the repo at the local path dirpath.
func Open(ctx context.Context, dirpath string) (repo *git.Repository, err error) {
defer derrors.Wrap(&err, "gitrepo.Open(%q)", dirpath)
ctx = event.Start(ctx, "gitrepo.Open")
defer event.End(ctx)
log.Infof(ctx, "Opening repo at %q", dirpath)
repo, err = git.PlainOpen(dirpath)
if err != nil {
return nil, err
}
return repo, nil
}
// CloneOrOpen clones repoPath if it is an HTTP(S) URL, or opens it from the
// local disk otherwise.
func CloneOrOpen(ctx context.Context, repoPath string) (*git.Repository, error) {
if strings.HasPrefix(repoPath, "http://") || strings.HasPrefix(repoPath, "https://") {
return Clone(ctx, repoPath)
}
return Open(ctx, repoPath)
}
// Root returns the root tree of the repo at HEAD.
func Root(repo *git.Repository) (root *object.Tree, err error) {
refName := plumbing.HEAD
ref, err := repo.Reference(refName, true)
if err != nil {
return nil, err
}
commit, err := repo.CommitObject(ref.Hash())
if err != nil {
return nil, err
}
return repo.TreeObject(commit.TreeHash)
}
// ReadTxtarRepo converts a txtar file to a single-commit
// repo. It is intended for testing.
func ReadTxtarRepo(filename string, now time.Time) (_ *git.Repository, err error) {
defer derrors.Wrap(&err, "readTxtarRepo(%q)", filename)
mfs := memfs.New()
ar, err := txtar.ParseFile(filename)
if err != nil {
return nil, err
}
for _, f := range ar.Files {
file, err := mfs.Create(f.Name)
if err != nil {
return nil, err
}
if _, err := file.Write(f.Data); err != nil {
return nil, err
}
if err := file.Close(); err != nil {
return nil, err
}
}
repo, err := git.Init(memory.NewStorage(), mfs)
if err != nil {
return nil, err
}
wt, err := repo.Worktree()
if err != nil {
return nil, err
}
for _, f := range ar.Files {
if _, err := wt.Add(f.Name); err != nil {
return nil, err
}
}
_, err = wt.Commit("", &git.CommitOptions{All: true, Author: &object.Signature{
Name: "Joe Random",
Email: "joe@example.com",
When: now,
}})
if err != nil {
return nil, err
}
return repo, nil
}
// HeadHash returns the hash of the repo's HEAD.
func HeadHash(repo *git.Repository) (plumbing.Hash, error) {
ref, err := repo.Reference(plumbing.HEAD, true)
if err != nil {
return plumbing.ZeroHash, err
}
return ref.Hash(), nil
}
// ParseGitHubRepo parses a string of the form owner/repo or
// github.com/owner/repo.
func ParseGitHubRepo(s string) (owner, repoName string, err error) {
parts := strings.Split(s, "/")
switch len(parts) {
case 2:
return parts[0], parts[1], nil
case 3:
if parts[0] != "github.com" {
return "", "", fmt.Errorf("%q is not in the form {github.com/}owner/repo", s)
}
return parts[1], parts[2], nil
default:
return "", "", fmt.Errorf("%q is not in the form {github.com/}owner/repo", s)
}
}
// ReferenceName is a git reference.
type ReferenceName struct{ plumbing.ReferenceName }
var (
HeadReference = ReferenceName{plumbing.HEAD} // HEAD
MainReference = ReferenceName{plumbing.NewRemoteReferenceName("origin", "master")} // origin/master
)
// Dates is the oldest and newest commit timestamps for a file.
type Dates struct {
Oldest, Newest time.Time
}
// AllCommitDates returns the oldest and newest commit timestamps for every
// file in the repo at the given reference, where the filename begins with
// prefix. The supplied prefix should include the trailing /.
func AllCommitDates(repo *git.Repository, refName ReferenceName, prefix string) (dates map[string]Dates, err error) {
defer derrors.Wrap(&err, "AllCommitDates(%q)", prefix)
ref, err := repo.Reference(refName.ReferenceName, true)
if err != nil {
return nil, err
}
commit, err := repo.CommitObject(ref.Hash())
if err != nil {
return nil, err
}
dates = make(map[string]Dates)
iter := object.NewCommitPreorderIter(commit, nil, nil)
commit, err = iter.Next()
if err != nil {
return nil, err
}
for commit != nil {
parentCommit, err := iter.Next()
if err != nil {
if err != io.EOF {
return nil, err
}
parentCommit = nil
}
currentTree, err := commit.Tree()
if err != nil {
return nil, err
}
var parentTree *object.Tree
if parentCommit != nil {
parentTree, err = parentCommit.Tree()
if err != nil {
return nil, err
}
}
changes, err := object.DiffTree(currentTree, parentTree)
if err != nil {
return nil, err
}
for _, change := range changes {
name := change.To.Name
if change.From.Name != "" {
name = change.From.Name
}
when := commit.Committer.When.UTC()
if !strings.HasPrefix(name, prefix) {
continue
}
d := dates[name]
if d.Oldest.IsZero() || when.Before(d.Oldest) {
if d.Oldest.After(d.Newest) {
d.Newest = d.Oldest
}
d.Oldest = when
}
if when.After(d.Newest) {
d.Newest = when
}
dates[name] = d
}
commit = parentCommit
}
return dates, nil
}