blob: 9de5e09c97aec82f7162ba5e6c4a05f6d8d2e30f [file] [log] [blame]
// Copyright 2022 The Dawn & Tint Authors
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this
// list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
// Package git provides helpers for interfacing with the git tool
package git
import (
"context"
"encoding/hex"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// Hash is a 20 byte, git object hash.
type Hash [20]byte
func (h Hash) String() string { return hex.EncodeToString(h[:]) }
// IsZero returns true if the hash h is all zeros
func (h Hash) IsZero() bool {
zero := Hash{}
return h == zero
}
// ParseHash returns a Hash from a hexadecimal string.
func ParseHash(s string) (Hash, error) {
b, err := hex.DecodeString(s)
if err != nil {
return Hash{}, fmt.Errorf("failed to parse hash '%v':\n %w", s, err)
}
h := Hash{}
copy(h[:], b)
return h, nil
}
// The timeout for git operations if no other timeout is specified
var DefaultTimeout = time.Minute
// Git wraps the 'git' executable
type Git struct {
// Path to the git executable
exe string
// Debug flag to print all command to the `git` executable
LogAllActions bool
}
// New returns a new Git instance
func New(exe string) (*Git, error) {
if exe == "" {
g, err := exec.LookPath("git")
if err != nil {
return nil, fmt.Errorf("failed to find git: %v", err)
}
exe = g
}
if _, err := os.Stat(exe); err != nil {
return nil, err
}
return &Git{exe: exe}, nil
}
// Credentials holds the user name and password used to perform git operations.
type Credentials struct {
Username string
Password string
}
// Empty return true if there's no username or password for authentication
func (a Credentials) Empty() bool {
return a.Username == "" && a.Password == ""
}
// addToURL returns the url with the credentials appended
func (c Credentials) addToURL(u string) (string, error) {
if !c.Empty() {
modified, err := url.Parse(u)
if err != nil {
return "", fmt.Errorf("failed to parse url '%v': %v", u, err)
}
modified.User = url.UserPassword(c.Username, c.Password)
u = modified.String()
}
return u, nil
}
// ErrRepositoryDoesNotExist indicates that a repository does not exist
var ErrRepositoryDoesNotExist = errors.New("repository does not exist")
// Open opens an existing git repo at path. If the repository does not exist at
// path then ErrRepositoryDoesNotExist is returned.
func (g Git) Open(path string) (*Repository, error) {
info, err := os.Stat(filepath.Join(path, ".git"))
if err != nil || !info.IsDir() {
return nil, ErrRepositoryDoesNotExist
}
return &Repository{g, path}, nil
}
// Optional settings for Git.Clone
type CloneOptions struct {
// If specified then the given branch will be cloned instead of the default
Branch string
// Timeout for the operation
Timeout time.Duration
// Authentication for the clone
Credentials Credentials
}
// Clone performs a clone of the repository at url to path.
func (g Git) Clone(path, url string, opt *CloneOptions) (*Repository, error) {
if err := os.MkdirAll(path, 0777); err != nil {
return nil, err
}
if opt == nil {
opt = &CloneOptions{}
}
url, err := opt.Credentials.addToURL(url)
if err != nil {
return nil, err
}
r := &Repository{g, path}
args := []string{"clone", url, "."}
if opt.Branch != "" {
args = append(args, "--branch", opt.Branch)
}
if _, err := r.run(nil, opt.Timeout, args...); err != nil {
return nil, err
}
return r, nil
}
// Repository points to a git repository
type Repository struct {
// Path to the 'git' executable
Git Git
// Repo directory
Path string
}
// Optional settings for Repository.Fetch
type FetchOptions struct {
// The remote name. Defaults to 'origin'
Remote string
// Timeout for the operation
Timeout time.Duration
// Git authentication for the remote
Credentials Credentials
}
// Fetch performs a fetch of a reference from the remote, returning the Hash of
// the fetched reference.
func (r Repository) Fetch(ref string, opt *FetchOptions) (Hash, error) {
if opt == nil {
opt = &FetchOptions{}
}
if opt.Remote == "" {
opt.Remote = "origin"
}
if _, err := r.run(nil, opt.Timeout, "fetch", opt.Remote, ref); err != nil {
return Hash{}, err
}
out, err := r.run(nil, 0, "rev-parse", "FETCH_HEAD")
if err != nil {
return Hash{}, err
}
return ParseHash(out)
}
// Optional settings for Repository.Push
type PushOptions struct {
// The remote name. Defaults to 'origin'
Remote string
// Timeout for the operation
Timeout time.Duration
// Git authentication for the remote
Credentials Credentials
}
// Push performs a push of the local reference to the remote reference.
func (r Repository) Push(localRef, remoteRef string, opt *PushOptions) error {
if opt == nil {
opt = &PushOptions{}
}
if opt.Remote == "" {
opt.Remote = "origin"
}
url, err := r.run(nil, opt.Timeout, "remote", "get-url", opt.Remote)
if err != nil {
return err
}
url, err = opt.Credentials.addToURL(url)
if err != nil {
return err
}
if _, err := r.run(nil, opt.Timeout, "push", url, localRef+":"+remoteRef); err != nil {
return err
}
return nil
}
// Optional settings for Repository.Add
type AddOptions struct {
// Timeout for the operation
Timeout time.Duration
// Git authentication for the remote
Credentials Credentials
}
// Add stages the listed files
func (r Repository) Add(path string, opt *AddOptions) error {
if opt == nil {
opt = &AddOptions{}
}
if _, err := r.run(nil, opt.Timeout, "add", path); err != nil {
return err
}
return nil
}
// Optional settings for Repository.Commit
type CommitOptions struct {
// Timeout for the operation
Timeout time.Duration
// Author name
AuthorName string
// Author email address
AuthorEmail string
// Amend last commit?
Amend bool
}
// Commit commits the staged files with the given message, returning the hash of
// commit
func (r Repository) Commit(msg string, opt *CommitOptions) (Hash, error) {
if opt == nil {
opt = &CommitOptions{}
}
args := []string{"commit"}
if opt.Amend {
args = append(args, "--amend")
} else {
args = append(args, "-m", msg)
}
var env []string
if opt.AuthorName != "" || opt.AuthorEmail != "" {
env = []string{
fmt.Sprintf("GIT_AUTHOR_NAME=%v", opt.AuthorName),
fmt.Sprintf("GIT_AUTHOR_EMAIL=%v", opt.AuthorEmail),
fmt.Sprintf("GIT_COMMITTER_NAME=%v", opt.AuthorName),
fmt.Sprintf("GIT_COMMITTER_EMAIL=%v", opt.AuthorEmail),
}
}
if _, err := r.run(env, opt.Timeout, "commit", "-m", msg); err != nil {
return Hash{}, err
}
out, err := r.run(nil, 0, "rev-parse", "HEAD")
if err != nil {
return Hash{}, err
}
return ParseHash(out)
}
// Optional settings for Repository.Clean
type CleanOptions struct {
// Timeout for the operation
Timeout time.Duration
}
// Clean deletes all the untracked files in the repo
func (r Repository) Clean(opt *CleanOptions) error {
if opt == nil {
opt = &CleanOptions{}
}
if _, err := r.run(nil, opt.Timeout, "clean", "-fd"); err != nil {
return err
}
return nil
}
// Optional settings for Repository.Checkout
type CheckoutOptions struct {
// Timeout for the operation
Timeout time.Duration
}
// Checkout performs a checkout of a reference.
func (r Repository) Checkout(ref string, opt *CheckoutOptions) error {
if opt == nil {
opt = &CheckoutOptions{}
}
if _, err := r.run(nil, opt.Timeout, "checkout", ref); err != nil {
return err
}
return nil
}
// Optional settings for Repository.Log
type LogOptions struct {
// The git reference to the oldest commit in the range to query.
From string
// The git reference to the newest commit in the range to query.
To string
// Timeout for the operation
Timeout time.Duration
}
const logPrettyFormatArg = "--pretty=format:ǁ%Hǀ%cIǀ%an <%ae>ǀ%sǀ%b"
// Log returns the list of commits between two references (inclusive).
// The first returned commit is the most recent.
func (r Repository) Log(opt *LogOptions) ([]CommitInfo, error) {
if opt == nil {
opt = &LogOptions{}
}
args := []string{"log"}
rng := "HEAD"
if opt.To != "" {
rng = opt.To
}
if opt.From != "" {
rng = opt.From + "^.." + rng
}
args = append(args, rng, logPrettyFormatArg)
out, err := r.run(nil, opt.Timeout, args...)
if err != nil {
return nil, err
}
return parseLog(out)
}
// Optional settings for Repository.LogBetween
type LogBetweenOptions struct {
// Timeout for the operation
Timeout time.Duration
}
// LogBetween returns the list of commits between two timestamps
// The first returned commit is the most recent.
func (r Repository) LogBetween(since, until time.Time, opt *LogBetweenOptions) ([]CommitInfo, error) {
if opt == nil {
opt = &LogBetweenOptions{}
}
args := []string{"log",
"--since", since.Format(time.RFC3339),
"--until", until.Format(time.RFC3339),
logPrettyFormatArg,
}
out, err := r.run(nil, opt.Timeout, args...)
if err != nil {
return nil, err
}
return parseLog(out)
}
// FileStats describes the changes to a given file in a commit
type FileStats struct {
Insertions int
Deletions int
}
// CommitStats is a map of file to FileStats
type CommitStats map[string]FileStats
// Optional settings for Repository.Stats
type StatsOptions struct {
// Timeout for the operation
Timeout time.Duration
}
// StatsOptions returns the statistics for a given change
func (r Repository) Stats(commit CommitInfo, opt *StatsOptions) (CommitStats, error) {
if opt == nil {
opt = &StatsOptions{}
}
hash := commit.Hash.String()
args := []string{"diff", "--numstat", hash, hash + "^"}
out, err := r.run(nil, opt.Timeout, args...)
if err != nil {
return nil, err
}
stats := CommitStats{}
for _, line := range strings.Split(out, "\n") {
if out == "" {
continue
}
parts := strings.Split(line, "\t")
if len(parts) != 3 {
return nil, fmt.Errorf("failed to parse stat line: '%v'", line)
}
insertions, deletions := 0, 0
if parts[0] != "-" {
insertions, err = strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("failed to stat insertions '%v': %w", parts[0], err)
}
}
if parts[1] != "-" {
deletions, err = strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to stat deletions '%v': %w", parts[1], err)
}
}
file := parts[2]
stats[file] = FileStats{Insertions: insertions, Deletions: deletions}
}
return stats, nil
}
// CommitInfo describes a single git commit
type CommitInfo struct {
Hash Hash
Date time.Time
Author string
Subject string
Description string
}
// Optional settings for Repository.ConfigOptions
type ConfigOptions struct {
// Timeout for the operation
Timeout time.Duration
}
// Config returns the git configuration values for the repo
func (r Repository) Config(opt *ConfigOptions) (map[string]string, error) {
if opt == nil {
opt = &ConfigOptions{}
}
text, err := r.run(nil, opt.Timeout, "config", "-l")
if err != nil {
return nil, err
}
lines := strings.Split(text, "\n")
out := make(map[string]string, len(lines))
for _, line := range lines {
idx := strings.Index(line, "=")
if idx > 0 {
key, value := line[:idx], line[idx+1:]
out[key] = value
}
}
return out, nil
}
func (r Repository) run(env []string, timeout time.Duration, args ...string) (string, error) {
return r.Git.run(r.Path, env, timeout, args...)
}
func (g Git) run(dir string, env []string, timeout time.Duration, args ...string) (string, error) {
if timeout == 0 {
timeout = DefaultTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, g.exe, args...)
cmd.Dir = dir
if env != nil {
// Godocs for exec.Cmd.Env:
// "If Env contains duplicate environment keys, only the last value in
// the slice for each duplicate key is used.
cmd.Env = append(os.Environ(), env...)
}
if g.LogAllActions {
fmt.Printf("%v> %v %v\n", dir, g.exe, strings.Join(args, " "))
}
out, err := cmd.CombinedOutput()
if g.LogAllActions {
fmt.Println(string(out))
}
if err != nil {
msg := fmt.Sprintf("%v> %v %v failed:", dir, g.exe, strings.Join(args, " "))
if err := ctx.Err(); err != nil {
msg += "\n" + err.Error()
}
return string(out), fmt.Errorf("%s\n %w\n%v", msg, err, string(out))
}
return strings.TrimSpace(string(out)), nil
}
func parseLog(str string) ([]CommitInfo, error) {
msgs := strings.Split(str, "ǁ")
cls := make([]CommitInfo, 0, len(msgs))
for _, s := range msgs {
if parts := strings.Split(s, "ǀ"); len(parts) == 5 {
hash, err := ParseHash(parts[0])
if err != nil {
return nil, err
}
date, err := time.Parse(time.RFC3339, parts[1])
if err != nil {
return nil, err
}
cl := CommitInfo{
Hash: hash,
Date: date,
Author: strings.TrimSpace(parts[2]),
Subject: strings.TrimSpace(parts[3]),
Description: strings.TrimSpace(parts[4]),
}
cls = append(cls, cl)
}
}
return cls, nil
}