Tag example with go-git library

Liviu Costea
5 min readMay 31, 2020

--

go-git tag repo example

One of the library I use pretty much as a systems engineer is go-git. It allows one to interact with git directly from go, without being a C or shell wrapper. But it had quite a steep learning curve for such a simple library, it took me many hours of looking at examples, browsing through the open and closed GitHub issues to find how to do quite simple things. And the longer it takes to do it, the bigger the pressure to start again on the bash road, even if you know it is going to turn out bad:

In our flows, after we validate a new binary for our application and mark it as ready for production deployment, we tag all our repos, including e2e test ones with the version that we will release. And to do that we have a cli written in go with one of the commands doing the tagging. Sounds simple, but there is a little bit of logic in it: it clones a repo, checks out a specific branch, checks if a tag exists and if not creates it from branch head and then pushes it back to the remote. In our real world scenario things are a little more complicated (they always are) as we do have to calculate the tag and the checkout branch, based on some parameters.

TLDR: If you don't want to go through the explanations and jump straight to the working code, you can find it on github.com/lcostea/go-git-tag-example. Otherwise let me walk you through the exercise.

First lets create a folder and do a go mod init so we can start our new go app (instead of lcostea.io you can use your own domain, or anything else like example.com)

mkdir go-git-tagcd go-git-taggo mod init lcostea.io/go-git-tag

This will create a go.mod file in your folder. Next we will create the main.go where all our code will be written. Inside the file we will need these imports:

package mainimport (  "fmt"  "io/ioutil"  "os"  "time"  "github.com/go-git/go-git/v5"  "github.com/go-git/go-git/v5/config"  "github.com/go-git/go-git/v5/plumbing/object"  "github.com/go-git/go-git/v5/plumbing/transport/ssh"  log "github.com/sirupsen/logrus")

We will then create the clone function, using the go-git library. There are 2 ways to clone: using https and using ssh. We will explore the second option, which is the most used one right now. It is easy to setup an Identity file for each git service (like github.com, gitlab.com, bitbucket.com etc), place it in ~/.ssh/config and stop worrying about credentials.

Unfortunately go-git library doesn’t use your configured identity files, so we would need to specify them in code, so see below how I used my GitHub public key.

func publicKey() (*ssh.PublicKeys, error) {  var publicKey *ssh.PublicKeys  sshPath := os.Getenv("HOME") + "/.ssh/github_rsa"  sshKey, _ := ioutil.ReadFile(sshPath)  publicKey, err := ssh.NewPublicKeys("git", []byte(sshKey), "")  if err != nil {    return nil, err  }  return publicKey, err}

Next we will implement the actual cloning part:

func cloneRepo(url, dir string) (*git.Repository, error) {  log.Infof("cloning %s into %s", url, dir)  auth, keyErr := publicKey()  if keyErr != nil {    return nil, keyErr  }  r, err := git.PlainClone(dir, false, &git.CloneOptions{    Progress: os.Stdout,    URL:      url,    Auth:     auth,  })  if err != nil {    if err == git.ErrRepositoryAlreadyExists {      log.Info("repo was already cloned")      return git.PlainOpen(dir)    } else {      log.Errorf("clone git repo error: %s", err)      return nil, err    }  }  return r, nil}

The library exposes some common errors you might encounter when doing operations with git, you can find a comprehensive list in repository.go. So if you run the code more than once, you might have already downloaded the repo, so we can try to recover from a "repository already exists" error. Just note that there might be something totally different in that folder and you would still get the error.

Moving on, we would like to set the actual tag, but before doing that we need to see if it actually exists, maybe somebody else added it before we did. If the tag exists we will not do anything, our job is done.

func tagExists(tag string, r *git.Repository) bool {  tagFoundErr := "tag was found"  tags, err := r.TagObjects()  if err != nil {    log.Errorf("get tags error: %s", err)    return false  }  res := false  err = tags.ForEach(func(t *object.Tag) error {    if t.Name == tag {      res = true      return fmt.Errorf(tagFoundErr)    }    return nil  })  if err != nil && err.Error() != tagFoundErr {    log.Errorf("iterate tags error: %s", err)    return false  }  return res}

The interesting part here is how to go through the list of tags and return an indicator when we actually find it. Normally I would expect such an indicator to be a bool value, but I guess in this case also the error is working, as it probably can give an error when accessing these tags. So I created a specific error message tagFoundErr := "tag was found" and used it to step out of the iterator.

Now, if it doesn't exists, we are going to create the tag. Nothing too complicated here, we take the HEAD of the branch and create an annotated tag for it.

func setTag(r *git.Repository, tag string, tagger *object.Signature) (bool, error) {  if tagExists(tag, r) {    log.Infof("tag %s already exists", tag)    return false, nil  }  log.Infof("Set tag %s", tag)  h, err := r.Head()  if err != nil {    log.Errorf("get HEAD error: %s", err)    return false, err  }  _, err = r.CreateTag(tag, h.Hash(), &git.CreateTagOptions{    Tagger:  tagger,    Message: tag,  })  if err != nil {    log.Errorf("create tag error: %s", err)    return false, err  }  return true, nil}

Next, moving on to the pushing of the tag.

func pushTags(r *git.Repository) error {  auth, _ := publicKey()  po := &git.PushOptions{    RemoteName: "origin",    Progress:   os.Stdout,    RefSpecs:    []config.RefSpec{config.RefSpec("refs/tags/*:refs/tags/*")},    Auth:       auth,  }  err := r.Push(po)  if err != nil {    if err == git.NoErrAlreadyUpToDate {      log.Info("origin remote was up to date, no push done")      return nil    }    log.Errorf("push to remote origin error: %s", err)    return err  }  return nil}

The important part here is the tag refspec: refs/tags/*:refs/tags/*. All tags live in the refs/tags, no matter if you create it locally or comes from remote, if it is annotated or light. So we need to include this and it should be the equivalent of git push --tag. Also this makes sure that all tags you created locally will be pushed to remote.

Finally below are the last 2 functions used: main and the defaultSignature function useful when setting the tag.

func defaultSignature(name, email string) *object.Signature {  return &object.Signature{    Name:  name,    Email: email,    When:  time.Now(),  }}func main() {//you will probably want to modify these variables, if you are going to use this example  url := "git@github.com:lcostea/sample-controller-workshop.git"  dir := "/tmp/sample-controller-workshop"  name := "Liviu Costea"  email := "_your_email_address_@gmail.com"  tag := "v0.1.0"  r, err := cloneRepo(url, dir)  if err != nil {    log.Errorf("clone repo error: %s", err)    return  }  if tagExists(tag, r) {    log.Infof("Tag %s already exists, nothing to do here", tag)    return  }  created, err := setTag(r, tag, defaultSignature(name, email))  if err != nil {    log.Errorf("create tag error: %s", err)    return  }  if created {    err = pushTags(r)    if err != nil {      log.Errorf("push tag error: %s", err)      return    }  }}

--

--