Home | Send Feedback

Git with go-git

Published: 1. August 2023  •  go

go-git is a library written in pure Go and does not depend on a native library or the Git command-line tool.

go-git implements the low-level (plumbing) and the high-level (porcelain) Git api. go-git aims to be fully compatible with Git. The porcelain operations implemented should work as the Git CLI does. Not every git command is implemented, though. Check out the compatibility documentation to see which commands are not implemented.

This blog post will show you a few common Git operations implemented with go-git. You find more code examples in this folder of the go-git project:
https://github.com/go-git/go-git/tree/master/_examples

In the last section of this blog post, I will show you a use case for the library and build a backup application for GitHub repositories.

Installation

To add go-git as a dependency to your Go program, run the following command.

# go get github.com/go-git/go-git/v5

I use this helper function in the following examples to make the code a bit more concise.

func CheckError(err error) {
  if err != nil {
    log.Fatalf("error occurred, %v", err)
  }
}

util.go

Init

From the command line calling git init creates a new, empty Git repository. With git-go calling the method git.PlainInit does the same. The first argument is a path to a directory where the new repository will be created. git-go will create the directory if it does not already exist. The second argument is a bool that defines if the new repository will have a worktree (false -> non-bare) or not (true -> bare).

  repo, err := git.PlainInit("./my_project", false)
  util.CheckError(err)

main.go

The method returns a Repository struct which is the entry point into the newly created repository.

The method returns an error if the given directory already contains a Git repository. Here is an example of how you can check for this error.

if err != nil && errors.Is(err, git.ErrRepositoryAlreadyExists) {
    // ...
}

Clone

The PlainClone method clones a repository.

  _, err := git.PlainClone("./go-git", true, &git.CloneOptions{
    URL:      "https://github.com/go-git/go-git",
    Progress: os.Stdout,
  })

main.go

This method takes three arguments: the name of the local directory, a boolean that specifies if the cloned repository will be bare (without a worktree) or non-bare (with worktree) and as third argument an options struct where different options can be specified. In this example, the URL of the remote repository and where to print out the progress log information.

Instead of working with repositories on the local disk go-git provides the storage.Storer interface that you can pass to the git.Clone method (and git.Init method) to use a virtual filesystem. One implementation is memory.NewStorage(), which emulates a filesystem in memory. The following example clones the repository into memory and lists all the commits.

  r, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
    URL: "https://github.com/go-git/go-git",
  })
  util.CheckError(err)

  ref, err := r.Head()
  util.CheckError(err)

  cIter, err := r.Log(&git.LogOptions{From: ref.Hash()})
  util.CheckError(err)

  err = cIter.ForEach(func(c *object.Commit) error {
    fmt.Println(c)
    return nil
  })
  util.CheckError(err)

main.go

Open repository

To open an existing Git repository, go-git provides the PlainOpen method. It expects the path to the Git repository as the argument.

  myGitRepo := "./my_project"

  repo, err := git.PlainOpen(myGitRepo)
  util.CheckError(err)

  wt, err := repo.Worktree()
  util.CheckError(err)

main.go

wt is a pointer to the Worktree struct, which an application uses to perform Git operations.

Add and Commit

The following code opens an existing Git repository, creates a new file, adds it to the staging area, and commits it into the repository.

  myGitRepo := "./my_project"

  repo, err := git.PlainOpen(myGitRepo)
  util.CheckError(err)

  wt, err := repo.Worktree()
  util.CheckError(err)

  // create
  err = os.WriteFile(myGitRepo+"/file1.md", []byte("Hello World"), 0644)
  util.CheckError(err)

  _, err = wt.Add("file1.md")
  util.CheckError(err)

  hash, err := wt.Commit("create", &git.CommitOptions{
    Author: &object.Signature{
      Name:  "Mr Author",
      Email: "author@email.com",
      When:  time.Now(),
    }})
  util.CheckError(err)
  fmt.Println("commit hash: ", hash)

main.go

The author is optional. go-git reads the author information from the Git config if not specified in the code. The Commit method returns the Git commit hash.

The following code updates an existing file. Instead of adding individual files to the staging area with Add, you may call AddGlob. This method expects a glob pattern and adds all files to the staging area that match this pattern.

  err = os.WriteFile(myGitRepo+"/file1.md", []byte("Hello Earth"), 0644)
  util.CheckError(err)

  err = wt.AddGlob(".")
  util.CheckError(err)

  _, err = wt.Commit("update", &git.CommitOptions{})
  util.CheckError(err)

main.go

Instead of adding the files, you can also set the All flag of the Commit method to true. This works the same as the a flag on the command line git commit -a .... It automatically stages all files that have been modified and deleted, but only files that Git already manages. New files must always be added to the staging area first.

  err = os.Remove(myGitRepo + "/file1.md")
  util.CheckError(err)

  _, err = wt.Commit("delete", &git.CommitOptions{
    All: true,
  })

main.go

There is also another way to delete a file from the repository. Remove deletes the file from the index (staging area) and the working tree. You still have to commit the change, but because the change is already on the staging area, no add is required.

    _, err = wt.Remove("file1.md")
    util.CheckError(err)

    _, err = wt.Commit("delete", &git.CommitOptions{})
    util.CheckError(err)

Log

The method Log returns the commit history from a repository. The method returns an iterator with a ForEach method that loops over all returned commits. The Commit struct contains all the information about the commit: hash, file name, date and time, author, and message.

  myGitRepo := "./my_project"
  repo, err := git.PlainOpen(myGitRepo)
  util.CheckError(err)

  fmt.Println("all commits")
  cIter, err := repo.Log(&git.LogOptions{})
  util.CheckError(err)

  err = cIter.ForEach(func(c *object.Commit) error {
    fmt.Println(c)
    return nil
  })
  util.CheckError(err)

main.go

The output looks the same as if you would execute git log from a shell.

commit 12d5e0897c8240de746ee5d590830bfe0cde2593
Author: Mr Author <author@email.com>
Date:   Tue Nov 30 06:47:12 2021 +0100

    delete

commit 0eb60866df254a9441ae4863808d631f7537a748
Author: Mr Author <author@email.com>
Date:   Tue Nov 30 06:47:11 2021 +0100

    update

commit a6c3b76be17508072ef1fc64793395c80f732e6e
Author: Mr Author <author@email.com>
Date:   Tue Nov 30 06:47:11 2021 +0100

    create

Instead of listing the complete commit history, you can also specify filters with LogOptions. Here is an example with a date and time range. Only commits with a timestamp between Since and Until will be returned.

  since := time.Date(2021, 11, 30, 5, 47, 11, 0, time.UTC)
  until := time.Date(2021, 11, 30, 5, 47, 12, 0, time.UTC)
  cIter, err = repo.Log(&git.LogOptions{Since: &since, Until: &until})

main.go

Or return the commit history for a particular file.

  file := "file1.md"
  cIter, err = repo.Log(&git.LogOptions{
    FileName: &file,
  })

main.go

You can also pass a function with PathFilter to have more control over the filter process. Only commits will be returned where this function returns true. The function expects as the parameter the file path as a string.

  cIter, err = repo.Log(&git.LogOptions{
    PathFilter: func(s string) bool {
      return strings.HasSuffix(s, ".md")
    },
  })

main.go

With From, a program can read the commit history starting from a specific git commit hash.

  hash, err := hex.DecodeString("0eb60866df254a9441ae4863808d631f7537a748")
  util.CheckError(err)

  var commitHash [20]byte
  copy(commitHash[:], hash)

  cIter, err = repo.Log(&git.LogOptions{
    From: commitHash,
  })
  util.CheckError(err)

main.go

Status

git status displays the current state of your Git repository, showing changes that are staged, untracked files, and modifications that are not yet staged for commit.

The go-git equivalent is Status.

The following example creates a new Git repository and multiple files in different states to demonstrate the status command. The code also shows you how to programmatically work with the .gitignore file.

  const gitRepo = "./my_project_status"

  repo, err := git.PlainInit(gitRepo, false)
  util.CheckError(err)

  wt, err := repo.Worktree()
  util.CheckError(err)

  err = os.WriteFile(gitRepo+"/ignored_file.md", []byte("Ignore me"), 0644)
  util.CheckError(err)

  err = os.WriteFile(gitRepo+"/.gitignore", []byte("ignored_file.md"), 0644)
  util.CheckError(err)

  err = os.WriteFile(gitRepo+"/file1.md", []byte("Hello World 1"), 0644)
  util.CheckError(err)
  err = os.WriteFile(gitRepo+"/file2.md", []byte("Hello World 2"), 0644)
  util.CheckError(err)
  err = os.WriteFile(gitRepo+"/file3.md", []byte("Hello World 3"), 0644)
  util.CheckError(err)
  err = os.WriteFile(gitRepo+"/fileOldName.md", []byte("File with old name"), 0644)
  util.CheckError(err)

  _, err = wt.Add(".")
  util.CheckError(err)

  _, err = wt.Commit("create files", &git.CommitOptions{
    Author: &object.Signature{
      Name:  "Mr Author",
      Email: "author@email.com",
      When:  time.Now(),
    }})
  util.CheckError(err)

  err = os.WriteFile(gitRepo+"/file4.md", []byte("Hello World 4"), 0644)
  util.CheckError(err)
  err = os.WriteFile(gitRepo+"/file5.md", []byte("Hello World 5"), 0644)
  util.CheckError(err)

  _, err = wt.Add("file5.md")

  err = os.WriteFile(gitRepo+"/file2.md", []byte("Hello Earth 2"), 0644)
  util.CheckError(err)

  _, err = wt.Add("file2.md")

  err = os.Remove(gitRepo + "/file1.md")
  util.CheckError(err)
  _, err = wt.Add("file1.md")

  err = os.Remove(gitRepo + "/file3.md")
  util.CheckError(err)

  _, err = wt.Move("fileOldName.md", "fileNewName.md")
  util.CheckError(err)

  status, err := wt.Status()
  util.CheckError(err)

  fmt.Printf("is clean %t\n", status.IsClean())

  for file, status := range status {
    fmt.Print(file + ": ")
    fmt.Print("Staging: " + toHumanReadable(status.Staging))
    fmt.Print("  ")
    fmt.Print("Worktree: " + toHumanReadable(status.Worktree))
    fmt.Println()
  }

main.go

The Status method returns the status of the staging area and the working tree. Another useful method of Status is IsClean, which returns true if all files are in the Unmodified state.

The example above prints out the following:

is clean false
fileOldName.md: Staging: deleted  Worktree: unmodified
file3.md: Staging: unmodified  Worktree: deleted      
file4.md: Staging: untracked  Worktree: untracked     
file1.md: Staging: deleted  Worktree: unmodified      
file2.md: Staging: modified  Worktree: unmodified     
file5.md: Staging: added  Worktree: unmodified        
fileNewName.md: Staging: added  Worktree: unmodified  

Branch

go-git provides a series of commands that work with branches.

To get the name of the currently checked-out branch, issue the following command:

  ref, err := repo.Head()
  util.CheckError(err)
  fmt.Println("current branch: ", ref.Name())

main.go

To create a new branch, you call the method Checkout. The method expects an option struct as the sole argument. The options specify the name of the branch and if go-git should create the branch.

  err = wt.Checkout(&git.CheckoutOptions{
    Branch: plumbing.NewBranchReferenceName("new_feature"), // or "refs/heads/new_feature"
    Create: true,
  })
  util.CheckError(err)

main.go

If you want to switch to an existing branch, you use the same method but set Create to false or omit the option because false is the default value.

  err = wt.Checkout(&git.CheckoutOptions{
    Branch: "refs/heads/master", // or omit. refs/heads/master is default
    Create: false,
  })
  util.CheckError(err)

main.go

To list the branches go-git provides the Branches method

  branches, err := repo.Branches()
  util.CheckError(err)

  err = branches.ForEach(func(reference *plumbing.Reference) error {
    fmt.Println(reference.Name())
    return nil
  })
  util.CheckError(err)

main.go

To delete a branch, you call the RemoveReference method.

  err = repo.Storer.RemoveReference("refs/heads/new_feature")
  util.CheckError(err)

main.go

Tag

With go-git you can create tags with the CreateTag method.

  myGitRepo := "./my_project_tag"

  repo, err := git.PlainInit(myGitRepo, false)
  util.CheckError(err)

  wt, err := repo.Worktree()
  util.CheckError(err)

  err = os.WriteFile(myGitRepo+"/file1.md", []byte("Hello World 1"), 0644)
  util.CheckError(err)

  _, err = wt.Add(".")
  util.CheckError(err)

  hash, err := wt.Commit("initial commit", &git.CommitOptions{
    Author: &object.Signature{
      Name:  "Mr Author",
      Email: "author@email.com",
      When:  time.Now(),
    }})
  util.CheckError(err)

  _, err = repo.CreateTag("v1.0", hash, &git.CreateTagOptions{
    Message: "version 1.0",
  })
  util.CheckError(err)

main.go

To delete a tag go-git provides the DeleteTag method.

  err = repo.DeleteTag("v1.0.1")
  util.CheckError(err)

main.go

With Tags, you get a list of all tags in the repository.

  tags, err := repo.Tags()
  util.CheckError(err)

  err = tags.ForEach(func(reference *plumbing.Reference) error {
    fmt.Println(reference.Name())
    return nil
  })
  util.CheckError(err)

main.go

Add remote and push

When you work with other developers together in a team, you usually not only have your local Git repository but also a remote Git repository where everybody pushes their changes to it and fetches the changes from the other team members.

If you clone a remote repository, you don't need to configure anything. Git automatically configures the remote config. But when you start with a local Git repository and later decide to push it to a remote repository, you need to configure the remote config.

With go-git you add a new remote with the CreateRemote method.

  _, err = r.CreateRemote(&config.RemoteConfig{
    Name: "example",
    URLs: []string{"https://github.com/ralscha/test2.git"},
  })
  util.CheckError(err)

main.go

To list all remotes go-git provides the Remotes method

  list, err := r.Remotes()
  util.CheckError(err)

  for _, r := range list {
    fmt.Println(r)
  }

main.go

And finally, to delete a remote, call DeleteRemote.

  err = r.DeleteRemote("example")
  util.CheckError(err)

main.go

Pull and push

An application can pull changes from a remote repository and push changes to a remote repository. go-git provides the methods Pull and Push for this purpose.

Here is an example of a pull operation. It expects as parameter a PullOptions struct with the remote name and an optional auth struct. The example shows you how to use a private key for pulling changes from a private GitHub repository.

  auth, err := ssh.NewPublicKeysFromFile("git", "/Users/myuser/.ssh/github", "")
  util.CheckError(err)

  r, err := git.PlainClone("./test", false, &git.CloneOptions{
    URL:      "github.com:ralscha/test.git",
    Progress: os.Stdout,
    Auth:     auth,
  })
  util.CheckError(err)

  w, err := r.Worktree()
  util.CheckError(err)

  err = w.Pull(&git.PullOptions{
    Auth:       auth,
    RemoteName: "origin",
  })
  if err != nil && err != git.NoErrAlreadyUpToDate {
    util.CheckError(err)
  }

main.go

If you pull changes from a public GitHub repository, you don't need to specify the Auth option.
Pull returns an error if the repository is already up to date.

The following example shows you how to modify a file, add it to the staging area, commit it and then push the commit to the remote repository.

  readme := "./test/README.md"
  err = os.WriteFile(readme, []byte("Hello World 1"), 0644)
  util.CheckError(err)

  _, err = w.Add("README.md")
  util.CheckError(err)

  _, err = w.Commit("change README.md", &git.CommitOptions{
    Author: &object.Signature{
      Name:  "Mr Author",
      Email: "author@email.com",
      When:  time.Now(),
    }})

  err = r.Push(&git.PushOptions{
    Auth: auth,
  })
  util.CheckError(err)

main.go

go-git Use Case: GitHub Backup

This section shows you a simple use case for the go-git library. A GitHub backup application that downloads (clones) all public and private repositories of a user to the local disk and compresses them into a zip file.

The first task is to get a list of all user's repositories. This is quite easy because GitHub provides a REST API to access this kind of information. You find the documentation for this API here:
https://docs.github.com/en/rest

This demo application depends on the go-github to access the GitHub API. You add this library to your project with

# go get github.com/google/go-github/v53

Because this program not only backs up the public but also the private repositories, it needs an access token for the GitHub API so it can read the information about the private repositories. You don't need a token if you are only interested in public repositories.

To create a token, log in to your GitHub account and open the Developer Settings page: https://github.com/settings/apps
Under Person access tokens, open Fine-grained tokens and click on Generate new token.
Give it an arbitrary name and enable Read-only access under the section Repository permissions for the Administration permission.

This program reads the token from an environment variable (GITHUB_BACKUP_TOKEN).

Here is the code that lists all public and private repositories for this particular user and stores them in the allRepos struct

  token := os.Getenv("GITHUB_BACKUP_TOKEN")

  ctx := context.Background()
  ts := oauth2.StaticTokenSource(
    &oauth2.Token{AccessToken: token},
  )

  tc := oauth2.NewClient(ctx, ts)
  client := github.NewClient(tc)

  opt := &github.RepositoryListOptions{
    Visibility:  "all",
    Affiliation: "owner",
    Sort:        "full_name",
    Direction:   "asc",
    ListOptions: github.ListOptions{PerPage: 25},
  }

  var allRepos []*github.Repository
  for {
    repos, resp, err := client.Repositories.List(context.Background(), "", opt)
    if err != nil {
      log.Fatal(err)
    }
    allRepos = append(allRepos, repos...)
    if resp.NextPage == 0 {
      break
    }
    opt.Page = resp.NextPage
  }

main.go

Note that the client.Repositories.List response is paged, so the application has to check if resp.NextPage is set to a non-zero value and call client.Repositories.List continuously until resp.NextPage is zero.

Because the program clones public and private repositories, go-git needs an authentication object to access the private repositories. In this case, it reads the private key for the GitHub account.

  auth, err := ssh.NewPublicKeysFromFile("git", "/Users/myuser/.ssh/github", "")
  if err != nil {
    log.Fatal(err)
  }

main.go

Next, the program iterates over all repositories (allRepos) and clones them. If a remote repository already exists locally, it opens the repository with PlainOpen and downloads the latest changes with Fetch.

  backupDir := "./backup_github"

  for _, repo := range allRepos {
    _, err := git.PlainClone(backupDir+"/"+*repo.FullName, true, &git.CloneOptions{
      Auth:     auth,
      URL:      *repo.SSHURL,
      Progress: os.Stdout,
    })
    if err != nil && errors.Is(err, git.ErrRepositoryAlreadyExists) {
      fmt.Println("pulling", *repo.FullName)
      r, err := git.PlainOpen("./backup_github/" + *repo.FullName)
      if err != nil {
        log.Fatal(err)
      }
      err = r.Fetch(&git.FetchOptions{
        Auth: auth,
      })
      if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
        log.Fatal(err)
      }
    }
  }

main.go

And as the last step, it compresses the whole backup folder into a zip file.

  zipFile := backupDir + ".zip"
  err = zipit(backupDir, zipFile)
  if err != nil {
    log.Fatal(err)
  }

main.go

You find the zipit implementation here.


This concludes this blog post about go-git. If you want to learn more about go-git, check out the project page on GitHub and the examples folder with a lot more code examples.