Automating Git Hygiene: Stale Branch Cleanup

• by Alien Brain Trust • AI Learning
Automating Git Hygiene: Stale Branch Cleanup

Automating Git Hygiene: Stale Branch Cleanup

Every sprint, I was doing the same thing. Open GitHub, sort by last commit date, scan the branch list, identify what was merged, check what was abandoned, and delete the obvious ones. Then ping a developer about the ambiguous ones. Then wait. Then follow up. Then delete. Ninety minutes, every two weeks, for a task that produced no insight and required no judgment — just execution.

That’s the definition of a task worth automating. Here’s the script I built, the decisions behind it, and what it changed.

TL;DR

A shell script running on a cron schedule now audits and removes stale Git branches across our repositories. No human touches it unless a branch hits ambiguous criteria. Ninety minutes of recurring manual work eliminated. The pattern is portable to any team using GitHub or GitLab with API access.

The Problem with Manual Branch Audits

Stale branches aren’t just noise. In an enterprise environment, they represent risk surface: old code that hasn’t been reviewed against current dependencies, potential merge conflicts that inflate over time, and in the worst cases, branches containing development credentials or feature flags that were never fully cleaned up.

I’ve seen audit findings triggered by a stale branch with a hardcoded test token that a developer created eight months prior and forgot. Nobody deleted it because the process for deletion was manual and low-priority.

The accumulation problem compounds. When deletion is manual, it only happens when someone is annoyed enough to do it. That means the repo fills up, the signal-to-noise ratio drops, and the real security implications get buried under thirty branches named feature/jira-123-temp-fix.

I was annoyed enough to fix it properly.

What the Automation Does

The script runs on a nightly cron. It does the following in sequence:

  1. Pulls the full branch list from the GitHub API for each configured repository
  2. Identifies branches that meet all three deletion criteria: merged into main, last commit older than 14 days, not protected by naming convention
  3. Deletes confirmed candidates via API
  4. Flags ambiguous branches (last commit 7–14 days, not merged, not protected) for a Slack notification to the owning team
  5. Writes a summary log entry with branch name, last commit author, and action taken

Nothing happens silently. Every deletion is logged. Every ambiguous branch generates a notification. The on-call team can review the log at any time.

The Script

#!/usr/bin/env bash
# stale-branch-cleanup.sh
# Removes merged stale branches; flags ambiguous ones for review

set -euo pipefail

REPO_OWNER="${REPO_OWNER}"
REPO_NAME="${REPO_NAME}"
GITHUB_TOKEN="${GITHUB_TOKEN}"
STALE_DAYS_DELETE=14
STALE_DAYS_FLAG=7
PROTECTED_PREFIXES=("main" "master" "release/" "hotfix/" "develop")
LOG_FILE="/var/log/branch-cleanup.log"
SLACK_WEBHOOK="${SLACK_WEBHOOK_URL}"
TODAY=$(date +%s)

log() {
  echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ") $*" | tee -a "$LOG_FILE"
}

is_protected() {
  local branch="$1"
  for prefix in "${PROTECTED_PREFIXES[@]}"; do
    if [[ "$branch" == "$prefix"* ]]; then
      return 0
    fi
  done
  return 1
}

notify_slack() {
  local message="$1"
  curl -s -X POST -H 'Content-type: application/json' \
    --data "{\"text\": \"$message\"}" \
    "$SLACK_WEBHOOK" > /dev/null
}

branches=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
  "https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/branches?per_page=100" \
  | jq -r '.[].name')

for branch in $branches; do
  if is_protected "$branch"; then
    continue
  fi

  branch_data=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
    "https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/branches/$branch")

  last_commit_date=$(echo "$branch_data" | jq -r '.commit.commit.committer.date')
  last_commit_ts=$(date -d "$last_commit_date" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$last_commit_date" +%s)
  age_days=$(( (TODAY - last_commit_ts) / 86400 ))

  # Check merged status
  merged=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "Authorization: token $GITHUB_TOKEN" \
    "https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/compare/main...$branch")

  if [[ "$merged" == "200" ]] && [[ "$age_days" -ge "$STALE_DAYS_DELETE" ]]; then
    curl -s -X DELETE -H "Authorization: token $GITHUB_TOKEN" \
      "https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/git/refs/heads/$branch" > /dev/null
    log "DELETED $branch (age: ${age_days}d, merged)"
  elif [[ "$age_days" -ge "$STALE_DAYS_DELETE" ]] && [[ "$merged" != "200" ]]; then
    log "FLAGGED $branch (age: ${age_days}d, unmerged) — requires manual review"
    notify_slack ":warning: Stale unmerged branch in $REPO_NAME: \`$branch\` (${age_days} days old). Review or delete."
  elif [[ "$age_days" -ge "$STALE_DAYS_FLAG" ]]; then
    log "WARNING $branch (age: ${age_days}d) approaching stale threshold"
  fi
done

log "Cleanup run complete for $REPO_OWNER/$REPO_NAME"

The token is pulled from the environment — never hardcoded, never logged. The Slack webhook is the same. The log captures what happened without capturing credentials. These are non-negotiable.

Security Decisions Built Into This Pattern

Read-before-delete. The script checks merge status before any destructive action. It never deletes an unmerged branch automatically, regardless of age. That boundary protects against false positives and protects work that isn’t ready to surface yet.

Protected prefix list. Any branch matching a defined prefix is skipped entirely. This is explicit allowlisting, not blocklisting. The default assumption is that a branch might be important. It earns deletion.

No credentials in logs. The log file captures branch names, authors pulled from commit metadata, and actions taken. It does not capture API tokens, webhook URLs, or anything that shouldn’t be in plaintext on disk. I’ve audited enough incident logs to know that logging hygiene is where teams get hurt.

Slack notification as audit trail. Every flagged and deleted branch generates a human-readable record outside the system. If something goes wrong — if a branch gets deleted that shouldn’t have been — there’s an external trail to reconstruct what happened and when.

Least privilege API token. The GitHub token needs two permissions: read branch data and delete branches. Nothing else. Scoped to the minimum required and rotated on a 90-day schedule.

What Changed After Deployment

The ninety minutes is gone. That’s the obvious outcome.

The less obvious outcome: the repo stays clean continuously instead of getting cleaned periodically. Branch accumulation doesn’t reach the point where it becomes an audit risk. Developers get a Slack nudge about their aging branches before they become a problem, which changed the behavior upstream — people started deleting their own branches more consistently because the visibility improved.

I also caught something I hadn’t anticipated. Two weeks in, the log showed a branch that was 34 days old, unmerged, and owned by a developer who had left the company 60 days earlier. That branch existed in the offboarding gap. Automated detection surfaced it; manual deletion and a post-mortem on the offboarding checklist followed.

That’s the class of thing that doesn’t get caught when cleanup is manual and infrequent.

Adapting This Pattern

The core logic is portable. GitLab uses a nearly identical API structure — swap the endpoints and the token mechanism. Azure DevOps requires a different auth model but the same logic applies. If you’re on a self-hosted Git solution with API access, the pattern holds.

The thresholds I use (14 days for deletion, 7 days for flagging) fit my workflow. A team shipping multiple times per day might set tighter thresholds. A team running quarterly releases might extend them. The numbers are parameters, not doctrine.

If your organization has a CMDB or asset register, the log output can feed directly into it. Branch lifecycle becomes an auditable record, not a manual spot-check.

Key Takeaways

  • Stale branches are a security surface, not just a cleanliness problem. Unreviewed old code, forgotten credentials, and orphaned feature flags accumulate there.
  • Automation with logging beats manual processes with documentation. You get better coverage and a better audit trail simultaneously.
  • Non-destructive first, destructive only on confidence. The script never deletes an unmerged branch. That one rule prevents the most damaging false positive.
  • Credential hygiene in automation is non-negotiable. Environment variables, not hardcoded values. Scoped tokens, not admin credentials. Logs that capture actions, not secrets.
  • Side effects are often the real value. The ninety minutes matters less than the offboarding gap it caught two weeks later.

The script is simple. The pattern is transferable. If you have a recurring manual task that runs on a schedule and follows the same logic every time, that task belongs in a cron job — not on your calendar.

Tags: #automation#workflows#implementation#building-and-learning#case-study

Comments

Loading comments...