blob: 5348ed387e46f2ffc3c0bcc3603fe8539643ec10 [file] [log] [blame]
package gitiles
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"go.skia.org/infra/go/git"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/util"
"go.skia.org/infra/go/vcsinfo"
"golang.org/x/time/rate"
)
/*
Utilities for working with Gitiles.
*/
const (
// CommitURL is the format of the URL used to retrieve a commit.
CommitURL = "%s/+show/%s"
// CommitURLJSON is the format of the URL used to retrieve a commit as JSON.
CommitURLJSON = CommitURL + "?format=JSON"
// DownloadURL is the format of the URL used to download a file.
DownloadURL = "%s/+show/%s/%s?format=TEXT"
// LogURL is the format of the URL used to view the git log.
LogURL = "%s/+log/%s?format=JSON"
// RefsURL is the format of the URL used to retrieve refs.
RefsURL = "%s/+refs%%2Fheads?format=JSON"
// TagsURL is the format of the URL used to retrieve tags.
TagsURL = "%s/+refs%%2Ftags?format=JSON"
dateFormatNoTZ = "Mon Jan 02 15:04:05 2006"
dateFormatTZ = "Mon Jan 02 15:04:05 2006 -0700"
// These were copied from the defaults used by gitfs:
// https://gerrit.googlesource.com/gitfs/+show/59c1163fd1737445281f2339399b2b986b0d30fe/gitiles/client.go#102
maxQPS = rate.Limit(4.0)
maxBurst = 40
// ModeHeader is an HTTP header which indicates the file mode.
ModeHeader = "X-Gitiles-Path-Mode"
// TypeHeader is an HTTP header which indicates the object type.
TypeHeader = "X-Gitiles-Object-Type"
)
var (
// ErrStopIteration is an error returned from a helper function passed to
// LogFn which indicates that iteration over commits should stop.
ErrStopIteration = errors.New("stop iteration")
)
// GitilesRepo is an interface to Gitiles.
type GitilesRepo interface {
// Details returns a vcsinfo.LongCommit for the given commit.
Details(ctx context.Context, ref string) (*vcsinfo.LongCommit, error)
// ReadObject reads the given object at the given ref, returning its contents
// and FileInfo.
ReadObject(ctx context.Context, path, ref string) (os.FileInfo, []byte, error)
// ReadFileAtRef reads the given file at the given ref.
ReadFileAtRef(ctx context.Context, srcPath, ref string) ([]byte, error)
// ReadFile reads the current version of the given file from the main branch
// of the Repo.
ReadFile(ctx context.Context, srcPath string) ([]byte, error)
// DownloadFile downloads the current version of the given file from the main
// branch of the Repo.
DownloadFile(ctx context.Context, srcPath, dstPath string) error
// DownloadFileAtRef downloads the given file at the given ref.
DownloadFileAtRef(ctx context.Context, srcPath, ref, dstPath string) error
// ListDirAtRef reads the given directory at the given ref. Returns a slice of
// file names and a slice of dir names, relative to the given directory, or any
// error which occurred.
ListDirAtRef(ctx context.Context, dir, ref string) ([]os.FileInfo, error)
// ListDir reads the given directory on the main branch. Returns a slice of
// file names and a slice of dir names, relative to the given directory, or any
// error which occurred.
ListDir(ctx context.Context, dir string) ([]os.FileInfo, error)
// ResolveRef resolves the given ref to a commit hash.
ResolveRef(ctx context.Context, ref string) (string, error)
// ListFilesRecursiveAtRef returns a list of all file paths, relative to the
// given directory, under the given directory at the given ref.
ListFilesRecursiveAtRef(ctx context.Context, topDir, ref string) ([]string, error)
// ListFilesRecursive returns a list of all file paths, relative to the given
// directory, under the given directory on the main branch.
ListFilesRecursive(ctx context.Context, dir string) ([]string, error)
// Log returns Gitiles' equivalent to "git log" for the given expression.
Log(ctx context.Context, logExpr string, opts ...LogOption) ([]*vcsinfo.LongCommit, error)
// LogFirstParent is equivalent to "git log --first-parent A..B", ie. it
// only returns commits which are reachable from A by following the first parent
// (the "main" branch) but not from B. LogFirstParent is incompatible with
// LogPath.
LogFirstParent(ctx context.Context, from, to string, opts ...LogOption) ([]*vcsinfo.LongCommit, error)
// LogLinear is equivalent to "git log --first-parent --ancestry-path from..to",
// ie. it only returns commits which are on the direct path from A to B, and
// only on the "main" branch. This is as opposed to "git log from..to" which
// returns all commits which are ancestors of 'to' but not 'from'. LogLinear is
// incompatible with LogPath.
LogLinear(ctx context.Context, from, to string, opts ...LogOption) ([]*vcsinfo.LongCommit, error)
// LogFn runs the given function for each commit in the log for the given
// expression. It stops when ErrStopIteration is returned.
LogFn(ctx context.Context, logExpr string, fn func(context.Context, *vcsinfo.LongCommit) error, opts ...LogOption) error
// LogFnBatch is the same as LogFn but it runs the given function over batches
// of commits.
LogFnBatch(ctx context.Context, logExpr string, fn func(context.Context, []*vcsinfo.LongCommit) error, opts ...LogOption) error
// Branches returns the list of branches in the repo.
Branches(ctx context.Context) ([]*git.Branch, error)
// Tags returns the list of tags in the repo. The returned map has tag names as
// keys and commit hashes as values.
Tags(ctx context.Context) (map[string]string, error)
// URL returns the repo URL.
URL() string
}
// Repo is an object used for interacting with a single Git repo using Gitiles.
type Repo struct {
client *http.Client
rl *rate.Limiter
url string
}
// NewRepo creates and returns a new Repo object.
func NewRepo(url string, c *http.Client) *Repo {
// TODO(borenet):Stop supporting a nil client; we should enforce that we
// always use an authenticated client to talk to Gitiles.
if c == nil {
c = httputils.NewTimeoutClient()
}
return &Repo{
client: c,
rl: rate.NewLimiter(maxQPS, maxBurst),
url: url,
}
}
// get executes a GET request to the given URL, returning the http.Response.
func (r *Repo) get(ctx context.Context, url string) (*http.Response, error) {
// Respect the rate limit.
if err := r.rl.Wait(ctx); err != nil {
return nil, err
}
resp, err := httputils.GetWithContext(ctx, r.client, url)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
util.Close(resp.Body)
return nil, skerr.Fmt("Request got status %q", resp.Status)
}
return resp, nil
}
// getJSON executes a GET request to the given URL, reads the response and
// unmarshals it to the given destination.
func (r *Repo) getJSON(ctx context.Context, url string, dest interface{}) error {
resp, err := r.get(ctx, url)
if err != nil {
return skerr.Wrapf(err, "GET %s", url)
}
defer util.Close(resp.Body)
b, err := io.ReadAll(resp.Body)
if err != nil {
return skerr.Fmt("Failed to read response: %s", err)
}
// Remove the first line.
b = b[4:]
return skerr.Wrap(json.Unmarshal(b, dest))
}
// ReadObject reads the given object at the given ref, returning its contents
// and FileInfo.
func (r *Repo) ReadObject(ctx context.Context, path, ref string) (os.FileInfo, []byte, error) {
path = strings.TrimSuffix(path, "/")
url := fmt.Sprintf(DownloadURL, r.url, ref, path)
resp, err := r.get(ctx, url)
if err != nil {
return nil, nil, skerr.Wrapf(err, "fetching %s", url)
}
defer util.Close(resp.Body)
var buf bytes.Buffer
d := base64.NewDecoder(base64.StdEncoding, resp.Body)
if _, err := io.Copy(&buf, d); err != nil {
return nil, nil, skerr.Wrapf(err, "fetching %s", url)
}
content := buf.Bytes()
mh := resp.Header.Get(ModeHeader)
typ := resp.Header.Get(TypeHeader)
fi, err := git.MakeFileInfo(path, mh, git.ObjectType(typ), len(content))
if err != nil {
return nil, nil, skerr.Wrapf(err, "fetching %s", url)
}
return fi, content, nil
}
// ReadFileAtRef reads the given file at the given ref.
func (r *Repo) ReadFileAtRef(ctx context.Context, srcPath, ref string) ([]byte, error) {
_, rv, err := r.ReadObject(ctx, srcPath, ref)
return rv, err
}
// ReadFile reads the current version of the given file from the main branch
// of the Repo.
func (r *Repo) ReadFile(ctx context.Context, srcPath string) ([]byte, error) {
return r.ReadFileAtRef(ctx, srcPath, git.MainBranch)
}
// DownloadFile downloads the current version of the given file from the main
// branch of the Repo.
func (r *Repo) DownloadFile(ctx context.Context, srcPath, dstPath string) error {
return util.WithWriteFile(dstPath, func(w io.Writer) error {
contents, err := r.ReadFile(ctx, srcPath)
if err != nil {
return skerr.Wrap(err)
}
_, err = w.Write(contents)
return skerr.Wrap(err)
})
}
// DownloadFileAtRef downloads the given file at the given ref.
func (r *Repo) DownloadFileAtRef(ctx context.Context, srcPath, ref, dstPath string) error {
return util.WithWriteFile(dstPath, func(w io.Writer) error {
contents, err := r.ReadFileAtRef(ctx, srcPath, ref)
if err != nil {
return skerr.Wrap(err)
}
_, err = w.Write(contents)
return skerr.Wrap(err)
})
}
// ListDirAtRef reads the given directory at the given ref. Returns a slice of
// file names and a slice of dir names, relative to the given directory, or any
// error which occurred.
func (r *Repo) ListDirAtRef(ctx context.Context, dir, ref string) ([]os.FileInfo, error) {
_, contents, err := r.ReadObject(ctx, dir, ref)
if err != nil {
return nil, skerr.Wrap(err)
}
return git.ParseDir(contents)
}
// ListDir reads the given directory on the main branch. Returns a slice of
// file names and a slice of dir names, relative to the given directory, or any
// error which occurred.
func (r *Repo) ListDir(ctx context.Context, dir string) ([]os.FileInfo, error) {
return r.ListDirAtRef(ctx, dir, git.MainBranch)
}
// ResolveRef resolves the given ref to a commit hash.
func (r *Repo) ResolveRef(ctx context.Context, ref string) (string, error) {
commit, err := r.Details(ctx, ref)
if err != nil {
return "", skerr.Wrapf(err, "cannot resolve ref %q", ref)
}
return commit.Hash, nil
}
// ListFilesRecursiveAtRef returns a list of all file paths, relative to the
// given directory, under the given directory at the given ref.
func (r *Repo) ListFilesRecursiveAtRef(ctx context.Context, topDir, ref string) ([]string, error) {
// First, resolve the given ref to a commit hash to ensure that we
// return consistent results even if the ref changes between requests.
hash, err := r.ResolveRef(ctx, ref)
if err != nil {
return nil, skerr.Wrap(err)
}
// List files recursively.
rv := []string{}
var helper func(string) error
helper = func(dir string) error {
infos, err := r.ListDirAtRef(ctx, dir, hash)
if err != nil {
return err
}
for _, fi := range infos {
if fi.IsDir() {
if err := helper(dir + "/" + fi.Name()); err != nil {
return err
}
} else {
rv = append(rv, strings.TrimPrefix(dir+"/"+fi.Name(), topDir+"/"))
}
}
return nil
}
if err := helper(topDir); err != nil {
return nil, err
}
sort.Strings(rv)
return rv, nil
}
// ListFilesRecursive returns a list of all file paths, relative to the given
// directory, under the given directory on the main branch.
func (r *Repo) ListFilesRecursive(ctx context.Context, dir string) ([]string, error) {
return r.ListFilesRecursiveAtRef(ctx, dir, git.MainBranch)
}
// Author represents the author of a Commit.
type Author struct {
Name string `json:"name"`
Email string `json:"email"`
Time string `json:"time"`
}
// TreeDiff represents a change to a file in a Commit.
type TreeDiff struct {
// Type can be one of Copy, Rename, Add, Delete, Modify.
Type string `json:"type"`
// Previous location of the changed file.
OldPath string `json:"old_path"`
// New location of the changed file.
NewPath string `json:"new_path"`
}
// Commit contains information about one Git commit.
type Commit struct {
Commit string `json:"commit"`
Parents []string `json:"parents"`
Author *Author `json:"author"`
Committer *Author `json:"committer"`
Message string `json:"message"`
TreeDiffs []*TreeDiff `json:"tree_diff"`
}
// Log represents a series of Commits in the git log.
type Log struct {
Log []*Commit `json:"log"`
Next string `json:"next"`
}
func commitToLongCommit(c *Commit) (*vcsinfo.LongCommit, error) {
var ts time.Time
var err error
if strings.Contains(c.Committer.Time, " +") || strings.Contains(c.Committer.Time, " -") {
ts, err = time.Parse(dateFormatTZ, c.Committer.Time)
} else {
ts, err = time.Parse(dateFormatNoTZ, c.Committer.Time)
}
if err != nil {
return nil, err
}
split := strings.Split(c.Message, "\n")
subject := split[0]
split = split[1:]
body := ""
if len(split) > 0 && split[0] == "" {
split = split[1:]
}
if len(split) > 0 {
body = strings.Join(split, "\n")
}
return &vcsinfo.LongCommit{
ShortCommit: &vcsinfo.ShortCommit{
Hash: c.Commit,
Author: fmt.Sprintf("%s (%s)", c.Author.Name, c.Author.Email),
Subject: subject,
},
Parents: c.Parents,
Body: body,
Timestamp: ts,
}, nil
}
// LongCommitToCommit converts the given LongCommit to a Commit. Intended for
// use in tests.
func LongCommitToCommit(details *vcsinfo.LongCommit) (*Commit, error) {
// vcsinfo.LongCommit expresses authors in the form: "Author Name (author@email.com)"
split := strings.Split(details.Author, "(")
if len(split) != 2 {
return nil, skerr.Fmt("Bad author format: %q", details.Author)
}
authorName := strings.TrimSpace(split[0])
authorEmail := strings.TrimSpace(strings.TrimRight(split[1], ")"))
return &Commit{
Commit: details.Hash,
Parents: details.Parents,
Author: &Author{
Name: authorName,
Email: authorEmail,
Time: details.Timestamp.Format(dateFormatTZ),
},
Committer: &Author{
Name: authorName,
Email: authorEmail,
Time: details.Timestamp.Format(dateFormatTZ),
},
Message: details.Subject + "\n\n" + details.Body,
}, nil
}
// getCommit returns a Commit for the given ref.
func (r *Repo) getCommit(ctx context.Context, ref string) (*Commit, error) {
var c Commit
if err := r.getJSON(ctx, fmt.Sprintf(CommitURLJSON, r.url, ref), &c); err != nil {
return nil, err
}
return &c, nil
}
// Details returns a vcsinfo.LongCommit for the given commit.
func (r *Repo) Details(ctx context.Context, ref string) (*vcsinfo.LongCommit, error) {
c, err := r.getCommit(ctx, ref)
if err != nil {
return nil, err
}
return commitToLongCommit(c)
}
// GetTreeDiffs returns a slice of TreeDiffs for the given commit.
func (r *Repo) GetTreeDiffs(ctx context.Context, ref string) ([]*TreeDiff, error) {
c, err := r.getCommit(ctx, ref)
if err != nil {
return nil, err
}
return c.TreeDiffs, nil
}
// LogOption represents an optional parameter to a Log function. Either Key()
// AND Value() OR Path() must return non-empty strings. Only one LogOption in
// a given set may return a non-empty value for Path().
type LogOption interface {
Key() string
Value() string
Path() string
}
type stringLogOption [2]string
func (s stringLogOption) Key() string {
return s[0]
}
func (s stringLogOption) Value() string {
return s[1]
}
func (s stringLogOption) Path() string {
return ""
}
// LogReverse is a LogOption which indicates that the commits in the Log should
// be returned in reverse order from the typical "git log" ordering, ie. each
// commit's parents appear before the commit itself.
func LogReverse() LogOption {
return stringLogOption([2]string{"reverse", "true"})
}
// LogBatchSize is a LogOption which indicates the number of commits which
// should be included in each batch of commits returned by Log.
func LogBatchSize(n int) LogOption {
return stringLogOption([2]string{logLimit(0).Key(), strconv.Itoa(n)})
}
// logLimit is an implementation of LogOption which is a special case, because
// Gitiles' limit option is really just a batch size. We need a new type to
// indicate that we shouldn't load additional batches after the first N commits.
type logLimit int
func (n logLimit) Key() string {
return "n"
}
func (n logLimit) Value() string {
return strconv.Itoa(int(n))
}
func (n logLimit) Path() string {
return ""
}
// LogLimit is a LogOption which makes Log return at most N commits.
func LogLimit(n int) LogOption {
return logLimit(n)
}
// logPath restricts the log to a given path.
type logPath string
func (p logPath) Key() string {
return ""
}
func (p logPath) Value() string {
return ""
}
func (p logPath) Path() string {
return string(p)
}
// LogPath is a LogOption which limits the git log to the given path. LogPath is
// incompatible with any Log queries which also limit the returned commits, eg.
// LogLinear and LogFirstParent.
func LogPath(path string) LogOption {
return logPath(path)
}
// LogOptionsToQuery converts the given LogOptions to a URL sub-path and query
// string. Returns the URL sub-path and query string and the maximum number of
// commits to return from a Log query (or zero if none is provided, indicating
// no limit), or any error which occurred.
func LogOptionsToQuery(opts []LogOption) (string, string, int, error) {
limit := 0
path := ""
query := ""
if len(opts) > 0 {
paramsMap := make(map[string]string, len(opts))
for _, opt := range opts {
optPath := opt.Path()
if optPath != "" {
if path != "" {
return "", "", 0, skerr.Fmt("Only one log option may change the URL path")
}
path = optPath
}
if opt.Key() == "" || opt.Value() == "" {
continue
}
// If LogLimit and LogBatchSize are both provided, or if
// LogBatchSize is provided more than once, use the
// smaller value. This ensures that we respect the batch
// size when smaller than the limit but prevent loading
// extra commits when the limit is smaller than the
// batch size.
// NOTE: We could try to be more efficient and ensure
// that the final batch contains only as many commits as
// we need to achieve the given limit. That would
// require moving this logic into the loop below.
if exist, ok := paramsMap[opt.Key()]; ok && opt.Key() == logLimit(0).Key() {
existInt, err := strconv.Atoi(exist)
if err != nil {
// This shouldn't happen, since we used
// strconv.Itoi to create it.
return "", "", 0, skerr.Wrap(err)
}
newInt, err := strconv.Atoi(opt.Value())
if err != nil {
// This shouldn't happen, since we used
// strconv.Itoi to create it.
return "", "", 0, skerr.Wrap(err)
}
if newInt < existInt {
paramsMap[opt.Key()] = opt.Value()
}
} else {
paramsMap[opt.Key()] = opt.Value()
}
if n, ok := opt.(logLimit); ok {
limit = int(n)
}
}
params := make([]string, 0, len(paramsMap))
for k, v := range paramsMap {
params = append(params, fmt.Sprintf("%s=%s", k, v))
}
sort.Strings(params) // For consistency in tests.
query = strings.Join(params, "&")
}
return path, query, limit, nil
}
// logHelper is used to perform requests which are equivalent to "git log".
// Loads commits in batches and calls the given function for each batch of
// commits. If the function returns an error, iteration stops, and the error is
// returned, unless it was ErrStopIteration.
func (r *Repo) logHelper(ctx context.Context, logExpr string, fn func(context.Context, []*vcsinfo.LongCommit) error, opts ...LogOption) error {
// Build the query parameters.
path, query, limit, err := LogOptionsToQuery(opts)
if err != nil {
return err
}
if path != "" {
logExpr += "/" + path
}
url := fmt.Sprintf(LogURL, r.url, logExpr)
if query != "" {
url += "&" + query
}
// Load commits in batches.
seen := 0
start := ""
for {
var l Log
u := url
if start != "" {
u += "&s=" + start
}
if err := r.getJSON(ctx, u, &l); err != nil {
return err
}
// Convert to vcsinfo.LongCommit.
commits := make([]*vcsinfo.LongCommit, 0, len(l.Log))
for _, c := range l.Log {
vc, err := commitToLongCommit(c)
if err != nil {
return err
}
commits = append(commits, vc)
seen++
if limit > 0 && seen == limit {
break
}
}
if err := fn(ctx, commits); err == ErrStopIteration {
return nil
} else if err != nil {
return err
}
if l.Next == "" || (limit > 0 && seen >= limit) {
return nil
}
start = l.Next
}
}
// Log returns Gitiles' equivalent to "git log" for the given expression.
func (r *Repo) Log(ctx context.Context, logExpr string, opts ...LogOption) ([]*vcsinfo.LongCommit, error) {
rv := []*vcsinfo.LongCommit{}
if err := r.logHelper(ctx, logExpr, func(ctx context.Context, commits []*vcsinfo.LongCommit) error {
rv = append(rv, commits...)
return nil
}, opts...); err != nil {
return nil, err
}
return rv, nil
}
// LogFirstParent is equivalent to "git log --first-parent A..B", ie. it
// only returns commits which are reachable from A by following the first parent
// (the "main" branch) but not from B. LogFirstParent is incompatible with
// LogPath.
func (r *Repo) LogFirstParent(ctx context.Context, from, to string, opts ...LogOption) ([]*vcsinfo.LongCommit, error) {
for _, opt := range opts {
if opt.Path() != "" {
return nil, skerr.Fmt("LogFirstParent is incompatible with LogPath")
}
}
// Retrieve the normal "git log".
commits, err := r.Log(ctx, git.LogFromTo(from, to), opts...)
if err != nil {
return nil, err
}
if len(commits) == 0 {
return commits, nil
}
// Now filter to only those commits which are on the first-parent path.
commitsMap := make(map[string]*vcsinfo.LongCommit, len(commits))
for _, commit := range commits {
commitsMap[commit.Hash] = commit
}
rv := make([]*vcsinfo.LongCommit, 0, len(commits))
c := commitsMap[to]
for c != nil {
rv = append(rv, c)
if len(c.Parents) > 0 {
c = commitsMap[c.Parents[0]]
} else {
c = nil
}
}
return rv, nil
}
// LogLinear is equivalent to "git log --first-parent --ancestry-path from..to",
// ie. it only returns commits which are on the direct path from A to B, and
// only on the "main" branch. This is as opposed to "git log from..to" which
// returns all commits which are ancestors of 'to' but not 'from'. LogLinear is
// incompatible with LogPath.
func (r *Repo) LogLinear(ctx context.Context, from, to string, opts ...LogOption) ([]*vcsinfo.LongCommit, error) {
for _, opt := range opts {
if opt.Path() != "" {
return nil, skerr.Fmt("LogLinear is incompatible with LogPath")
}
}
// Retrieve the normal "git log".
commits, err := r.Log(ctx, git.LogFromTo(from, to), opts...)
if err != nil {
return nil, err
}
if len(commits) == 0 {
return commits, nil
}
// Now filter to only those commits which are on the direct path.
commitsMap := make(map[string]*vcsinfo.LongCommit, len(commits))
for _, commit := range commits {
commitsMap[commit.Hash] = commit
}
isDescendant := make(map[string]bool, len(commits))
var search func(string) bool
search = func(hash string) bool {
// Shortcut if we've already searched this commit.
if rv, ok := isDescendant[hash]; ok {
return rv
}
// If the commit isn't in our list, we can't include it.
commit, ok := commitsMap[hash]
if !ok {
isDescendant[hash] = false
return false
}
// The commit is on the ancestry path if it is reachable from
// "to" and a descendant of "from". The former case is handled
// by vanilla "git log", so we just need to find the commits
// which have "from" as an ancestor.
// If "from" is a parent of this commit, it's on the ancestry
// path.
if util.In(from, commit.Parents) {
isDescendant[hash] = true
return true
}
// If the first parent of this commit is on the direct line,
// then this commit is as well.
if len(commit.Parents) > 0 {
if search(commit.Parents[0]) {
isDescendant[hash] = true
return true
}
}
return false
}
search(commits[0].Hash)
rv := make([]*vcsinfo.LongCommit, 0, len(commits))
for _, commit := range commits {
if isDescendant[commit.Hash] {
rv = append(rv, commit)
}
}
return rv, nil
}
// LogFn runs the given function for each commit in the log for the given
// expression. It stops when ErrStopIteration is returned.
func (r *Repo) LogFn(ctx context.Context, logExpr string, fn func(context.Context, *vcsinfo.LongCommit) error, opts ...LogOption) error {
return r.LogFnBatch(ctx, logExpr, func(ctx context.Context, commits []*vcsinfo.LongCommit) error {
for _, c := range commits {
if err := fn(ctx, c); err != nil {
return err
}
}
return nil
}, opts...)
}
// LogFnBatch is the same as LogFn but it runs the given function over batches
// of commits.
func (r *Repo) LogFnBatch(ctx context.Context, logExpr string, fn func(context.Context, []*vcsinfo.LongCommit) error, opts ...LogOption) error {
return r.logHelper(ctx, logExpr, fn, opts...)
}
// Ref represents a single ref, as returned by the API.
type Ref struct {
Value string `json:"value"`
}
// RefsMap is the result of a request to REFS_URL.
type RefsMap map[string]Ref
// Branches returns the list of branches in the repo.
func (r *Repo) Branches(ctx context.Context) ([]*git.Branch, error) {
branchMap := RefsMap{}
if err := r.getJSON(ctx, fmt.Sprintf(RefsURL, r.url), &branchMap); err != nil {
return nil, err
}
rv := make([]*git.Branch, 0, len(branchMap))
for branch, v := range branchMap {
rv = append(rv, &git.Branch{
Name: branch,
Head: v.Value,
})
}
sort.Sort(git.BranchList(rv))
return rv, nil
}
// Tags returns the list of tags in the repo. The returned map has tag names as
// keys and commit hashes as values.
func (r *Repo) Tags(ctx context.Context) (map[string]string, error) {
tags := map[string]struct {
Value string `json:"value"`
Peeled string `json:"peeled"`
}{}
if err := r.getJSON(ctx, fmt.Sprintf(TagsURL, r.url), &tags); err != nil {
return nil, err
}
rv := make(map[string]string, len(tags))
for k, tag := range tags {
rv[k] = tag.Value
}
return rv, nil
}
// URL returns the repo URL.
func (r *Repo) URL() string {
return r.url
}
// Ensure that Repo implements GitilesRepo.
var _ GitilesRepo = &Repo{}
// ParseURL breaks a gitiles URL into the repo URL, ref, and sub-path. Note that
// this is inherently error-prone due to the way that Gitiles URLs are
// structured: it is impossible to distinguish between the ref name and a path
// within the repo since both are separated by slashes with no other
// distinction. This function assumes that the ref name has a single component,
// eg. "refs/heads/main", or simply, "main".
func ParseURL(u string) (string, string, string, error) {
parsed, err := url.Parse(u)
if err != nil {
return "", "", "", skerr.Wrap(err)
}
repoURL := parsed.Scheme + "://" + parsed.Host
var ref, path string
splitPath := strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/")
actionIndex := len(splitPath)
for idx, elem := range splitPath {
if strings.HasPrefix(elem, "+") {
actionIndex = idx
break
}
}
repoURL += "/" + strings.Join(splitPath[:actionIndex], "/")
if actionIndex == len(splitPath) {
return repoURL, ref, path, nil
}
splitRefAndPath := splitPath[actionIndex+1:]
if len(splitRefAndPath) < 2 {
return "", "", "", skerr.Fmt("Not enough parts to %v", splitRefAndPath)
}
if len(splitRefAndPath) > 2 && splitRefAndPath[0] == "refs" && splitRefAndPath[1] == "heads" {
// Note: we have no way of knowing whether each path component belongs
// to the ref name or the path within the repo. We just have to assume
// that the ref name has a single element.
ref = strings.Join(splitRefAndPath[0:3], "/")
path = strings.Join(splitRefAndPath[3:], "/")
} else if len(splitRefAndPath) > 1 {
ref = splitRefAndPath[0]
path = strings.Join(splitRefAndPath[1:], "/")
} else {
return "", "", "", skerr.Fmt("Not enough parts to %v", splitRefAndPath)
}
return repoURL, ref, path, nil
}