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 }