Skip to content

GitHub Branch Hygiene

Published: March 1, 2026

A practical guide to keeping your Git branches clean, organized, and under control. Stale branches slow teams down, cause confusion, and make repositories harder to navigate.

Why Branch Hygiene Matters

  • Reduces confusion — fewer branches means less "is this still active?" guessing
  • Speeds up CI/CD — some pipelines trigger on all branches, wasting resources
  • Prevents merge conflicts — long-lived branches diverge and become painful to merge
  • Improves onboarding — new team members can quickly understand what's in progress
  • Keeps git branch output readable — instead of scrolling through 200 branches

1. Branch Naming Conventions

Consistent naming makes branches self-documenting.

<type>/<ticket-id>-<short-description>

Branch Types

PrefixPurposeExample
feature/New functionalityfeature/PROJ-42-user-avatars
fix/Bug fixesfix/PROJ-99-login-redirect
hotfix/Urgent production fixeshotfix/PROJ-101-payment-crash
chore/Maintenance, dependencieschore/update-node-to-22
docs/Documentation changesdocs/api-authentication-guide
refactor/Code restructuringrefactor/extract-auth-service
test/Adding or fixing teststest/add-checkout-integration
experiment/Throwaway experimentsexperiment/try-htmx

Naming Rules

bash
# Good - lowercase, kebab-case, descriptive
feature/PROJ-42-user-profile-avatars
fix/login-timeout-on-slow-networks

# Bad - vague, inconsistent, no context
my-branch
fix
john/stuff
Feature/Do_The_Thing

Enforce naming with a Git hook

Add a pre-push hook to validate branch names against your convention:

bash
#!/bin/bash
branch=$(git branch --show-current)
pattern="^(feature|fix|hotfix|chore|docs|refactor|test|experiment)/"

if [[ ! "$branch" =~ $pattern ]] && [[ "$branch" != "main" ]] && [[ "$branch" != "develop" ]]; then
    echo "ERROR: Branch name '$branch' doesn't follow naming convention."
    echo "Use: <type>/<description> (e.g., feature/add-search)"
    exit 1
fi

2. Keep Branches Short-Lived

Long-lived feature branches are the number one source of merge pain.

The Golden Rules

RuleTarget
Branch lifetime< 1 week (ideally 1-3 days)
PR size< 400 lines changed
Commits per branch< 10

Strategies for Smaller Branches

Break features into vertical slices — deliver small, deployable pieces rather than one massive branch:

# Instead of one giant branch:
feature/user-management     # 3000 lines, 3 weeks old

# Use incremental branches:
feature/user-model           # Day 1 - schema + model
feature/user-api-endpoints   # Day 2 - REST endpoints
feature/user-ui-list         # Day 3 - list view
feature/user-ui-edit         # Day 4 - edit form

Use feature flags for incomplete work that needs to ship:

python
if feature_flags.is_enabled("new_checkout_flow"):
    return new_checkout(cart)
else:
    return legacy_checkout(cart)

The longer a branch lives, the harder the merge

A branch that is 1 day old rarely has conflicts. A branch that is 3 weeks old almost always does.

3. Stay Synced with the Base Branch

Regularly pull changes from your base branch to avoid surprises at merge time.

Rebase vs Merge

bash
# Update your feature branch with latest main
git checkout feature/my-work
git fetch origin
git rebase origin/main
bash
# Update your feature branch with latest main
git checkout feature/my-work
git fetch origin
git merge origin/main

Rebase produces a clean, linear history — your commits appear on top of the latest main. Merge creates a merge commit, showing the exact timeline but it can get messy.

Sync at least daily

Make it a habit to rebase onto main every morning. The longer you wait, the more conflicts accumulate.

Automate the Sync

bash
[alias]
    # Rebase current branch onto latest main
    freshen = "!git fetch origin && git rebase origin/main"

    # Interactive rebase to clean up commits before PR
    tidy = "rebase -i origin/main"

4. Clean Up After Merging

Merged branches serve no purpose. Delete them immediately.

Configure Auto-Delete on GitHub

  1. Go to your repository Settings
  2. Under General > Pull Requests, check Automatically delete head branches

This is the single most impactful setting for branch hygiene.

Clean Up Local Branches

bash
# Prune remote-tracking branches that no longer exist on the remote
git fetch --prune

# Delete local branches that have been merged into main
git branch --merged main | grep -v "^\*\|main\|develop" | xargs -n 1 git branch -d

# See which local branches are tracking deleted remotes
git branch -vv | grep ': gone]'

# Delete those stale local branches
git branch -vv | grep ': gone]' | awk '{print $1}' | xargs -n 1 git branch -D

All-in-One Cleanup Alias

bash
[alias]
    # Full branch cleanup
    prune-branches = "!git fetch --prune && git branch --merged main | grep -v '^\\*\\|main\\|develop' | xargs -n 1 git branch -d 2>/dev/null; echo 'Cleanup complete'"

    # Show stale branches (merged but not deleted)
    stale = "branch --merged main --no-contains main"

    # Show branches with no remote
    orphans = "!git branch -vv | grep ': gone]' | awk '{print $1}'"
Schedule weekly cleanup

Add a reminder or cron job to run branch cleanup weekly:

bash
# Add to crontab (runs every Monday at 9am)
0 9 * * 1 cd ~/projects/my-repo && git prune-branches

5. Protect Important Branches

Guard main and other critical branches from accidental pushes.

GitHub Branch Protection Rules

Go to Settings > Branches > Add branch protection rule for main:

SettingRecommendation
Require pull request reviews1 reviewer minimum
Require status checks to passEnable for CI
Require branches to be up to dateEnable
Require linear historyEnable (prevents merge commits)
Restrict who can pushLimit to admins
Do not allow bypassingEnable for consistency

Local Protection

Prevent accidental commits to main with a pre-commit hook:

bash
#!/bin/bash
branch=$(git branch --show-current)

if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
    echo "ERROR: Direct commits to '$branch' are not allowed."
    echo "Create a feature branch: git checkout -b feature/your-change"
    exit 1
fi

Make hooks executable

bash
chmod +x .git/hooks/pre-commit
chmod +x .git/hooks/pre-push

6. Audit Stale Branches

Periodically review branches that have gone quiet.

Find Stale Branches

bash
# Remote branches sorted by last commit date (oldest first)
git for-each-ref --sort=committerdate refs/remotes/origin \
    --format='%(committerdate:relative) %(refname:short) %(authorname)' \
    | head -20

# Local branches older than 30 days with no activity
git for-each-ref --sort=committerdate refs/heads \
    --format='%(committerdate:short) %(refname:short)' \
    | while read date branch; do
        if [[ $(date -d "$date" +%s 2>/dev/null || date -j -f "%Y-%m-%d" "$date" +%s 2>/dev/null) -lt $(date -d "30 days ago" +%s 2>/dev/null || date -v-30d +%s 2>/dev/null) ]]; then
            echo "STALE: $date $branch"
        fi
    done

Using GitHub CLI

bash
# List branches with their last commit date
gh api repos/{owner}/{repo}/branches --paginate \
    --jq '.[] | "\(.commit.commit.author.date | split("T")[0]) \(.name)"' \
    | sort

# Find PRs that are still open but stale
gh pr list --state open --json number,title,updatedAt \
    --jq '.[] | select(.updatedAt < "2026-01-01") | "#\(.number) \(.title)"'

Stale Branch Policy

Define clear rules for your team:

Branch AgeAction
< 1 weekActive development, leave it
1-2 weeksCheck in with the author
2-4 weeksAuthor must justify keeping it
> 1 monthDelete (can always restore from reflog or recreate)

Deleted branches are recoverable

Git doesn't truly delete anything immediately. If you need a deleted branch back:

bash
# Find the commit hash in reflog
git reflog

# Recreate the branch at that commit
git branch recovered-branch <commit-hash>

7. Commit Hygiene on Branches

Clean commits make branches easier to review and merge.

Write Meaningful Commits

bash
# Good - explains what and why
git commit -m "fix: prevent duplicate form submissions by disabling button on click"

# Bad - says nothing useful
git commit -m "fix bug"
git commit -m "wip"
git commit -m "changes"

Clean Up Before Opening a PR

Use interactive rebase to squash WIP commits and write clear messages:

bash
# Rebase against the base branch
git rebase -i origin/main

In the editor, mark commits to squash or reword:

pick   a1b2c3d feat: add user search endpoint
squash d4e5f6a wip: search query builder
squash 7g8h9i0 fix typo
pick   j1k2l3m feat: add search results pagination
reword m4n5o6p tests

Only rewrite history on branches you own

Never rebase or squash commits that others have already pulled. This rewrites shared history and causes headaches.

8. Branch Hygiene Checklist

Use this as a periodic review checklist for your repositories:

  • [ ] Auto-delete is enabled for merged branches on GitHub
  • [ ] Branch protection rules are configured for main
  • [ ] Naming convention is documented and followed
  • [ ] No branches older than 2 weeks without justification
  • [ ] Local branches are pruned (git fetch --prune)
  • [ ] Feature branches are rebased onto main regularly
  • [ ] PR size stays under 400 lines on average
  • [ ] WIP commits are squashed before merging

Quick Health Check Command

bash
#!/bin/bash
# Run this in any repo to check branch hygiene

echo "=== Branch Hygiene Report ==="
echo ""

total=$(git branch -r | grep -v HEAD | wc -l | tr -d ' ')
merged=$(git branch -r --merged main | grep -v HEAD | grep -v main | wc -l | tr -d ' ')
echo "Remote branches: $total"
echo "Merged (deletable): $merged"
echo "Active: $((total - merged))"
echo ""

echo "Oldest 5 remote branches:"
git for-each-ref --sort=committerdate refs/remotes/origin \
    --format='  %(committerdate:relative) - %(refname:short)' \
    | head -5
echo ""

local_gone=$(git branch -vv | grep ': gone]' | wc -l | tr -d ' ')
if [ "$local_gone" -gt 0 ]; then
    echo "WARNING: $local_gone local branches track deleted remotes"
    git branch -vv | grep ': gone]' | awk '{print "  " $1}'
fi

Summary

PracticeImpactEffort
Enable auto-delete on mergeHighOne-time setup
Use naming conventionsMediumOngoing discipline
Keep branches under 1 weekHighRequires smaller PRs
Rebase onto main dailyMedium30 seconds/day
Run git fetch --prune weeklyMedium5 seconds/week
Set up branch protectionHighOne-time setup
Squash WIP commits before PRMedium2 minutes/PR

The goal: your branch list should only contain work that is actively in progress. Everything else is noise.

  • Git Setup — The global Git configuration that supports clean branch workflows
  • Git Quick Reference — Scannable cheatsheet for daily Git commands