github.com/richardwilkes/toolbox@v1.121.0/vcs/git/repo.go (about)

     1  // Copyright (c) 2016-2024 by Richard A. Wilkes. All rights reserved.
     2  //
     3  // This Source Code Form is subject to the terms of the Mozilla Public
     4  // License, version 2.0. If a copy of the MPL was not distributed with
     5  // this file, You can obtain one at http://mozilla.org/MPL/2.0/.
     6  //
     7  // This Source Code Form is "Incompatible With Secondary Licenses", as
     8  // defined by the Mozilla Public License, version 2.0.
     9  
    10  // Package git provides simple git repository access.
    11  package git
    12  
    13  import (
    14  	"bytes"
    15  	"os"
    16  	"os/exec"
    17  	"path/filepath"
    18  	"regexp"
    19  	"strings"
    20  	"time"
    21  
    22  	"github.com/richardwilkes/toolbox/errs"
    23  	"github.com/richardwilkes/toolbox/i18n"
    24  )
    25  
    26  var (
    27  	tagsRefListRegex   = regexp.MustCompile(`(?m)tags/(\S+)$`)
    28  	originRefListRegex = regexp.MustCompile(`(?m)origin/(\S+)$`)
    29  )
    30  
    31  // Repo provides access to a git repository.
    32  type Repo struct {
    33  	remote string
    34  	local  string
    35  }
    36  
    37  // NewRepo creates a new git repository access object.
    38  func NewRepo(remote, local string) (*Repo, error) {
    39  	if _, err := exec.LookPath("git"); err != nil {
    40  		return nil, errs.New(i18n.Text("git is not installed"))
    41  	}
    42  	repo := &Repo{
    43  		remote: remote,
    44  		local:  local,
    45  	}
    46  	if repo.CheckLocal() {
    47  		out, err := repo.runFromDir("git", "config", "--get", "remote.origin.url")
    48  		if err != nil {
    49  			return nil, errs.NewWithCause(i18n.Text("Unable to retrieve local repository information"), err)
    50  		}
    51  		localRemote := strings.TrimSpace(string(out))
    52  		if remote != "" && localRemote != remote {
    53  			return nil, errs.Newf(i18n.Text("Existing remote (%) does not match requested remote (%s)"), localRemote, remote)
    54  		}
    55  		if remote == "" && localRemote != "" {
    56  			repo.remote = localRemote
    57  		}
    58  	}
    59  	return repo, nil
    60  }
    61  
    62  // CheckLocal verifies the local location is a Git repo.
    63  func (repo *Repo) CheckLocal() bool {
    64  	_, err := os.Stat(repo.local + "/.git")
    65  	return err == nil
    66  }
    67  
    68  // Init initializes a git repository at the local location.
    69  func (repo *Repo) Init() error {
    70  	if _, err := exec.Command("git", "init", repo.local).CombinedOutput(); err != nil {
    71  		return errs.NewWithCause(i18n.Text("Unable to initialize repository"), err)
    72  	}
    73  	return nil
    74  }
    75  
    76  // Clone a repository.
    77  func (repo *Repo) Clone() error {
    78  	if _, err := exec.Command("git", "clone", repo.remote, repo.local).CombinedOutput(); err != nil {
    79  		return errs.NewWithCause(i18n.Text("Unable to clone repository"), err)
    80  	}
    81  	return nil
    82  }
    83  
    84  // Checkout a revision, branch or tag.
    85  func (repo *Repo) Checkout(revisionBranchOrTag string) error {
    86  	if _, err := repo.runFromDir("git", "checkout", revisionBranchOrTag); err != nil {
    87  		return errs.NewWithCausef(err, i18n.Text("Unable to check out '%s'"), revisionBranchOrTag)
    88  	}
    89  	return nil
    90  }
    91  
    92  // Fetch a repository.
    93  func (repo *Repo) Fetch() error {
    94  	if _, err := repo.runFromDir("git", "fetch", "--tags"); err != nil {
    95  		return errs.NewWithCause(i18n.Text("Unable to fetch"), err)
    96  	}
    97  	return nil
    98  }
    99  
   100  // Pull a repository.
   101  func (repo *Repo) Pull() error {
   102  	if _, err := repo.runFromDir("git", "pull"); err != nil {
   103  		return errs.NewWithCause(i18n.Text("Unable to pull"), err)
   104  	}
   105  	return nil
   106  }
   107  
   108  // HasDetachedHead returns true if the repo is currently in a "detached head" state.
   109  func (repo *Repo) HasDetachedHead() bool {
   110  	contents, err := os.ReadFile(filepath.Join(repo.local, ".git", "HEAD"))
   111  	return err != nil && !bytes.HasPrefix(bytes.TrimSpace(contents), []byte("ref: "))
   112  }
   113  
   114  // Date retrieves the date on the latest commit.
   115  func (repo *Repo) Date() (time.Time, error) {
   116  	out, err := repo.runFromDir("git", "log", "-1", "--date=iso", "--pretty=format:%cd")
   117  	if err != nil {
   118  		return time.Time{}, errs.NewWithCause(i18n.Text("Unable to retrieve revision date"), err)
   119  	}
   120  	t, err := time.Parse("2006-01-02 15:04:05 -0700", string(out))
   121  	if err != nil {
   122  		return time.Time{}, errs.NewWithCause(i18n.Text("Unable to retrieve revision date"), err)
   123  	}
   124  	return t, nil
   125  }
   126  
   127  // Branches returns a list of available branches.
   128  func (repo *Repo) Branches() ([]string, error) {
   129  	out, err := repo.runFromDir("git", "show-ref")
   130  	if err != nil {
   131  		return []string{}, errs.NewWithCause(i18n.Text("Unable to retrieve branches"), err)
   132  	}
   133  	return repo.referenceList(string(out), originRefListRegex), nil
   134  }
   135  
   136  // Revision retrieves the current revision.
   137  func (repo *Repo) Revision() (string, error) {
   138  	out, err := repo.runFromDir("git", "rev-parse", "HEAD")
   139  	if err != nil {
   140  		return "", errs.NewWithCause(i18n.Text("Unable to retrieve checked out revision"), err)
   141  	}
   142  	return strings.TrimSpace(string(out)), nil
   143  }
   144  
   145  // Current returns the current branch/tag/revision.
   146  // * Branch name if on the tip of the branch
   147  // * Tag if on a tag
   148  // * Otherwise a revision id
   149  func (repo *Repo) Current() (string, error) {
   150  	if out, err := repo.runFromDir("git", "symbolic-ref", "HEAD"); err == nil {
   151  		return string(bytes.TrimSpace(bytes.TrimPrefix(out, []byte("refs/heads/")))), nil
   152  	}
   153  	rev, err := repo.Revision()
   154  	if err != nil {
   155  		return "", err
   156  	}
   157  	tags, err := repo.TagsFromCommit(rev)
   158  	if err != nil {
   159  		return "", err
   160  	}
   161  	if len(tags) > 0 {
   162  		return tags[0], nil
   163  	}
   164  	return rev, nil
   165  }
   166  
   167  // Tags returns a list of available tags.
   168  func (repo *Repo) Tags() ([]string, error) {
   169  	out, err := repo.runFromDir("git", "show-ref")
   170  	if err != nil {
   171  		return []string{}, errs.NewWithCause(i18n.Text("Unable to retrieve tags"), err)
   172  	}
   173  	return repo.referenceList(string(out), tagsRefListRegex), nil
   174  }
   175  
   176  // TagsFromCommit retrieves the tags from a revision.
   177  func (repo *Repo) TagsFromCommit(rev string) ([]string, error) {
   178  	out, err := repo.runFromDir("git", "show-ref", "-d")
   179  	if err != nil {
   180  		return nil, errs.NewWithCause(i18n.Text("Unable to retrieve tags"), err)
   181  	}
   182  	lines := strings.Split(string(out), "\n")
   183  	list := make([]string, 0, len(lines))
   184  	for _, line := range lines {
   185  		line = strings.TrimSpace(line)
   186  		if strings.HasPrefix(line, rev) {
   187  			list = append(list, line)
   188  		}
   189  	}
   190  	tags := repo.referenceList(strings.Join(list, "\n"), tagsRefListRegex)
   191  	result := make([]string, 0, len(tags))
   192  	for _, t := range tags {
   193  		result = append(result, strings.TrimSuffix(t, "^{}"))
   194  	}
   195  	return result, nil
   196  }
   197  
   198  func (repo *Repo) referenceList(content string, re *regexp.Regexp) []string {
   199  	submatches := re.FindAllStringSubmatch(content, -1)
   200  	out := make([]string, 0, len(submatches))
   201  	for _, m := range submatches {
   202  		out = append(out, m[1])
   203  	}
   204  	return out
   205  }
   206  
   207  // HasChanges returns true if changes are present.
   208  func (repo *Repo) HasChanges() bool {
   209  	out, err := repo.runFromDir("git", "status", "--porcelain")
   210  	return err != nil || len(out) != 0
   211  }
   212  
   213  func (repo *Repo) runFromDir(cmd string, args ...string) ([]byte, error) {
   214  	c := exec.Command(cmd, args...)
   215  	c.Dir = repo.local
   216  	c.Env = mergeEnvLists([]string{"PWD=" + c.Dir}, os.Environ())
   217  	return c.CombinedOutput()
   218  }
   219  
   220  func mergeEnvLists(in, out []string) []string {
   221  NextVar:
   222  	for _, ikv := range in {
   223  		k := strings.SplitAfterN(ikv, "=", 2)[0] + "="
   224  		for i, okv := range out {
   225  			if strings.HasPrefix(okv, k) {
   226  				out[i] = ikv
   227  				continue NextVar
   228  			}
   229  		}
   230  		out = append(out, ikv)
   231  	}
   232  	return out
   233  }