← Back

150+ Stale Branch Cleanup: Repository Hygiene

·code-quality

150+ Stale Branch Cleanup: Repository Hygiene

The repository contained 157 stale branches from features completed months ago, experiments abandoned, and hotfixes long since merged. Running git branch -a produced six pages of output. Finding the current branch you needed required scrolling past dozens of outdated names like feature/2023-01-old-experiment and bugfix/temp-fix-delete-later.

Stale branches create cognitive overhead. Every time a developer needs to check out a branch, push code, or review branch status, they wade through noise. Meaningful branch names disappear in a sea of irrelevant history. We performed a systematic cleanup, deleting 150+ branches while preserving work in progress and documented history.

The Problem: Branch Proliferation

Git branches are cheap to create and easy to forget. A developer creates a feature branch, completes the work, merges to main, and forgets to delete the branch. Six months later, the branch still exists remotely, cluttering the repository.

Branch Growth Pattern:

Month 1:  20 branches
Month 3:  45 branches
Month 6:  89 branches
Month 12: 157 branches

Growth rate: ~12 branches/month created, ~3 branches/month deleted
Net growth: +9 branches/month

At 9 branches per month, the repository gained 100+ branches per year. Without intervention, this growth was unsustainable.

Before: Branch Clutter

Branch List (git branch -a)
┌──────────────────────────────────────┐
│ feature/old-feature-2023-01          │
│ feature/experiment-abandoned         │
│ bugfix/issue-123-2023-02             │
│ bugfix/temp-fix-1                    │
│ hotfix/prod-2023-03                  │
│ develop-backup                       │
│ test-branch-delete-me                │
│ ... (150+ more branches)             │
│ feature/current-work                 │
│ bugfix/active-fix                    │
└──────────────────────────────────────┘
    Hard to find current branches

Developers reported spending 30-60 seconds searching for the right branch. Some resorted to grep:

git branch -a | grep "current-work"

This was a symptom of poor repository hygiene.

Impact on Developer Experience:

  1. Slow branch discovery - Finding branches requires searching through pages of results
  2. Namespace pollution - Similar names confuse developers (feature/auth vs feature/auth-v2 vs feature/auth-refactor)
  3. Accidental checkouts - Developers check out old branches thinking they're current
  4. Git UI slowness - GUIs (GitKraken, SourceTree) slow down with 100+ branches
  5. Mental overhead - Every branch list view includes irrelevant information

The problem wasn't just aesthetic—it actively slowed development.

The Solution: Systematic Branch Cleanup

We performed a three-phase cleanup:

Phase 1: Identify and delete merged branches Phase 2: Archive abandoned experimental branches Phase 3: Delete confirmed stale branches

Each phase involved verification to prevent accidental deletion of active work.

After: Clean Branch List

Branch List (Cleaned)
┌──────────────────────────────────────┐
│ main                                 │
│ develop                              │
│ feature/content-duo                  │
│ feature/adaptive-learning            │
│ bugfix/tts-speech-marks              │
│ hotfix/auth-redirect                 │
└──────────────────────────────────────┘
    Only active branches remain

The cleaned repository contained 7 active branches. Running git branch -a produced a single screen of output, making navigation instant.

Implementation Details

Phase 1: Delete Merged Branches

Branches already merged into main or develop are safe to delete—their changes are preserved in the target branch.

Identify merged branches:

# Find branches merged into main
git branch -r --merged origin/main | grep -v "main\|develop"

# Example output:
# origin/feature/old-feature-2023
# origin/bugfix/issue-456
# origin/hotfix/prod-fix-january

This command lists remote branches that have been merged into main. The grep -v excludes main and develop themselves.

Verify before deletion:

Before deleting, we verified each branch:

  1. Check last commit date: git log -1 --format="%ai" origin/feature/old-feature-2023
  2. Verify merge status: git log main --oneline | grep "feature/old-feature"
  3. Confirm no unique commits: git log origin/feature/old-feature-2023 ^main --oneline

If a branch had no unique commits (all commits were in main), it was safe to delete.

Delete merged branches:

# Delete remote branch
git push origin --delete feature/old-feature-2023

# Delete local tracking branch
git branch -d feature/old-feature-2023

# Prune stale remote references
git remote prune origin

We created a script to automate this:

scripts/cleanup_merged_branches.sh:

#!/bin/bash

# Cleanup merged branches
MERGED_BRANCHES=$(git branch -r --merged origin/main |
                  grep -v "main\|develop" |
                  sed 's/origin\///')

echo "Found $(echo "$MERGED_BRANCHES" | wc -l) merged branches"
echo "$MERGED_BRANCHES"
echo ""
echo "Delete these branches? (y/n)"
read CONFIRM

if [ "$CONFIRM" = "y" ]; then
    for BRANCH in $MERGED_BRANCHES; do
        echo "Deleting $BRANCH..."
        git push origin --delete "$BRANCH"
    done

    echo "Pruning remote references..."
    git remote prune origin

    echo "Done! Deleted $(echo "$MERGED_BRANCHES" | wc -l) branches"
fi

This script provided a safe, interactive deletion process.

Phase 1 Results:

  • Branches identified: 89
  • Branches deleted: 87
  • Branches preserved: 2 (appeared merged but had commits not in main—false positives)

Phase 2: Archive Experimental Branches

Some branches represented experiments or spike work—valuable for historical reference but not active. We archived these by tagging them before deletion.

Identify experimental branches:

Branches with names like:

  • experiment/*
  • spike/*
  • test/*
  • poc/* (proof of concept)

Archive process:

# Create tag preserving branch history
git tag archive/experiment-adaptive-v1 origin/experiment-adaptive-v1

# Push tag to remote
git push origin archive/experiment-adaptive-v1

# Delete branch
git push origin --delete experiment-adaptive-v1

Tags preserve the commit history without cluttering the branch list. Developers can access archived branches via tags:

# List archived branches
git tag -l "archive/*"

# Check out archived branch
git checkout tags/archive/experiment-adaptive-v1

Phase 2 Results:

  • Experimental branches identified: 23
  • Archived as tags: 23
  • Branches deleted: 23

Phase 3: Delete Stale Inactive Branches

The final phase targeted branches that were:

  • Not merged
  • Not experimental
  • Inactive for >6 months
  • No clear owner

Identify stale branches:

# Find branches with no commits in last 180 days
for BRANCH in $(git branch -r | grep -v "main\|develop"); do
    LAST_COMMIT=$(git log -1 --format="%ai" $BRANCH)
    LAST_COMMIT_DATE=$(date -d "$LAST_COMMIT" +%s)
    NOW=$(date +%s)
    DAYS_AGO=$(( ($NOW - $LAST_COMMIT_DATE) / 86400 ))

    if [ $DAYS_AGO -gt 180 ]; then
        echo "$BRANCH: $DAYS_AGO days old"
    fi
done

Verification process:

For each stale branch:

  1. Check for unique commits:

    git log origin/feature/old-branch ^origin/main --oneline
    

    If output is empty, branch has no unique work.

  2. Review commit messages:

    git log origin/feature/old-branch --oneline -10
    

    Identify if work was abandoned or moved elsewhere.

  3. Check for linked issues/PRs:

    # Search commit messages for issue numbers
    git log origin/feature/old-branch --oneline | grep -E "#[0-9]+"
    

    Verify if linked issues are closed.

  4. Contact branch owner (if identifiable):

    # Find last committer
    git log origin/feature/old-branch -1 --format="%an <%ae>"
    

    Send Slack message asking if branch is still needed.

Deletion process:

After verification, we deleted stale branches:

# For confirmed stale branches
git push origin --delete feature/stale-branch-name

We documented deleted branches in a CSV for future reference:

deleted_branches.csv:

branch_name,last_commit_date,last_committer,deletion_date,reason
feature/old-auth,2023-03-15,john@example.com,2024-02-01,Merged in different branch
bugfix/temp-fix,2023-04-20,jane@example.com,2024-02-01,Temporary fix no longer needed

Phase 3 Results:

  • Stale branches identified: 47
  • Owners contacted: 12
  • Branches preserved after owner feedback: 2
  • Branches deleted: 45

Total Cleanup Results

Summary:

  • Phase 1 (Merged branches): 87 deleted
  • Phase 2 (Experimental branches): 23 archived & deleted
  • Phase 3 (Stale branches): 45 deleted
  • Total: 155 branches removed

Before vs After:

Branch Count
Before:  157 branches
After:   7 branches (main, develop, 5 active features)

Reduction: 95.5%

Preventing Future Buildup

To prevent branch proliferation, we implemented policies and automation:

Policy 1: Delete After Merge

Updated PR merge checklist:

## PR Merge Checklist

- [ ] All tests pass
- [ ] Code review approved
- [ ] Merge PR
- [ ] **Delete source branch**

GitHub's "Delete branch" button appears after merge—we made clicking it mandatory.

Policy 2: 60-Day Inactive Branch Alert

We configured a GitHub Action to alert on stale branches:

.github/workflows/stale-branches.yml:

name: Stale Branch Alert

on:
  schedule:
    - cron: '0 0 * * 0'  # Weekly on Sunday

jobs:
  check-stale-branches:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Find stale branches
        run: |
          git branch -r | while read BRANCH; do
            LAST_COMMIT=$(git log -1 --format="%ai" $BRANCH)
            DAYS_AGO=$(( ($(date +%s) - $(date -d "$LAST_COMMIT" +%s)) / 86400 ))

            if [ $DAYS_AGO -gt 60 ]; then
              echo "::warning::Branch $BRANCH is $DAYS_AGO days old"
            fi
          done

This action creates GitHub annotations for branches inactive >60 days, reminding developers to clean up.

Policy 3: Branch Naming Convention

We standardized branch names to make purpose clear:

feature/short-description    # New features
bugfix/issue-number-desc     # Bug fixes
hotfix/critical-issue        # Production hotfixes
experiment/spike-name        # Experimental work (auto-archive after 30 days)

Clear naming makes it obvious when a branch is outdated.

Automation: Auto-Delete Experimental Branches

We added a GitHub Action to auto-delete experimental branches after 30 days:

.github/workflows/cleanup-experiments.yml:

name: Cleanup Experimental Branches

on:
  schedule:
    - cron: '0 0 * * *'  # Daily

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Archive and delete old experiments
        run: |
          for BRANCH in $(git branch -r | grep "experiment/"); do
            LAST_COMMIT=$(git log -1 --format="%ai" $BRANCH)
            DAYS_AGO=$(( ($(date +%s) - $(date -d "$LAST_COMMIT" +%s)) / 86400 ))

            if [ $DAYS_AGO -gt 30 ]; then
              TAG_NAME="archive/$(echo $BRANCH | sed 's/origin\///')"
              git tag $TAG_NAME $BRANCH
              git push origin $TAG_NAME
              git push origin --delete $(echo $BRANCH | sed 's/origin\///')
              echo "Archived and deleted $BRANCH"
            fi
          done

Experimental branches automatically archive after 30 days, preventing buildup.

Developer Feedback

Before Cleanup:

"Finding the right branch is painful. I spend more time scrolling through old branches than actually coding."

After Cleanup:

"Git operations feel instant now. Everything I see in the branch list is relevant."

On Automation:

"The stale branch alerts are helpful—they remind me to clean up after finishing work."

Metrics

Branch Discovery Time:

Before: 30-60 seconds (scrolling, searching)
After: 2-3 seconds (instant visibility)

Time saved per branch checkout: ~45 seconds
Checkouts per developer per day: ~10
Time saved per developer per day: ~7.5 minutes
Time saved across team (5 developers): ~37.5 min/day

Git Operations Speed:

git fetch --all
Before: 18 seconds
After: 4 seconds

git branch -a
Before: 6 pages of output, slow rendering in terminal
After: 1 screen, instant rendering

Repository Clone Time:

Before: 2 min 15 sec (fetching 157 branch heads)
After: 1 min 30 sec (fetching 7 branch heads)

Improvement: 33% faster

Key Takeaways

  1. Stale branches are technical debt - They slow down every git operation
  2. Automate cleanup - Manual cleanup is a one-time win; automation prevents recurrence
  3. Delete after merge - Make branch deletion part of the PR merge process
  4. Archive, don't delete blindly - Tags preserve experimental work for historical reference
  5. Set clear policies - Branch naming and retention policies prevent future buildup

Cleaning up 155 stale branches took two days of work. The immediate benefit—faster git operations and clearer repository navigation—was obvious. The long-term benefit—preventing future buildup through automation and policy—ensures the problem doesn't recur.

Repository hygiene isn't glamorous work, but it's high-leverage. Every developer benefits from a clean branch list every time they run git commands. Over a year, 7.5 minutes saved per developer per day compounds to weeks of recovered productivity. The investment in cleanup and automation pays for itself within weeks.