Skip to main content
All Guides
Code Quality

Git Branch Aging Report: Finding and Cleaning Stale Branches

Track branch aging to identify technical debt. Learn branch lifecycle stages, hygiene policies that scale, and safe cleanup automation strategies.

10 min readUpdated February 1, 2026By CodePulse Team
Git Branch Aging Report: Finding and Cleaning Stale Branches - visual overview

Every team has them: branches created months ago, sitting untouched, accumulating merge conflicts like interest on a forgotten credit card. A stale branch isn't just clutterβ€”it's a merge conflict waiting to happen, a piece of abandoned work obscuring your actual progress, and often a symptom of deeper workflow problems. This guide helps you identify, categorize, and clean up branches before they become expensive technical debt.

"A repository with 500 branches isn't well-organizedβ€”it's a graveyard of good intentions."

Why Stale Branches Are Technical Debt

Branches age silently. Unlike code quality issues that surface during reviews or bugs that trigger alerts, stale branches accumulate without any warning system. By the time you notice them, the damage is done.

The Hidden Costs of Branch Sprawl

  • Merge conflict risk: Every day a branch sits unmerged, it drifts further from main. A branch that was trivial to merge last week might require hours of conflict resolution next month.
  • Context loss: After 30 days, even the author forgets the nuances. After 90 days, the original requirements may have changed entirely.
  • CI/CD resource drain: Stale branches still trigger builds when rebased, consuming compute resources for code that may never ship.
  • False progress signals: A repository with 200 active branches looks busy, but if 150 are stale, you have 150 pieces of abandoned work masquerading as progress.
  • Cognitive overhead: Developers scanning branch lists waste time filtering through dead branches to find active work.

Quantifying the Problem

The merge conflict cost compounds exponentially. A branch that's 7 days old might have 2-3 trivial conflicts. At 30 days, expect 5-10 moderate conflicts. At 90 days, you're often looking at a partial rewrite because the underlying code has changed so significantly.

Branch AgeTypical Conflict CountResolution TimeRisk Level
<7 days0-2 trivial5-15 minutesLow
7-30 days3-8 moderate30-60 minutesMedium
30-90 days10-25 significant2-4 hoursHigh
>90 days25+ severeHalf-day to full rewriteCritical

πŸ”₯ Our Take

A 90-day-old branch isn't a branch anymoreβ€”it's a historical artifact. If the work was important, it would have been merged. If it wasn't important enough to merge, it shouldn't be important enough to keep.

The sunk cost fallacy keeps teams clinging to stale branches. "We put 3 weeks into this!" But those 3 weeks are gone regardless. The only question is whether you'll spend another week dealing with merge conflicts for code that may never ship.

Detect code hotspots and knowledge silos with CodePulse

The Branch Lifecycle: From Fresh to Fossilized

Branch Lifecycle Timeline showing stages from Fresh (0-7 days) through Aging, Stale, and Fossilized (180+ days)
Branch lifecycle stages: From active development to technical debt

Understanding where each branch sits in its lifecycle helps you make informed decisions about what to keep, what to merge urgently, and what to delete.

Branch Age Thresholds

BRANCH LIFECYCLE STAGES
═══════════════════════════════════════════════════════════════════

FRESH (0-7 days)
β”œβ”€β”€ Status: Active development expected
β”œβ”€β”€ Action: None needed - normal workflow
β”œβ”€β”€ Merge difficulty: Trivial
└── Context: Author fully remembers intent

AGING (7-30 days)
β”œβ”€β”€ Status: Should be near completion or needs attention
β”œβ”€β”€ Action: Check in with author, prioritize merge/close
β”œβ”€β”€ Merge difficulty: Minor conflicts likely
└── Context: Author remembers most details

STALE (30-90 days)
β”œβ”€β”€ Status: Likely abandoned or blocked
β”œβ”€β”€ Action: Decide: fast-track merge, close, or document why kept
β”œβ”€β”€ Merge difficulty: Significant rebase work required
└── Context: Author may need to re-learn the code

FOSSILIZED (90+ days)
β”œβ”€β”€ Status: Technical debt - actively harmful
β”œβ”€β”€ Action: Archive or delete - do not attempt merge without review
β”œβ”€β”€ Merge difficulty: Partial or full rewrite often easier
└── Context: Original requirements may no longer apply

═══════════════════════════════════════════════════════════════════

Why Branches Stagnate

Before deleting branches, understand why they stalled. The pattern reveals workflow problems:

PatternCommon CauseFix
No PR createdWork started but never completedSmaller scope, clearer requirements
PR open but unreviewedReview bottleneckReview load balancing, PR SLAs
PR with requested changesAuthor moved to other prioritiesExplicit ownership handoff process
Blocked by dependenciesWaiting on other teams/PRsBetter dependency tracking
Experimental/spikeExploratory work, not meant to mergeNaming conventions, dedicated spike repos

"Every stale branch has a story. Most of those stories are about unclear requirements, review bottlenecks, or shifting prioritiesβ€”not lazy developers."

Identifying Stale vs Intentionally Long-Lived Branches

Not every old branch is a problem. Some branches are intentionally long-lived. The key is distinguishing legitimate long-lived branches from accidentally abandoned ones.

Legitimate Long-Lived Branches

  • Release branches: release/v2.4, release-2024-q1β€” maintained for hotfixes to shipped versions
  • Environment branches: staging, productionβ€” deployment targets with continuous updates
  • Feature flags: feature/new-checkout-behind-flagβ€” large features deployed incrementally via flags
  • Compliance branches: audit/soc2-2024β€” preserved for regulatory requirements

Naming Conventions That Signal Intent

A good naming convention makes branch status obvious at a glance:

RECOMMENDED BRANCH PREFIXES
═══════════════════════════════════════════════════════════════════

SHORT-LIVED (expect merge within days):
  feature/    - New functionality
  fix/        - Bug fixes
  hotfix/     - Urgent production fixes
  chore/      - Maintenance, deps, cleanup

LONG-LIVED (intentionally preserved):
  release/    - Version release branches
  env/        - Environment-specific branches
  experiment/ - Exploratory work (may never merge)

QUESTIONABLE (investigate if >30 days old):
  wip/        - Work in progress (should resolve quickly)
  spike/      - Research spikes (should convert to docs or merge)
  temp/       - Temporary branches (should be deleted soon)

═══════════════════════════════════════════════════════════════════

Branch Age Report Script

Generate a quick aging report for your repository:

#!/bin/bash
# Branch Aging Report - Run from repo root

echo "=== BRANCH AGING REPORT ==="
echo "Repository: $(basename $(pwd))"
echo "Date: $(date +%Y-%m-%d)"
echo ""

# Get current date in seconds since epoch
now=$(date +%s)

# Define thresholds in seconds
fresh_threshold=$((7 * 24 * 60 * 60))      # 7 days
aging_threshold=$((30 * 24 * 60 * 60))     # 30 days
stale_threshold=$((90 * 24 * 60 * 60))     # 90 days

fresh=0; aging=0; stale=0; fossilized=0

for branch in $(git for-each-ref --format='%(refname:short)' refs/heads/); do
  # Skip main/master/develop
  [[ "$branch" =~ ^(main|master|develop)$ ]] && continue

  last_commit=$(git log -1 --format=%ct "$branch" 2>/dev/null)
  [ -z "$last_commit" ] && continue

  age=$((now - last_commit))
  days=$((age / 86400))

  if [ $age -lt $fresh_threshold ]; then
    ((fresh++))
  elif [ $age -lt $aging_threshold ]; then
    ((aging++))
    echo "[AGING: ${days}d] $branch"
  elif [ $age -lt $stale_threshold ]; then
    ((stale++))
    echo "[STALE: ${days}d] $branch"
  else
    ((fossilized++))
    echo "[FOSSILIZED: ${days}d] $branch"
  fi
done

echo ""
echo "=== SUMMARY ==="
echo "Fresh (<7d):      $fresh"
echo "Aging (7-30d):    $aging"
echo "Stale (30-90d):   $stale"
echo "Fossilized (90d+): $fossilized"
echo "Total non-main:   $((fresh + aging + stale + fossilized))"
Identify bottlenecks slowing your team with CodePulse

Branch Hygiene Policies That Scale

Individual cleanup is reactive. Policies make cleanup automatic and consistent across the organization.

Policy Template: Startup/Small Team (5-20 engineers)

PolicySettingRationale
Auto-delete merged branchesEnabledNo reason to keep after merge
Stale branch warning14 daysFast-moving teams should merge frequently
Auto-archive threshold30 daysAggressive cleanup prevents accumulation
Protected branchesmain, stagingMinimal long-lived branches
Naming enforcementOptionalTeam is small enough for ad-hoc coordination

Policy Template: Growth Stage (20-100 engineers)

PolicySettingRationale
Auto-delete merged branchesEnabledEssential at scale
Stale branch warning21 daysSlightly longer for cross-team dependencies
Auto-archive threshold60 daysBalance between cleanup and flexibility
Protected branchesmain, develop, release/*Release branches preserved for hotfixes
Naming enforcementRequired prefixesConsistency across multiple teams

Policy Template: Enterprise (100+ engineers)

PolicySettingRationale
Auto-delete merged branchesEnabled with audit logCompliance requires tracking
Stale branch warning30 daysLonger cycles, more dependencies
Auto-archive threshold90 days (with approval override)Large features may legitimately take longer
Protected branchesmain, develop, release/*, env/*Multiple environments and release trains
Naming enforcementCI/CD gatedAutomated enforcement prevents drift
Ownership assignmentRequired CODEOWNERSClear accountability for cleanup

πŸ“ŠHow to See This in CodePulse

While CodePulse doesn't directly track branch age, PR data reveals branch patterns:

  • Repositories β†’ Long-open PRs indicate branches that aren't merging
  • File Hotspots β†’ High churn in specific files often correlates with branch conflicts
  • Use Cycle Time metrics to spot work that's stuck in "coding" phase too long
  • PRs with extended "Waiting for Review" time often indicate blocked branches

Automating Branch Cleanup Safely

Manual cleanup doesn't scale. Automation doesβ€”but it needs guardrails to prevent accidentally deleting important work.

GitHub Native Options

GitHub provides built-in branch management that requires zero additional tooling:

  • Auto-delete head branches: Repository Settings β†’ General β†’ "Automatically delete head branches" after PR merge
  • Branch protection rules: Prevent deletion of main/develop/release branches
  • Stale branch detection: GitHub shows "stale" label on branches with no recent activity in the branch listing

Safe Automation Script

This script identifies candidates for deletion but requires confirmation. Never blindly delete branches.

#!/bin/bash
# Safe Branch Cleanup Script
# Identifies stale branches, requires confirmation before deletion

THRESHOLD_DAYS=90
DRY_RUN=true  # Set to false to enable actual deletion

echo "=== SAFE BRANCH CLEANUP ==="
echo "Threshold: $THRESHOLD_DAYS days"
echo "Mode: $([ "$DRY_RUN" = true ] && echo "DRY RUN" || echo "LIVE")"
echo ""

# Protected patterns - customize for your workflow
PROTECTED_PATTERNS="^(main|master|develop|staging|production|release/|env/)"

now=$(date +%s)
threshold_seconds=$((THRESHOLD_DAYS * 24 * 60 * 60))
candidates=()

for branch in $(git for-each-ref --format='%(refname:short)' refs/heads/); do
  # Skip protected branches
  if [[ "$branch" =~ $PROTECTED_PATTERNS ]]; then
    continue
  fi

  last_commit=$(git log -1 --format=%ct "$branch" 2>/dev/null)
  [ -z "$last_commit" ] && continue

  age=$((now - last_commit))

  if [ $age -gt $threshold_seconds ]; then
    days=$((age / 86400))
    author=$(git log -1 --format='%an' "$branch")
    candidates+=("$branch|$days|$author")
  fi
done

if [ ${#candidates[@]} -eq 0 ]; then
  echo "No branches older than $THRESHOLD_DAYS days found."
  exit 0
fi

echo "Found ${#candidates[@]} candidates for cleanup:"
echo ""
printf "%-40s %-10s %s\n" "BRANCH" "AGE" "AUTHOR"
printf "%-40s %-10s %s\n" "------" "---" "------"

for candidate in "${candidates[@]}"; do
  IFS='|' read -r branch days author <<< "$candidate"
  printf "%-40s %-10s %s\n" "$branch" "${days}d" "$author"
done

echo ""
if [ "$DRY_RUN" = true ]; then
  echo "DRY RUN complete. Set DRY_RUN=false to enable deletion."
else
  read -p "Delete these branches? (yes/no): " confirm
  if [ "$confirm" = "yes" ]; then
    for candidate in "${candidates[@]}"; do
      IFS='|' read -r branch _ _ <<< "$candidate"
      git branch -D "$branch"
      echo "Deleted: $branch"
    done
  else
    echo "Cleanup cancelled."
  fi
fi

CI/CD Integration

For automated cleanup in CI/CD pipelines:

# GitHub Actions - Weekly Branch Cleanup
name: Stale Branch Cleanup

on:
  schedule:
    - cron: '0 9 * * 1'  # Monday 9 AM UTC
  workflow_dispatch:  # Manual trigger option

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

      - name: Find and report stale branches
        run: |
          echo "## Stale Branch Report" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY

          # Find branches older than 90 days
          git for-each-ref --format='%(refname:short) %(committerdate:relative)' \
            refs/remotes/origin/ | \
            grep -v -E '(HEAD|main|master|develop|release)' | \
            while read branch date; do
              echo "- $branch (last commit: $date)" >> $GITHUB_STEP_SUMMARY
            done

      - name: Create cleanup issue
        uses: actions/github-script@v7
        with:
          script: |
            // Create an issue for team to review stale branches
            // Instead of auto-deleting

"Automate detection, not deletion. The best branch cleanup automation creates visibility and nudgesβ€”not silent deletions that might destroy someone's work."

Best Practices for Safe Automation

  1. Start with reporting: Run cleanup scripts in dry-run mode for 2-4 weeks before enabling deletion
  2. Notify before deleting: Send Slack/email to branch author before deletion with a grace period
  3. Archive, don't delete: Consider creating archive tags before branch deletion for recovery
  4. Exclude recent activity: Never delete branches with commits in the last week, regardless of total age
  5. Respect PR status: Never delete branches with open PRsβ€”close the PR first
See your engineering metrics in 5 minutes with CodePulse

Frequently Asked Questions

What about feature branches for large features that take months?

Large features shouldn't live on a single branch for months. Use feature flags to merge incremental work to main behind a flag. This keeps branches short-lived while allowing long development cycles. If you must have a long-lived feature branch, document why in the repository README and exclude it from automated cleanup.

Should I delete branches that have been merged?

Yes, always. The commit history is preserved in main after merge. The branch name itself serves no purpose once merged. Enable "auto-delete head branches" in GitHub repository settings to handle this automatically.

What if someone deletes a branch I was going to use?

If the branch was merged, the code is in main. If it wasn't merged and was deleted, it's still recoverable: git reflog shows deleted branch references for ~30 days. But this scenario indicates a communication problemβ€”stale branches shouldn't have active interest.

How do stale branches affect repository performance?

Git handles thousands of branches efficiently, so raw performance isn't usually the issue. The problems are operational: slower UI in GitHub branch listings, longer clone times (slightly), and human cognitive load from cluttered branch lists. The primary cost is developer time and merge conflict risk, not Git performance.

Can CodePulse detect stale branches directly?

CodePulse focuses on PR and commit analytics rather than direct branch tracking. However, you can infer branch health from PR data: a PR that's been open for 60+ days with no activity indicates a stale branch. Check the Repositories view to see PR age distribution and identify patterns of stuck work.

What's the relationship between branch age and code churn?

Older branches often require more rewriting when finally merged, showing up as high code churn in the files they touch. If you see a spike in churn, check if it correlates with merging a long-lived branch. This is another reason to keep branches short-livedβ€”they merge cleanly instead of requiring extensive rework.

How does branch sprawl relate to knowledge silos?

Stale branches often represent knowledge silos in progress. The branch author may be the only person who understands that code. When branches sit for months, the author's context fades, creating a knowledge silo that's particularly hard to address because the code isn't even in the main codebase yet.

What tools integrate with GitHub for branch management?

Beyond GitHub's native features, tools like monorepo management platforms often include branch hygiene features. For enterprise environments, consider:

  • GitHub Actions for automated reporting and cleanup
  • Branch protection rules for policy enforcement
  • CODEOWNERS files for ownership accountability
  • Custom scripts integrated into your CI/CD pipeline

The best approach combines GitHub's native capabilities with lightweight automation that fits your team's specific workflow and policies.

See these insights for your team

CodePulse connects to your GitHub and shows you actionable engineering metrics in minutes. No complex setup required.

Free tier available. No credit card required.