Updated Aug 11, 2026

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/v5

go-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.3

GitOps 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-git is 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), not wt.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-token as username for GitHub. For SSH repos, use gitssh.PublicKeys with a parsed signer from golang.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.

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy