08 - Git Automation
📋 Jump to Takeaways🎁 What if your CI pipeline could clone a Git repo, update a Kubernetes manifest, open a pull request, and push — all in Go, with no git binary installed?
This lesson adds devctl gitops push <repo> <service> <tag> to the CLI. After CI passes, this command handles the entire config-repo update without anyone touching a terminal.
Your team practices GitOps. Every deploy means updating an image tag in a YAML file in the config repo. The process: copy the new tag, check out the config repo, create a branch, edit the file, commit it, push, open a PR, wait for approval. Someone does this by hand for every deploy. Ten minutes of human attention per release.
You automate it. When CI passes, the pipeline clones the config repo, updates the image tag, creates a branch, commits the change, and pushes. The obvious implementation shells out to git — it's already on your laptop:
exec.Command("git", "clone", repoURL, "/tmp/config").Run()
// edit the file
exec.Command("git", "-C", "/tmp/config", "commit", "-am", "update image tag").Run()
exec.Command("git", "-C", "/tmp/config", "push").Run()It works locally. You ship it. Three days later CI is broken and you spend two hours figuring out why.
Imports in code snippets are trimmed for brevity. See the full example linked at the end for complete, compilable source.
Why go-git Instead of Shelling Out?
The approach looks reasonable at first:
func updateConfigRepo(repoURL, token, newTag string) error {
cmd := exec.Command("git", "clone", repoURL, "/tmp/config")
cmd.Env = append(os.Environ(), "GIT_ASKPASS=echo", "GIT_PASSWORD="+token)
if err := cmd.Run(); err != nil {
return err // exit status 128. That's all you get.
}
// ...
}Three things break, and they all show up in production.
First: git might not be in your runner container. Many CI images are minimal, just the Go toolchain and nothing else. Your tool compiles and deploys fine, then crashes at runtime because git isn't on PATH.
Second: auth failures give you no structure. When GitHub rejects a token, git writes fatal: Authentication failed to stderr and exits with code 128. Your code sees exit status 128. You have to parse stderr strings to know why it failed, and those strings differ across git versions.
Third: checking for changes requires parsing text. To know if there's anything to commit, you run git status --porcelain and grep the output. One extra whitespace or a locale change and your parser breaks.
go-git solves all three. Pure Go, no binary dependency, typed errors, structured return values.
go get github.com/go-git/go-git/v5go-git Library
go-git (github.com/go-git/go-git/v5) is a pure-Go implementation of Git. No CGo, no libgit2, no system git binary needed. It supports clone, fetch, push, commit, branch, tag, diff, and tree traversal.
Core types you'll use constantly:
| Type | Purpose |
|---|---|
git.Repository |
Represents a local repo |
git.Worktree |
Working directory operations |
plumbing.Hash |
SHA-1 commit/object reference |
object.Commit |
Commit metadata and tree |
object.Tree |
Directory listing at a commit |
Cloning a Repo
For automation you almost always want a shallow clone. You don't need history, and full clones of large repos add minutes to a pipeline run.
func cloneRepo(url, dir string) (*git.Repository, error) {
repo, err := git.PlainClone(dir, false, &git.CloneOptions{
URL: url,
Depth: 1,
SingleBranch: true,
ReferenceName: plumbing.Main,
})
if err != nil {
return nil, fmt.Errorf("clone %s: %w", url, err)
}
return repo, nil
}For repos already on disk, open them with git.PlainOpen(path). To show progress during a long clone, set Progress: os.Stdout in CloneOptions.
Authentication
This is exactly where exec.Command falls apart. With go-git, auth is a typed value you pass to any operation that talks to a remote. When it fails, you get a real error you can wrap with context.
Token auth for HTTPS (GitHub, GitLab):
import "github.com/go-git/go-git/v5/plumbing/transport/http"
func tokenAuth(token string) *http.BasicAuth {
return &http.BasicAuth{
Username: "x-access-token", // GitHub convention
Password: token,
}
}
func clonePrivateRepo(url, dir, token string) (*git.Repository, error) {
return git.PlainClone(dir, false, &git.CloneOptions{
URL: url,
Auth: tokenAuth(token),
})
}For SSH-authenticated repos, use gitssh.PublicKeys from github.com/go-git/go-git/v5/plumbing/transport/ssh with a parsed private key signer — the same pattern as golang.org/x/crypto/ssh from the previous lesson.
The same Auth field works on clone, fetch, and push. One auth setup, used everywhere.
Creating Branches and Committing
In the GitOps pipeline, you create a new branch for each deploy, commit the config change to that branch, and push it for review.
func createBranch(repo *git.Repository, branchName string) error {
headRef, err := repo.Head()
if err != nil {
return err
}
ref := plumbing.NewHashReference(
plumbing.NewBranchReferenceName(branchName),
headRef.Hash(),
)
return repo.Storer.SetReference(ref)
}
func checkoutBranch(repo *git.Repository, branchName string) error {
wt, err := repo.Worktree()
if err != nil {
return err
}
return wt.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(branchName),
})
}Staging and committing a specific file:
func commitFile(repo *git.Repository, filePath, message string) (plumbing.Hash, error) {
wt, err := repo.Worktree()
if err != nil {
return plumbing.ZeroHash, err
}
// Stage only the specific file — never wt.Add(".")
_, err = wt.Add(filePath)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("staging %s: %w", filePath, err)
}
hash, err := wt.Commit(message, &git.CommitOptions{
Author: &object.Signature{
Name: "Deploy Bot",
Email: "[email protected]",
When: time.Now(),
},
})
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("commit: %w", err)
}
return hash, nil
}Gotcha: wt.Add(".") stages everything in the working tree, including files you didn't intend to commit. If your automation writes a temp file during processing (a rendered template, a downloaded manifest), it ends up in the commit. Always pass the specific file path to wt.Add.
Before committing, check whether there's actually anything to stage. If the config was already at the new image tag, the file is unchanged:
wt, _ := repo.Worktree()
status, err := wt.Status()
if err != nil {
return err
}
if status.IsClean() {
fmt.Println("already up to date, nothing to commit")
return nil
}Without this check, you push an empty commit and open a PR for nothing. The status check is the structured alternative to parsing git status --porcelain.
Webhook Handlers
Git hosting platforms send webhook payloads on push events. Validate the HMAC signature before doing anything with the payload. Without this, any caller can fake a push event and trigger your automation. An attacker who knows your endpoint can push arbitrary changes to your config repo.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
)
type PushEvent struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
Repository struct {
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
} `json:"repository"`
Commits []struct {
ID string `json:"id"`
Added []string `json:"added"`
Modified []string `json:"modified"`
Removed []string `json:"removed"`
} `json:"commits"`
}
func webhookHandler(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
sig := r.Header.Get("X-Hub-Signature-256")
if !verifySignature(body, sig, secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var event PushEvent
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "parse error", http.StatusBadRequest)
return
}
changedPaths := extractChangedPaths(event)
go handleChanges(event.Repository.FullName, changedPaths)
w.WriteHeader(http.StatusOK)
}
}
func verifySignature(payload []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}Monorepo Change Detection
In monorepos, you need to know which services changed so you trigger builds only for what needs rebuilding. extractChangedPaths and handleChanges are called by the webhook handler above.
type Service struct {
Name string
Path string
}
var services = []Service{
{Name: "api", Path: "services/api/"},
{Name: "worker", Path: "services/worker/"},
{Name: "web", Path: "services/web/"},
{Name: "shared", Path: "pkg/"},
}
func extractChangedPaths(event PushEvent) []string {
seen := make(map[string]bool)
var paths []string
for _, commit := range event.Commits {
for _, files := range [][]string{commit.Added, commit.Modified, commit.Removed} {
for _, f := range files {
if !seen[f] {
seen[f] = true
paths = append(paths, f)
}
}
}
}
return paths
}
func affectedServices(changedPaths []string) []Service {
var affected []Service
for _, svc := range services {
for _, path := range changedPaths {
if strings.HasPrefix(path, svc.Path) {
affected = append(affected, svc)
break
}
}
}
// If shared code changed, rebuild everything that imports it
for _, svc := range affected {
if svc.Name == "shared" {
return services
}
}
return affected
}
func handleChanges(repo string, changedPaths []string) {
affected := affectedServices(changedPaths)
for _, svc := range affected {
fmt.Printf("triggering build for %s (repo: %s)\n", svc.Name, repo)
// Output: triggering build for api (repo: acme/platform)
// Output: triggering build for worker (repo: acme/platform)
}
}Putting It Together: the GitOps Pipeline
Here's what the full automation looks like. CI passes, the pipeline calls this, and a PR appears in the config repo:
func gitOpsDeploy(repoURL, token, service, newTag string) error {
dir, err := os.MkdirTemp("", "config-*")
if err != nil {
return err
}
defer os.RemoveAll(dir)
auth := tokenAuth(token)
fmt.Printf("cloning %s...\n", repoURL)
repo, err := git.PlainClone(dir, false, &git.CloneOptions{
URL: repoURL,
Auth: auth,
Depth: 1,
SingleBranch: true,
ReferenceName: plumbing.Main,
})
if err != nil {
return fmt.Errorf("clone: %w", err)
}
branchName := fmt.Sprintf("deploy/%s-%s", service, newTag)
if err := createBranch(repo, branchName); err != nil {
return fmt.Errorf("branch: %w", err)
}
if err := checkoutBranch(repo, branchName); err != nil {
return fmt.Errorf("checkout: %w", err)
}
// Read the service's deploy YAML, replace the image tag, write it back
configPath := filepath.Join(dir, "deploy", service+".yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("read config: %w", err)
}
// Replace image tag line: " image: myregistry/api:v1.2.2" → " image: myregistry/api:v1.2.3"
updated := regexp.MustCompile(`(image:\s*\S+:)\S+`).
ReplaceAll(data, []byte("${1}"+newTag))
if err := os.WriteFile(configPath, updated, 0644); err != nil {
return fmt.Errorf("update config: %w", err)
}
wt, err := repo.Worktree()
if err != nil {
return fmt.Errorf("worktree: %w", err)
}
status, err := wt.Status()
if err != nil {
return fmt.Errorf("status: %w", err)
}
if status.IsClean() {
fmt.Printf("%s is already at %s\n", service, newTag)
return nil
}
relPath := filepath.Join("deploy", service+".yaml")
hash, err := commitFile(repo, relPath, fmt.Sprintf("deploy: update %s to %s", service, newTag))
if err != nil {
return fmt.Errorf("commit: %w", err)
}
fmt.Printf("committed %s\n", hash)
err = repo.Push(&git.PushOptions{
Auth: auth,
RemoteName: "origin",
})
if err != nil {
return fmt.Errorf("push: %w", err)
}
fmt.Printf("pushed branch %s, open a PR to merge\n", branchName)
return nil
}Every step that would have been a fragile shell command is now a typed function call. Auth failures return a wrapped error. An unchanged file short-circuits before the push. And there's no git binary anywhere in the pipeline container.
Wire gitOpsDeploy into devctl as the gitops push subcommand:
var gitopsPushCmd = &cobra.Command{
Use: "push <repo> <service> <tag>",
Short: "Update a service image tag in the GitOps config repo",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
return fmt.Errorf("GITHUB_TOKEN is not set")
}
return gitOpsDeploy(args[0], token, args[1], args[2])
},
}
func init() {
gitopsCmd.AddCommand(gitopsPushCmd)
rootCmd.AddCommand(gitopsCmd)
}Run it with:
devctl gitops push https://github.com/acme/config api v1.2.3GitOps Commit Bot
Clones a repo, creates a feature branch, writes a generated config file, commits, and pushes. This is the foundational pattern for GitOps config automation where infrastructure changes are driven by Git commits.
Input: A GitHub token and repository URL. Output: A new branch pushed with an automated config commit.
Full source: examples/gitops-commit-bot
Key Takeaways
go-gitis a pure-Go Git implementation. No external binaries, works everywhere Go compiles.exec.Command("git", ...)fails in minimal CI containers and gives you exit codes instead of typed errors.- Use shallow clones (
Depth: 1) in CI/automation for speed; full clones only when you need history. - Always use
wt.Add(specificFile), notwt.Add("."). The dot form stages everything in the working tree, including files you didn't intend to commit. - Check
wt.Status().IsClean()before committing. Skip the push if nothing changed. - HMAC-verify webhook signatures before processing payloads. Never trust unsigned requests.
- Monorepo change detection maps file paths to services to enable selective builds.
- Token auth uses
x-access-tokenas username for GitHub. For SSH repos, usegitssh.PublicKeyswith a parsed signer fromgolang.org/x/crypto/ssh. - Tags are a one-liner:
repo.CreateTag(name, head.Hash(), opts)— useful for marking a commit after a successful deploy.
🎁 The config is pushed. But the new image needs credentials to connect to the database. Next, devctl secrets load fetches fresh secrets from Vault and injects them into the environment before the service starts.