Git hooks are scripts that run at specific points in the Git workflow. They live inside every repository. You do not need to install anything. You do not need a server. They just work.
In this guide, you will start with simple shell scripts for pre-commit and pre-push checks. Then you will build each piece in Go. By the end, you will have a complete webhook server that receives push events over HTTP, validates them with HMAC signatures, and triggers builds.
Every Go program in this article compiles with the standard library. No third-party packages.
Step 1: Pre-Commit Hooks — Catch Problems Early
A pre-commit hook runs before every commit. If the hook exits with a non-zero code, the commit is aborted. This is where you catch problems before they enter the history.
The shell hook
Create the hook file in your repository.
touch .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
The chmod +x is required. Git will silently ignore hooks that are not executable.
Now open .git/hooks/pre-commit and add checks. This version looks for debug print statements and large files.
#!/bin/bash
echo "Running pre-commit checks..."
# Check for debug statements in Go files
grep -rn 'fmt.Println("DEBUG' *.go && exit 1
# Check for large files being committed
find . -size +1M -not -path './.git/*' | head -5
if [ $? -eq 0 ]; then
echo "ERROR: Files larger than 1MB found."
exit 1
fi
echo "All checks passed."
exit 0
Test it by adding a Go file with a debug statement.
echo 'package main
import "fmt"
func main() {
fmt.Println("DEBUG this should not be committed")
}' > test.go
git add test.go
git commit -m "test commit"
Output:
Running pre-commit checks...
test.go:4: fmt.Println("DEBUG this should not be committed")
The commit was blocked. Good. But there is a problem.
The bug
The grep command exits immediately when it finds a match. If there are three files with debug statements, you only see the first one. The developer fixes that one file, commits again, sees another one, fixes it, commits again, and so on. This is slow and frustrating.
Also, the find check never runs. The grep line uses && exit 1, which means: if grep finds a match (exit 0), then exit 1. The script stops right there. Everything after it is skipped.
The fix
Collect all violations first. Report them all. Exit with an error at the end.
#!/bin/bash
echo "Running pre-commit checks..."
FAILED=0
# Check for debug statements in Go files
DEBUG_MATCHES=$(grep -rn 'fmt.Println("DEBUG' *.go 2>/dev/null)
if [ -n "$DEBUG_MATCHES" ]; then
echo "ERROR: Debug statements found:"
echo "$DEBUG_MATCHES"
FAILED=1
fi
# Check for large files being committed
LARGE_FILES=$(find . -size +1M -not -path './.git/*' 2>/dev/null)
if [ -n "$LARGE_FILES" ]; then
echo "ERROR: Files larger than 1MB:"
echo "$LARGE_FILES"
FAILED=1
fi
if [ $FAILED -eq 1 ]; then
echo ""
echo "Pre-commit checks failed. Fix the issues above and try again."
exit 1
fi
echo "All checks passed."
exit 0
Now both checks run every time. The developer sees all problems in one shot.
Go version: a pre-commit checker
Shell scripts work, but they are hard to test and hard to extend. Here is a Go program that does the same thing. It runs multiple checks and reports which ones failed.
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
type CheckResult struct {
Name string
Passed bool
Messages []string
}
func checkDebugStatements() CheckResult {
result := CheckResult{Name: "debug-statements", Passed: true}
err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() && info.Name() == ".git" {
return filepath.SkipDir
}
if !strings.HasSuffix(path, ".go") {
return nil
}
file, err := os.Open(path)
if err != nil {
return nil
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if strings.Contains(line, `fmt.Println("DEBUG`) {
result.Passed = false
result.Messages = append(result.Messages,
fmt.Sprintf(" %s:%d: %s", path, lineNum, strings.TrimSpace(line)))
}
}
return nil
})
if err != nil {
result.Messages = append(result.Messages, " error walking directory: "+err.Error())
}
return result
}
func checkLargeFiles() CheckResult {
result := CheckResult{Name: "large-files", Passed: true}
const maxSize int64 = 1 * 1024 * 1024 // 1 MB
err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() && info.Name() == ".git" {
return filepath.SkipDir
}
if !info.IsDir() && info.Size() > maxSize {
result.Passed = false
sizeMB := float64(info.Size()) / 1024 / 1024
result.Messages = append(result.Messages,
fmt.Sprintf(" %s: %.2f MB", path, sizeMB))
}
return nil
})
if err != nil {
result.Messages = append(result.Messages, " error walking directory: "+err.Error())
}
return result
}
func checkGoFmt() CheckResult {
result := CheckResult{Name: "go-fmt", Passed: true}
cmd := exec.Command("gofmt", "-l", ".")
output, err := cmd.Output()
if err != nil {
result.Messages = append(result.Messages, " gofmt not available, skipping")
return result
}
unformatted := strings.TrimSpace(string(output))
if unformatted != "" {
result.Passed = false
for _, file := range strings.Split(unformatted, "\n") {
result.Messages = append(result.Messages, " "+file)
}
}
return result
}
func main() {
fmt.Println("Running pre-commit checks...")
fmt.Println()
checks := []CheckResult{
checkDebugStatements(),
checkLargeFiles(),
checkGoFmt(),
}
allPassed := true
for _, check := range checks {
if check.Passed {
fmt.Printf("[PASS] %s\n", check.Name)
} else {
fmt.Printf("[FAIL] %s\n", check.Name)
for _, msg := range check.Messages {
fmt.Println(msg)
}
allPassed = false
}
}
fmt.Println()
if !allPassed {
fmt.Println("Pre-commit checks failed.")
os.Exit(1)
}
fmt.Println("All checks passed.")
}
Build it and install it as the hook.
go build -o .git/hooks/pre-commit ./cmd/precommit/main.go
Now the hook is a compiled binary. It runs faster than a shell script, and you can write unit tests for each check function.
Step 2: Pre-Push Hooks — Validate Before Sharing
A pre-push hook runs before git push sends commits to the remote. This is the last chance to catch problems before other people see your code.
The shell hook
touch .git/hooks/pre-push
chmod +x .git/hooks/pre-push
This hook runs tests and checks the branch name.
#!/bin/bash
echo "Running pre-push checks..."
# Run tests
go test ./...
if [ $? -ne 0 ]; then
echo "ERROR: Tests failed. Push aborted."
exit 1
fi
# Prevent pushing to main
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" = "main" ]; then
echo "ERROR: Cannot push directly to main. Use a feature branch."
exit 1
fi
echo "Pre-push checks passed."
exit 0
Test it.
git checkout main
git push origin main
Output:
Running pre-push checks...
ok myproject/pkg/util 0.003s
ERROR: Cannot push directly to main. Use a feature branch.
The push was blocked. But there is a problem.
The bug
The hook checks the current branch name with git rev-parse --abbrev-ref HEAD. This tells you what branch you are on right now, not which remote branch you are pushing to.
Imagine this scenario: You are on a feature branch called deploy-fix. You run git push origin main. The hook checks the current branch, sees deploy-fix, and allows the push. You just pushed to main from a feature branch. The protection did nothing.
The pre-push hook receives ref information on stdin. Each line has four fields.
read local_ref local_sha remote_ref remote_sha
The remote_ref tells you where the commits are going. That is what you need to check.
The fix
Parse the remote ref from stdin.
#!/bin/bash
echo "Running pre-push checks..."
# Run tests
go test ./...
if [ $? -ne 0 ]; then
echo "ERROR: Tests failed. Push aborted."
exit 1
fi
# Read ref info from stdin
while read local_ref local_sha remote_ref remote_sha; do
if [ "$remote_ref" = "refs/heads/main" ]; then
echo "ERROR: Cannot push to main. Use a pull request."
exit 1
fi
done
echo "Pre-push checks passed."
exit 0
Now the hook checks the actual destination, not the current branch. Pushing to refs/heads/main is blocked regardless of which branch you are on.
Go version: a pre-push validator
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"strings"
)
type PushRef struct {
LocalRef string
LocalSHA string
RemoteRef string
RemoteSHA string
}
func readPushRefs() []PushRef {
var refs []PushRef
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
parts := strings.Fields(scanner.Text())
if len(parts) == 4 {
refs = append(refs, PushRef{
LocalRef: parts[0],
LocalSHA: parts[1],
RemoteRef: parts[2],
RemoteSHA: parts[3],
})
}
}
return refs
}
func runTests() error {
cmd := exec.Command("go", "test", "./...")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func checkProtectedBranches(refs []PushRef) error {
protected := map[string]bool{
"refs/heads/main": true,
"refs/heads/master": true,
"refs/heads/release": true,
}
for _, ref := range refs {
if protected[ref.RemoteRef] {
return fmt.Errorf("cannot push to protected branch: %s", ref.RemoteRef)
}
}
return nil
}
func main() {
fmt.Println("Running pre-push checks...")
fmt.Println()
refs := readPushRefs()
// Check protected branches
fmt.Print("Checking branch protection... ")
if err := checkProtectedBranches(refs); err != nil {
fmt.Println("FAILED")
fmt.Printf(" %s\n", err)
os.Exit(1)
}
fmt.Println("OK")
// Run tests
fmt.Println("Running tests...")
if err := runTests(); err != nil {
fmt.Println()
fmt.Println("Tests failed. Push aborted.")
os.Exit(1)
}
fmt.Println()
fmt.Println("Pre-push checks passed.")
}
The Go version has a clear advantage: you can define a list of protected branches instead of checking one at a time. You can also add more branches later without changing the logic.
Build and install it.
go build -o .git/hooks/pre-push ./cmd/prepush/main.go
Step 3: Server-Side Post-Receive Hooks
Client-side hooks (pre-commit, pre-push) run on the developer’s machine. Server-side hooks run on the machine that receives the push. A post-receive hook runs after a push is fully accepted. It cannot reject the push, but it can trigger actions like builds or deployments.
Setting up a bare repository
Server-side hooks live in bare repositories. A bare repository has no working tree. It is what remote servers use.
mkdir -p /opt/repos/myproject.git
cd /opt/repos/myproject.git
git init --bare
Now create the post-receive hook.
touch /opt/repos/myproject.git/hooks/post-receive
chmod +x /opt/repos/myproject.git/hooks/post-receive
The shell hook
The post-receive hook reads lines from stdin. Each line has the old revision, new revision, and ref name.
#!/bin/bash
while read oldrev newrev refname; do
echo "Ref updated: $refname"
echo " Old: $oldrev"
echo " New: $newrev"
# Only trigger builds for the main branch
if [ "$refname" = "refs/heads/main" ]; then
echo "Triggering build for $newrev..."
/opt/build.sh "$newrev"
fi
done
The build script /opt/build.sh does the actual work.
#!/bin/bash
# /opt/build.sh
REVISION=$1
WORK_DIR="/opt/builds/$REVISION"
mkdir -p "$WORK_DIR"
git --git-dir=/opt/repos/myproject.git archive "$REVISION" | tar -x -C "$WORK_DIR"
cd "$WORK_DIR"
go build -o app ./cmd/server
echo "Build complete for $REVISION"
Push to the bare repository.
git remote add deploy ssh://server/opt/repos/myproject.git
git push deploy main
Output:
remote: Ref updated: refs/heads/main
remote: Old: abc1234
remote: New: def5678
remote: Triggering build for def5678...
remote: Build complete for def5678
It works. But there is a problem.
The bug
The build script runs synchronously. The git push command blocks until the build finishes. If the build takes five minutes, the developer stares at a frozen terminal for five minutes. This is not acceptable.
The fix
Run the build in the background. Write the PID to a file so you can track it later.
#!/bin/bash
while read oldrev newrev refname; do
echo "Ref updated: $refname"
if [ "$refname" = "refs/heads/main" ]; then
echo "Starting build for $newrev in background..."
nohup /opt/build.sh "$newrev" > "/opt/logs/build-$newrev.log" 2>&1 &
BUILD_PID=$!
echo "$BUILD_PID" > "/opt/logs/build-$newrev.pid"
echo "Build started with PID $BUILD_PID. Check /opt/logs/build-$newrev.log for output."
fi
done
Now the push returns immediately. The build runs in the background. You can check its status later.
# Check if build is still running
PID=$(cat /opt/logs/build-def5678.pid)
ps -p $PID > /dev/null && echo "Still running" || echo "Finished"
# Check the build log
tail -f /opt/logs/build-def5678.log
Go version: a post-receive handler
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
type RefUpdate struct {
OldRev string
NewRev string
RefName string
}
func parseRefUpdates() []RefUpdate {
var updates []RefUpdate
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
parts := strings.Fields(scanner.Text())
if len(parts) == 3 {
updates = append(updates, RefUpdate{
OldRev: parts[0],
NewRev: parts[1],
RefName: parts[2],
})
}
}
return updates
}
func triggerBuild(rev string) {
logFile := fmt.Sprintf("/opt/logs/build-%s.log", rev)
pidFile := fmt.Sprintf("/opt/logs/build-%s.pid", rev)
f, err := os.Create(logFile)
if err != nil {
fmt.Printf(" ERROR: Cannot create log file: %s\n", err)
return
}
cmd := exec.Command("/opt/build.sh", rev)
cmd.Stdout = f
cmd.Stderr = f
err = cmd.Start()
if err != nil {
fmt.Printf(" ERROR: Cannot start build: %s\n", err)
f.Close()
return
}
pid := cmd.Process.Pid
os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", pid)), 0644)
fmt.Printf(" Build started with PID %d\n", pid)
fmt.Printf(" Log: %s\n", logFile)
// Release the process so it continues after this program exits
go func() {
cmd.Wait()
f.Close()
}()
}
func main() {
updates := parseRefUpdates()
timestamp := time.Now().Format("2006-01-02 15:04:05")
for _, update := range updates {
fmt.Printf("[%s] Ref updated: %s\n", timestamp, update.RefName)
fmt.Printf(" %s -> %s\n", update.OldRev[:8], update.NewRev[:8])
if update.RefName == "refs/heads/main" {
fmt.Println(" Triggering build...")
triggerBuild(update.NewRev)
}
}
}
Build and install it in the bare repository.
go build -o /opt/repos/myproject.git/hooks/post-receive ./cmd/postreceive/main.go
The Go version handles errors properly. If the build script cannot start, you get a clear error message. If it starts, you get the PID and log file path.
Step 4: HTTP Webhook Receiver
Git hooks work well for direct pushes. But most teams use hosted Git services. These services send HTTP webhooks when events happen. You need a server that listens for these webhooks.
Quick debugging with netcat
Before writing any code, use nc to see what a webhook request looks like.
nc -l -p 9000
In another terminal, send a test webhook.
curl -X POST \
-H 'Content-Type: application/json' \
-d '{"ref":"refs/heads/main","after":"abc123"}' \
http://localhost:9000/webhook
The netcat terminal shows the raw HTTP request.
POST /webhook HTTP/1.1
Host: localhost:9000
Content-Type: application/json
Content-Length: 42
{"ref":"refs/heads/main","after":"abc123"}
Now you know what the server needs to handle. Kill netcat and build a real server.
Go version: basic HTTP webhook receiver
package main
import (
"fmt"
"io"
"net/http"
"time"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Cannot read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
timestamp := time.Now().Format("15:04:05")
fmt.Printf("[%s] Received webhook: %d bytes\n", timestamp, len(body))
fmt.Printf(" Body: %s\n", string(body))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"received"}`))
}
func main() {
http.HandleFunc("/webhook", webhookHandler)
fmt.Println("Webhook server listening on :9000")
err := http.ListenAndServe(":9000", nil)
if err != nil {
fmt.Printf("Server error: %s\n", err)
}
}
Run it and test.
go run main.go &
curl -X POST \
-H 'Content-Type: application/json' \
-d '{"ref":"refs/heads/main"}' \
http://localhost:9000/webhook
Output:
Webhook server listening on :9000
[14:32:07] Received webhook: 25 bytes
Body: {"ref":"refs/heads/main"}
It works. But there is a serious problem.
The bug
This server accepts any POST to /webhook. There is no authentication. Anyone who knows the URL can trigger builds. A random person on the internet, a bot scanning for open endpoints, or a disgruntled employee can send fake webhooks.
The fix
Most Git hosting services sign webhook payloads with a shared secret. They compute an HMAC-SHA256 hash of the request body and send it in the X-Hub-Signature-256 header. Your server must verify this signature.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const webhookSecret = "your-secret-key-here"
func verifySignature(body []byte, signatureHeader string) bool {
if signatureHeader == "" {
return false
}
// The header looks like: sha256=abc123def456...
if !strings.HasPrefix(signatureHeader, "sha256=") {
return false
}
receivedSig := signatureHeader[7:] // Remove "sha256=" prefix
// Compute expected signature
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(body)
expectedSig := hex.EncodeToString(mac.Sum(nil))
// Use hmac.Equal for constant-time comparison (prevents timing attacks)
return hmac.Equal([]byte(receivedSig), []byte(expectedSig))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Cannot read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
timestamp := time.Now().Format("15:04:05")
// Verify HMAC signature
signature := r.Header.Get("X-Hub-Signature-256")
if !verifySignature(body, signature) {
fmt.Printf("[%s] REJECTED: Invalid signature\n", timestamp)
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
fmt.Printf("[%s] VERIFIED: Webhook received (%d bytes)\n", timestamp, len(body))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"accepted"}`))
}
func main() {
http.HandleFunc("/webhook", webhookHandler)
fmt.Println("Webhook server listening on :9000")
fmt.Println("HMAC verification: enabled")
err := http.ListenAndServe(":9000", nil)
if err != nil {
fmt.Printf("Server error: %s\n", err)
}
}
Test with a valid signature.
# Compute the signature for the payload
PAYLOAD='{"ref":"refs/heads/main"}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "your-secret-key-here" | awk '{print $2}')
curl -X POST \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: sha256=$SIGNATURE" \
-d "$PAYLOAD" \
http://localhost:9000/webhook
Output:
[14:35:12] VERIFIED: Webhook received (25 bytes)
Test with an invalid signature.
curl -X POST \
-H 'Content-Type: application/json' \
-H 'X-Hub-Signature-256: sha256=0000000000000000000000000000000000000000000000000000000000000000' \
-d '{"ref":"refs/heads/main"}' \
http://localhost:9000/webhook
Output:
[14:35:18] REJECTED: Invalid signature
The hmac.Equal function is important. It does a constant-time comparison. A regular == comparison returns faster when the first differing byte is found. An attacker can measure this timing difference to guess the secret one byte at a time. Constant-time comparison always takes the same amount of time regardless of where the strings differ.
Step 5: Webhook Payload Parsing and Filtering
The server can receive and verify webhooks. Now it needs to understand what happened. The webhook payload contains the branch name, commit hashes, and changed files.
Inspecting payloads with jq
Use jq to explore the structure of a webhook payload.
# Save a sample payload to a file
cat > payload.json << 'EOF'
{
"ref": "refs/heads/main",
"before": "abc1234567890",
"after": "def5678901234",
"commits": [
{
"id": "def5678901234",
"message": "Update config and add new handler",
"added": ["pkg/handler/new.go"],
"modified": ["config.yaml", "main.go"],
"removed": []
}
],
"repository": {
"name": "myproject",
"full_name": "org/myproject"
},
"pusher": {
"name": "developer1"
}
}
EOF
Extract specific fields.
# Get the branch name
jq '.ref' payload.json
# Output: "refs/heads/main"
# Get all modified files across all commits
jq '.commits[].modified[]' payload.json
# Output:
# "config.yaml"
# "main.go"
# Get the pusher name
jq '.pusher.name' payload.json
# Output: "developer1"
# Get added, modified, and removed files in one query
jq '[.commits[] | (.added + .modified + .removed)[]] | unique' payload.json
# Output: ["config.yaml", "main.go", "pkg/handler/new.go"]
Go version: parse the webhook payload
Define Go structs that match the JSON structure.
package main
import (
"encoding/json"
"fmt"
"strings"
)
type WebhookPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
Commits []Commit `json:"commits"`
Repository Repository `json:"repository"`
Pusher Pusher `json:"pusher"`
}
type Commit struct {
ID string `json:"id"`
Message string `json:"message"`
Added []string `json:"added"`
Modified []string `json:"modified"`
Removed []string `json:"removed"`
}
type Repository struct {
Name string `json:"name"`
FullName string `json:"full_name"`
}
type Pusher struct {
Name string `json:"name"`
}
func (p *WebhookPayload) Branch() string {
return strings.TrimPrefix(p.Ref, "refs/heads/")
}
func (p *WebhookPayload) ChangedFiles() []string {
seen := make(map[string]bool)
var files []string
for _, commit := range p.Commits {
for _, f := range commit.Added {
if !seen[f] {
seen[f] = true
files = append(files, f)
}
}
for _, f := range commit.Modified {
if !seen[f] {
seen[f] = true
files = append(files, f)
}
}
for _, f := range commit.Removed {
if !seen[f] {
seen[f] = true
files = append(files, f)
}
}
}
return files
}
func main() {
raw := `{
"ref": "refs/heads/main",
"before": "abc1234567890",
"after": "def5678901234",
"commits": [{
"id": "def5678901234",
"message": "Update config",
"added": ["new.go"],
"modified": ["main.go"],
"removed": ["old.go"]
}],
"repository": {"name": "myproject", "full_name": "org/myproject"},
"pusher": {"name": "developer1"}
}`
var payload WebhookPayload
err := json.Unmarshal([]byte(raw), &payload)
if err != nil {
fmt.Printf("Parse error: %s\n", err)
return
}
fmt.Printf("Repository: %s\n", payload.Repository.FullName)
fmt.Printf("Branch: %s\n", payload.Branch())
fmt.Printf("Pusher: %s\n", payload.Pusher.Name)
fmt.Printf("Commit: %s\n", payload.After[:8])
fmt.Printf("Changed files:\n")
for _, f := range payload.ChangedFiles() {
fmt.Printf(" - %s\n", f)
}
}
Output:
Repository: org/myproject
Branch: main
Pusher: developer1
Commit: def56789
Changed files:
- new.go
- main.go
- old.go
Now add branch filtering. You probably only want to build when specific branches are pushed to.
The bug
A common mistake is comparing the raw ref field directly with the branch name.
// Bug: this never matches
if payload.Ref == "main" {
fmt.Println("Building main branch...")
}
The ref field contains "refs/heads/main", not "main". The comparison always fails. No builds are ever triggered. The server receives webhooks, logs them, and does nothing.
This is a silent failure. There is no error message. The server looks like it is working. You only notice the problem when you realize builds are not running.
The fix
Use strings.TrimPrefix to strip the refs/heads/ prefix before comparing.
func (p *WebhookPayload) Branch() string {
return strings.TrimPrefix(p.Ref, "refs/heads/")
}
// Now this works
if payload.Branch() == "main" {
fmt.Println("Building main branch...")
}
The Branch() method handles this once. Every place that needs the branch name calls this method instead of parsing the ref again.
Here is the complete filtering logic with support for multiple branches and path-based filtering.
package main
import (
"encoding/json"
"fmt"
"strings"
)
type WebhookPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
Commits []Commit `json:"commits"`
Repository Repository `json:"repository"`
Pusher Pusher `json:"pusher"`
}
type Commit struct {
ID string `json:"id"`
Message string `json:"message"`
Added []string `json:"added"`
Modified []string `json:"modified"`
Removed []string `json:"removed"`
}
type Repository struct {
Name string `json:"name"`
FullName string `json:"full_name"`
}
type Pusher struct {
Name string `json:"name"`
}
type BuildConfig struct {
Branches []string // Branches that trigger builds
PathFilters []string // Only build if files in these paths changed
}
func (p *WebhookPayload) Branch() string {
return strings.TrimPrefix(p.Ref, "refs/heads/")
}
func (p *WebhookPayload) ChangedFiles() []string {
seen := make(map[string]bool)
var files []string
for _, commit := range p.Commits {
all := make([]string, 0)
all = append(all, commit.Added...)
all = append(all, commit.Modified...)
all = append(all, commit.Removed...)
for _, f := range all {
if !seen[f] {
seen[f] = true
files = append(files, f)
}
}
}
return files
}
func shouldBuild(payload *WebhookPayload, config BuildConfig) (bool, string) {
// Check branch
branch := payload.Branch()
branchMatch := false
for _, b := range config.Branches {
if b == branch {
branchMatch = true
break
}
}
if !branchMatch {
return false, fmt.Sprintf("branch %q not in build list", branch)
}
// Check path filters (if configured)
if len(config.PathFilters) > 0 {
files := payload.ChangedFiles()
for _, file := range files {
for _, prefix := range config.PathFilters {
if strings.HasPrefix(file, prefix) {
return true, fmt.Sprintf("file %q matches filter %q", file, prefix)
}
}
}
return false, "no changed files match path filters"
}
return true, "branch matches, no path filters"
}
func main() {
raw := `{
"ref": "refs/heads/main",
"after": "def5678901234",
"commits": [{
"id": "def5678901234",
"message": "Update docs only",
"added": [],
"modified": ["docs/readme.md"],
"removed": []
}],
"repository": {"name": "myproject", "full_name": "org/myproject"},
"pusher": {"name": "developer1"}
}`
var payload WebhookPayload
json.Unmarshal([]byte(raw), &payload)
config := BuildConfig{
Branches: []string{"main", "staging"},
PathFilters: []string{"cmd/", "pkg/", "internal/"},
}
build, reason := shouldBuild(&payload, config)
fmt.Printf("Should build: %v\n", build)
fmt.Printf("Reason: %s\n", reason)
}
Output:
Should build: false
Reason: no changed files match path filters
The push changed docs/readme.md, but the build config only watches cmd/, pkg/, and internal/. No build is triggered. This prevents wasting build time on documentation-only changes.
Step 6: Complete Webhook Server With Build Triggering
Now combine everything from the previous steps into one server. This server receives webhooks, validates HMAC signatures, parses payloads, filters by branch and path, queues builds, and reports status.
Architecture overview
The server has three parts:
- HTTP handler: receives webhooks and validates signatures
- Build queue: ensures only one build runs at a time
- Status endpoint: returns current build state as JSON
Linux: testing the complete flow
Before looking at the code, set up a test environment.
# Create a build script
mkdir -p /tmp/webhook-test
cat > /tmp/webhook-test/build.sh << 'SCRIPT'
#!/bin/bash
echo "Building revision $1..."
sleep 2
echo "Running tests..."
sleep 1
echo "Build complete."
exit 0
SCRIPT
chmod +x /tmp/webhook-test/build.sh
Generate the HMAC signature for a test payload.
SECRET="my-webhook-secret"
PAYLOAD='{"ref":"refs/heads/main","after":"abc123","commits":[{"id":"abc123","message":"deploy fix","added":[],"modified":["main.go"],"removed":[]}],"repository":{"name":"myproject","full_name":"org/myproject"},"pusher":{"name":"dev1"}}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
echo "Signature: sha256=$SIGNATURE"
Go: the complete server
This is the full server. Read through it section by section.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"sync"
"time"
)
// --- Configuration ---
type Config struct {
Port int
Secret string
BuildScript string
Branches []string
PathFilters []string
}
func defaultConfig() Config {
return Config{
Port: 9000,
Secret: "my-webhook-secret",
BuildScript: "/tmp/webhook-test/build.sh",
Branches: []string{"main"},
PathFilters: []string{},
}
}
// --- Webhook payload types ---
type WebhookPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
Commits []Commit `json:"commits"`
Repository Repository `json:"repository"`
Pusher Pusher `json:"pusher"`
}
type Commit struct {
ID string `json:"id"`
Message string `json:"message"`
Added []string `json:"added"`
Modified []string `json:"modified"`
Removed []string `json:"removed"`
}
type Repository struct {
Name string `json:"name"`
FullName string `json:"full_name"`
}
type Pusher struct {
Name string `json:"name"`
}
func (p *WebhookPayload) Branch() string {
return strings.TrimPrefix(p.Ref, "refs/heads/")
}
func (p *WebhookPayload) ChangedFiles() []string {
seen := make(map[string]bool)
var files []string
for _, c := range p.Commits {
all := make([]string, 0, len(c.Added)+len(c.Modified)+len(c.Removed))
all = append(all, c.Added...)
all = append(all, c.Modified...)
all = append(all, c.Removed...)
for _, f := range all {
if !seen[f] {
seen[f] = true
files = append(files, f)
}
}
}
return files
}
// --- HMAC verification ---
func verifySignature(body []byte, signatureHeader string, secret string) bool {
if signatureHeader == "" {
return false
}
if !strings.HasPrefix(signatureHeader, "sha256=") {
return false
}
receivedSig := signatureHeader[7:]
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expectedSig := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(receivedSig), []byte(expectedSig))
}
// --- Build queue ---
type BuildStatus struct {
State string `json:"state"` // idle, building, success, failed
Revision string `json:"revision"`
Branch string `json:"branch"`
Pusher string `json:"pusher"`
StartedAt time.Time `json:"started_at,omitempty"`
Duration string `json:"duration,omitempty"`
Output string `json:"output,omitempty"`
}
type BuildQueue struct {
mu sync.Mutex
current BuildStatus
running bool
}
func NewBuildQueue() *BuildQueue {
return &BuildQueue{
current: BuildStatus{State: "idle"},
}
}
func (q *BuildQueue) Status() BuildStatus {
q.mu.Lock()
defer q.mu.Unlock()
return q.current
}
func (q *BuildQueue) Enqueue(payload *WebhookPayload, buildScript string) (bool, string) {
q.mu.Lock()
if q.running {
q.mu.Unlock()
return false, "build already in progress"
}
q.running = true
q.current = BuildStatus{
State: "building",
Revision: payload.After,
Branch: payload.Branch(),
Pusher: payload.Pusher.Name,
StartedAt: time.Now(),
}
q.mu.Unlock()
// Run build in background
go q.runBuild(payload, buildScript)
return true, "build started"
}
func (q *BuildQueue) runBuild(payload *WebhookPayload, buildScript string) {
start := time.Now()
rev := payload.After
cmd := exec.Command(buildScript, rev)
output, err := cmd.CombinedOutput()
q.mu.Lock()
defer q.mu.Unlock()
q.running = false
q.current.Duration = time.Since(start).Round(time.Millisecond).String()
q.current.Output = string(output)
if err != nil {
q.current.State = "failed"
logColor("\033[31m", "BUILD FAILED", rev, time.Since(start))
} else {
q.current.State = "success"
logColor("\033[32m", "BUILD SUCCESS", rev, time.Since(start))
}
}
// --- Filtering ---
func shouldBuild(payload *WebhookPayload, config Config) (bool, string) {
branch := payload.Branch()
branchMatch := false
for _, b := range config.Branches {
if b == branch {
branchMatch = true
break
}
}
if !branchMatch {
return false, fmt.Sprintf("branch %q not in build list", branch)
}
if len(config.PathFilters) > 0 {
files := payload.ChangedFiles()
for _, file := range files {
for _, prefix := range config.PathFilters {
if strings.HasPrefix(file, prefix) {
return true, fmt.Sprintf("file %q matches filter %q", file, prefix)
}
}
}
return false, "no changed files match path filters"
}
return true, "branch matches"
}
// --- Logging ---
func logColor(color string, label string, rev string, duration time.Duration) {
reset := "\033[0m"
ts := time.Now().Format("15:04:05")
fmt.Printf("[%s] %s%s%s %s (%s)\n", ts, color, label, reset, rev, duration)
}
func logEvent(format string, args ...interface{}) {
ts := time.Now().Format("15:04:05")
msg := fmt.Sprintf(format, args...)
fmt.Printf("[%s] %s\n", ts, msg)
}
// --- HTTP handlers ---
type Server struct {
config Config
queue *BuildQueue
}
func NewServer(config Config) *Server {
return &Server{
config: config,
queue: NewBuildQueue(),
}
}
func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Cannot read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Step 1: Verify signature
signature := r.Header.Get("X-Hub-Signature-256")
if !verifySignature(body, signature, s.config.Secret) {
logEvent("\033[31mREJECTED\033[0m: Invalid signature from %s", r.RemoteAddr)
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
logEvent("\033[33mWEBHOOK\033[0m received from %s (%d bytes)", r.RemoteAddr, len(body))
// Step 2: Parse payload
var payload WebhookPayload
err = json.Unmarshal(body, &payload)
if err != nil {
logEvent(" Parse error: %s", err)
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
logEvent(" Repository: %s", payload.Repository.FullName)
logEvent(" Branch: %s", payload.Branch())
logEvent(" Pusher: %s", payload.Pusher.Name)
logEvent(" Revision: %s", payload.After)
// Step 3: Check if we should build
build, reason := shouldBuild(&payload, s.config)
if !build {
logEvent(" \033[33mSKIPPED\033[0m: %s", reason)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "skipped",
"reason": reason,
})
return
}
// Step 4: Queue the build
started, msg := s.queue.Enqueue(&payload, s.config.BuildScript)
if !started {
logEvent(" \033[33mQUEUED\033[0m: %s", msg)
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{
"status": "rejected",
"reason": msg,
})
return
}
logEvent(" \033[32mBUILD STARTED\033[0m: %s", payload.After)
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]string{
"status": "building",
"revision": payload.After,
})
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
status := s.queue.Status()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"healthy"}`))
}
func main() {
config := defaultConfig()
server := NewServer(config)
http.HandleFunc("/webhook", server.handleWebhook)
http.HandleFunc("/status", server.handleStatus)
http.HandleFunc("/health", server.handleHealth)
fmt.Println("==========================================")
fmt.Println(" Webhook Server")
fmt.Println("==========================================")
fmt.Printf(" Port: %d\n", config.Port)
fmt.Printf(" Branches: %v\n", config.Branches)
fmt.Printf(" Build script: %s\n", config.BuildScript)
fmt.Printf(" HMAC: enabled\n")
fmt.Println("==========================================")
fmt.Println()
addr := fmt.Sprintf(":%d", config.Port)
logEvent("Listening on %s", addr)
err := http.ListenAndServe(addr, nil)
if err != nil {
fmt.Printf("Server error: %s\n", err)
}
}
Running the complete server
Start the server.
go run main.go
Output:
==========================================
Webhook Server
==========================================
Port: 9000
Branches: [main]
Build script: /tmp/webhook-test/build.sh
HMAC: enabled
==========================================
[14:45:00] Listening on :9000
Send a valid webhook.
SECRET="my-webhook-secret"
PAYLOAD='{"ref":"refs/heads/main","after":"abc12345","commits":[{"id":"abc12345","message":"fix login bug","added":[],"modified":["main.go","pkg/auth/login.go"],"removed":[]}],"repository":{"name":"myproject","full_name":"org/myproject"},"pusher":{"name":"karandeep"}}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -s -X POST \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: sha256=$SIGNATURE" \
-d "$PAYLOAD" \
http://localhost:9000/webhook | jq .
Response:
{
"status": "building",
"revision": "abc12345"
}
Server output:
[14:45:30] WEBHOOK received from 127.0.0.1:52340 (244 bytes)
[14:45:30] Repository: org/myproject
[14:45:30] Branch: main
[14:45:30] Pusher: karandeep
[14:45:30] Revision: abc12345
[14:45:30] BUILD STARTED: abc12345
[14:45:33] BUILD SUCCESS abc12345 (3.012s)
Check the build status.
curl -s http://localhost:9000/status | jq .
Response:
{
"state": "success",
"revision": "abc12345",
"branch": "main",
"pusher": "karandeep",
"started_at": "2025-01-01T14:45:30Z",
"duration": "3.012s",
"output": "Building revision abc12345...\nRunning tests...\nBuild complete.\n"
}
Send a webhook for a non-main branch.
PAYLOAD='{"ref":"refs/heads/feature/docs","after":"xyz999","commits":[{"id":"xyz999","message":"update readme","added":[],"modified":["README.md"],"removed":[]}],"repository":{"name":"myproject","full_name":"org/myproject"},"pusher":{"name":"karandeep"}}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -s -X POST \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: sha256=$SIGNATURE" \
-d "$PAYLOAD" \
http://localhost:9000/webhook | jq .
Response:
{
"status": "skipped",
"reason": "branch \"feature/docs\" not in build list"
}
Server output:
[14:46:00] WEBHOOK received from 127.0.0.1:52341 (198 bytes)
[14:46:00] Repository: org/myproject
[14:46:00] Branch: feature/docs
[14:46:00] Pusher: karandeep
[14:46:00] Revision: xyz999
[14:46:00] SKIPPED: branch "feature/docs" not in build list
Send a webhook with a bad signature.
curl -s -X POST \
-H 'Content-Type: application/json' \
-H 'X-Hub-Signature-256: sha256=badhash' \
-d '{"ref":"refs/heads/main"}' \
http://localhost:9000/webhook
Server output:
[14:46:10] REJECTED: Invalid signature from 127.0.0.1:52342
The server rejected the request with a 401 status code.
What the server does at each stage
Here is the flow for every incoming request:
- Receive: Accept the HTTP POST and read the body
- Verify: Compute HMAC-SHA256 and compare with the signature header
- Parse: Decode the JSON payload into Go structs
- Filter: Check if the branch and changed files match the build config
- Queue: Start the build if nothing else is running
- Execute: Run the build script with the revision as an argument
- Report: Update the status with the result
Each stage can reject the request with a clear reason. The /status endpoint always returns the latest build state.
Summary
You started with a shell script in .git/hooks/pre-commit and ended with a full HTTP server in Go. Here is what each step covered.
| Step | Hook / Component | What It Does |
|---|---|---|
| 1 | pre-commit | Blocks commits with debug statements or large files |
| 2 | pre-push | Blocks pushes to protected branches, runs tests |
| 3 | post-receive | Triggers background builds on a bare repository |
| 4 | HTTP receiver | Listens for webhook POST requests with HMAC verification |
| 5 | Payload parser | Extracts branch, files, and filters by configuration |
| 6 | Complete server | Combines all pieces with build queue and status endpoint |
The key patterns to remember:
- Client-side hooks (pre-commit, pre-push) prevent mistakes before they enter the repository. They run on the developer’s machine.
- Server-side hooks (post-receive) trigger actions after a push is accepted. They run on the server that hosts the repository.
- Webhook servers are HTTP services that respond to push events from hosted Git services. They need HMAC verification to prevent unauthorized requests.
- Always use
strings.TrimPrefixwhen comparing thereffield. The raw value isrefs/heads/main, notmain. - Always use
hmac.Equalfor signature comparison. Regular string comparison is vulnerable to timing attacks. - Background builds prevent blocking the Git push. Write the PID to a file and provide a status endpoint.
Every Go program in this article uses only the standard library. You can copy any of them into your project and compile with go build. No dependencies to manage.
External Resources
Keep Reading
- CI Pipeline Basics: From Shell Scripts to a Go Build Runner — build the CI runner that your webhook server triggers on push events.
- Deployment Automation: From SSH Scripts to a Go Deploy Tool — chain deployments after successful builds triggered by your webhook server.
- Remote Server Configuration: From SSH Loops to a Go Config Tool — push configuration to the servers your pipeline deploys to.