Appearance
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 branchoutput readable — instead of scrolling through 200 branches
1. Branch Naming Conventions
Consistent naming makes branches self-documenting.
Recommended Format
<type>/<ticket-id>-<short-description>Branch Types
| Prefix | Purpose | Example |
|---|---|---|
feature/ | New functionality | feature/PROJ-42-user-avatars |
fix/ | Bug fixes | fix/PROJ-99-login-redirect |
hotfix/ | Urgent production fixes | hotfix/PROJ-101-payment-crash |
chore/ | Maintenance, dependencies | chore/update-node-to-22 |
docs/ | Documentation changes | docs/api-authentication-guide |
refactor/ | Code restructuring | refactor/extract-auth-service |
test/ | Adding or fixing tests | test/add-checkout-integration |
experiment/ | Throwaway experiments | experiment/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_ThingEnforce 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
fi2. Keep Branches Short-Lived
Long-lived feature branches are the number one source of merge pain.
The Golden Rules
| Rule | Target |
|---|---|
| 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 formUse 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/mainbash
# Update your feature branch with latest main
git checkout feature/my-work
git fetch origin
git merge origin/mainRebase 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
- Go to your repository Settings
- 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 -DAll-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-branches5. 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:
| Setting | Recommendation |
|---|---|
| Require pull request reviews | 1 reviewer minimum |
| Require status checks to pass | Enable for CI |
| Require branches to be up to date | Enable |
| Require linear history | Enable (prevents merge commits) |
| Restrict who can push | Limit to admins |
| Do not allow bypassing | Enable 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
fiMake hooks executable
bash
chmod +x .git/hooks/pre-commit
chmod +x .git/hooks/pre-push6. 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
doneUsing 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 Age | Action |
|---|---|
| < 1 week | Active development, leave it |
| 1-2 weeks | Check in with the author |
| 2-4 weeks | Author must justify keeping it |
| > 1 month | Delete (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/mainIn 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 testsOnly 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
mainregularly - [ ] 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}'
fiSummary
| Practice | Impact | Effort |
|---|---|---|
| Enable auto-delete on merge | High | One-time setup |
| Use naming conventions | Medium | Ongoing discipline |
| Keep branches under 1 week | High | Requires smaller PRs |
| Rebase onto main daily | Medium | 30 seconds/day |
Run git fetch --prune weekly | Medium | 5 seconds/week |
| Set up branch protection | High | One-time setup |
| Squash WIP commits before PR | Medium | 2 minutes/PR |
The goal: your branch list should only contain work that is actively in progress. Everything else is noise.
Related
- Git Setup — The global Git configuration that supports clean branch workflows
- Git Quick Reference — Scannable cheatsheet for daily Git commands