Two years into a project, git branch stops being a list and starts being an archaeology dig. Most of what’s in there is already merged and just never got cleaned up — but nobody wants to be the person who git branch -D‘d someone’s unfinished work by mistake. This script classifies every branch across a whole fleet of repos and only ever deletes what git itself can prove is safe.
Step through the build below:
Ask any team with more than a year of git history and they’ll tell you the same thing: git branch on the shared build box or a long-lived clone is a wall of names nobody recognizes anymore. Most of those branches are perfectly safe to delete — they were merged into main months ago and just never got cleaned up. A few are genuinely abandoned experiments. And a few, somewhere in that pile, might be someone’s unfinished work that would be a real loss to destroy. Doing this by hand across dozens of repos doesn’t scale; doing it with a blanket git branch -D $(git branch | ...) one-liner is exactly the kind of automation mistake that makes a team distrust automation for years. git_branch_hygiene.rb splits the difference: it classifies every branch as PROTECTED, MERGED, STALE, or ACTIVE, and it only ever deletes a branch two ways — safely, using git’s own merge check, or force, behind an explicit second confirmation flag.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# git_branch_hygiene.rb -- audit and prune stale/merged local git branches
# across a fleet of repos.
#
# Problem it solves:
# After a year or two of active development, `git branch` on a shared
# build box or a developer's machine turns into a wall of forgotten
# feature branches. Most are already merged and just haven't been
# deleted; a few are genuinely abandoned; a few might still matter.
# Nobody wants to `git branch -D` their way through 200 branches by
# hand, and doing it wrong (deleting something unmerged) is exactly the
# kind of mistake that erodes trust in automation. This script scans
# one or many repos, classifies every local branch as MERGED / STALE /
# ACTIVE relative to a default branch, and only ever deletes a branch
# two ways: safely (git's own merge check, via `branch -d`) or with an
# explicit, separately-flagged, force confirmation for stale-but-unmerged
# branches.
#
# Prerequisites:
# - Ruby >= 2.7
# - git available on PATH
# - stdlib only: open3, optparse, json, time, fnmatch (via File.fnmatch)
#
# Usage:
# # Report only (safe, default) -- one repo
# ruby git_branch_hygiene.rb --repo /srv/myapp
#
# # Report only -- every git repo one level under a directory
# ruby git_branch_hygiene.rb --repos-dir /srv/repos
#
# # Actually delete branches already merged into the default branch
# ruby git_branch_hygiene.rb --repo /srv/myapp --delete-merged
#
# # Force-delete branches with no activity in 180+ days, even if unmerged
# # (requires the explicit --confirm-force flag as a safety rail)
# ruby git_branch_hygiene.rb --repo /srv/myapp --delete-stale --stale-days 180 --confirm-force
#
# ruby git_branch_hygiene.rb --repos-dir /srv/repos --json
require "open3"
require "optparse"
require "json"
require "time"
class GitBranchHygiene
DEFAULT_PROTECTED = %w[main master develop HEAD].freeze
Branch = Struct.new(:name, :last_commit_at, :age_days, :merged, :status, :action, keyword_init: true) do
def to_h
{
name: name,
last_commit_at: last_commit_at&.iso8601,
age_days: age_days,
merged: merged,
status: status,
action: action
}
end
end
RepoReport = Struct.new(:repo, :default_branch, :branches, :error, keyword_init: true) do
def to_h
{ repo: repo, default_branch: default_branch, branches: branches&.map(&:to_h), error: error }.compact
end
end
# protected_patterns: array of glob-style patterns (File.fnmatch) that are
# never touched, e.g. ["main", "master", "release/*"].
# runner: object responding to #call(argv, chdir:) -> [stdout, status_success?]
# Injected so this class can be unit tested with a fake git binary,
# though in this script we always exercise it against real `git`
# in throwaway sandbox repos (see git_branch_hygiene_test.rb).
def initialize(stale_days: 90, protected_patterns: DEFAULT_PROTECTED, runner: nil)
@stale_days = stale_days
@protected_patterns = protected_patterns
@runner = runner || self.class.method(:run_git)
end
# Inspect one repo and classify every local branch. Does not modify
# anything -- deletion is a separate explicit step (see #delete!).
def scan(repo_path)
unless git_repo?(repo_path)
return RepoReport.new(repo: repo_path, branches: [], error: "not a git repository")
end
default = default_branch(repo_path)
current = current_branch(repo_path)
merged_set = merged_branches(repo_path, default)
branches = list_branches(repo_path).map do |name, commit_iso|
commit_time = commit_iso ? Time.parse(commit_iso) : nil
age = commit_time ? ((Time.now - commit_time) / 86_400).floor : nil
age = 0 if age&.negative? # guard against minor clock skew making a just-now commit look "future"
merged = merged_set.include?(name)
protected_branch = protected?(name) || name == current
status =
if protected_branch
:protected
elsif merged
:merged
elsif age && age >= @stale_days
:stale
else
:active
end
Branch.new(name: name, last_commit_at: commit_time, age_days: age, merged: merged, status: status, action: :none)
end
RepoReport.new(repo: repo_path, default_branch: default, branches: branches, error: nil)
rescue StandardError => e
RepoReport.new(repo: repo_path, branches: [], error: "#{e.class}: #{e.message}")
end
# Deletes branches from `report` according to policy. Mutates each
# Branch's #action field to record what happened (deleted / skipped / failed).
# delete_merged: if true, safely delete every :merged branch via `git branch -d`
# (git itself refuses -d on anything not fully merged, so this
# can never destroy unmerged work even if our own bookkeeping is wrong)
# delete_stale: if true AND confirm_force is true, force-delete every :stale
# branch via `git branch -D`. Requires confirm_force as a second,
# independent safety rail because -D discards unmerged commits.
def delete!(report, delete_merged: false, delete_stale: false, confirm_force: false)
return report if report.error
report.branches.each do |b|
if delete_merged && b.status == :merged
b.action = delete_branch(report.repo, b.name, force: false)
elsif delete_stale && b.status == :stale
if confirm_force
b.action = delete_branch(report.repo, b.name, force: true)
else
b.action = :skipped_needs_confirm_force
end
end
end
report
end
private
def protected?(name)
@protected_patterns.any? { |pat| File.fnmatch(pat, name) }
end
def git_repo?(path)
Dir.exist?(path) && (out, ok = @runner.call(%w[rev-parse --is-inside-work-tree], chdir: path); ok && out.strip == "true")
end
def default_branch(path)
# Prefer the branch origin/HEAD points at; fall back to main, then master.
out, ok = @runner.call(%w[symbolic-ref --short refs/remotes/origin/HEAD], chdir: path)
return out.strip.sub("origin/", "") if ok && !out.strip.empty?
%w[main master].each do |candidate|
_, exists = @runner.call(["show-ref", "--verify", "--quiet", "refs/heads/#{candidate}"], chdir: path)
return candidate if exists
end
current_branch(path)
end
def current_branch(path)
out, ok = @runner.call(%w[symbolic-ref --short HEAD], chdir: path)
ok ? out.strip : nil
end
def merged_branches(path, default)
return [] unless default
out, ok = @runner.call(["branch", "--merged", default, "--format=%(refname:short)"], chdir: path)
return [] unless ok
out.split("\n").map(&:strip).reject(&:empty?)
end
def list_branches(path)
out, ok = @runner.call(["for-each-ref", "refs/heads", "--format=%(refname:short)|%(committerdate:iso-strict)"], chdir: path)
return [] unless ok
out.split("\n").filter_map do |line|
name, date = line.split("|", 2)
next if name.nil? || name.strip.empty?
[name.strip, date&.strip]
end
end
def delete_branch(path, name, force:)
flag = force ? "-D" : "-d"
_, ok = @runner.call(["branch", flag, name], chdir: path)
ok ? :deleted : :failed
end
# Default runner: shells out to the real `git` binary via Open3.
# Returns [combined_output_string, success_boolean].
def self.run_git(argv, chdir:)
stdout, stderr, status = Open3.capture3("git", *argv, chdir: chdir)
[status.success? ? stdout : "#{stdout}#{stderr}", status.success?]
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = {
stale_days: 90,
protected: GitBranchHygiene::DEFAULT_PROTECTED.dup,
delete_merged: false,
delete_stale: false,
confirm_force: false,
json: false
}
OptionParser.new do |opts|
opts.banner = "Usage: git_branch_hygiene.rb (--repo PATH | --repos-dir DIR) [options]"
opts.on("--repo PATH", "Path to a single git repo") { |v| options[:repo] = v }
opts.on("--repos-dir DIR", "Directory containing multiple git repos (one level deep)") { |v| options[:repos_dir] = v }
opts.on("--stale-days N", Integer, "Days of inactivity before an unmerged branch is 'stale' (default 90)") { |v| options[:stale_days] = v }
opts.on("--protect LIST", "Comma-separated glob patterns never touched (default: main,master,develop,HEAD)") { |v| options[:protected] = v.split(",") }
opts.on("--delete-merged", "Safely delete branches already merged into the default branch") { options[:delete_merged] = true }
opts.on("--delete-stale", "Force-delete unmerged branches past --stale-days (needs --confirm-force too)") { options[:delete_stale] = true }
opts.on("--confirm-force", "Required alongside --delete-stale to actually run the destructive force-delete") { options[:confirm_force] = true }
opts.on("--json", "Emit machine-readable JSON") { options[:json] = true }
opts.on("-h", "--help", "Show this help") { puts opts; exit 0 }
end.parse!
repos =
if options[:repo]
[options[:repo]]
elsif options[:repos_dir]
Dir.children(options[:repos_dir]).map { |c| File.join(options[:repos_dir], c) }.select { |p| File.directory?(p) }.sort
else
abort "ERROR: pass --repo PATH or --repos-dir DIR"
end
hygiene = GitBranchHygiene.new(stale_days: options[:stale_days], protected_patterns: options[:protected])
reports = repos.map do |repo|
report = hygiene.scan(repo)
hygiene.delete!(report,
delete_merged: options[:delete_merged],
delete_stale: options[:delete_stale],
confirm_force: options[:confirm_force])
end
if options[:json]
puts JSON.pretty_generate(reports.map(&:to_h))
else
reports.each do |r|
puts "== #{r.repo} =="
if r.error
puts " ERROR: #{r.error}"
next
end
puts " default branch: #{r.default_branch}"
if r.branches.empty?
puts " (no local branches found)"
end
r.branches.each do |b|
line = format(" %-8s %-35s age=%-5s merged=%-5s", b.status.to_s.upcase, b.name, (b.age_days ? "#{b.age_days}d" : "?"), b.merged)
line += " -> #{b.action}" unless b.action == :none
puts line
end
end
end
merged_count = reports.sum { |r| (r.branches || []).count { |b| b.status == :merged } }
stale_count = reports.sum { |r| (r.branches || []).count { |b| b.status == :stale } }
deleted_count = reports.sum { |r| (r.branches || []).count { |b| b.action == :deleted } }
has_errors = reports.any?(&:error)
unless options[:json]
puts "\nSummary: #{merged_count} merged, #{stale_count} stale, #{deleted_count} deleted across #{reports.size} repo(s)."
end
exit(has_errors ? 2 : (deleted_count.positive? || merged_count.positive? || stale_count.positive? ? 1 : 0))
end
The safety model is the whole point. --delete-merged calls git branch -d (lowercase), which git itself refuses to run on anything not fully merged into the current branch — so even if this script’s own bookkeeping about what’s “merged” were somehow wrong, git’s built-in check is the actual backstop that prevents data loss. --delete-stale is different: a branch with no commits in 90+ days that still isn’t merged might be genuinely abandoned, or it might be someone’s paused side quest. Force-deleting it needs both --delete-stale and a separate --confirm-force flag — one flag alone does nothing destructive, by design, so a single typo or a copy-pasted command from an old runbook can’t silently start deleting unmerged branches.
$ ruby git_branch_hygiene.rb --repos-dir /srv/repos --stale-days 90 == /srv/repos/repo-a == default branch: main ACTIVE feature/active-recent age=0d merged=false MERGED feature/already-merged age=578d merged=true STALE feature/stale-abandoned age=797d merged=false PROTECTED main age=0d merged=true == /srv/repos/repo-b == default branch: master MERGED hotfix/done age=550d merged=true PROTECTED master age=0d merged=true Summary: 2 merged, 1 stale, 0 deleted across 2 repo(s). $ ruby git_branch_hygiene.rb --repo /srv/repos/repo-a --delete-merged == /srv/repos/repo-a == default branch: main ACTIVE feature/active-recent age=0d merged=false MERGED feature/already-merged age=578d merged=true -> deleted STALE feature/stale-abandoned age=797d merged=false PROTECTED main age=0d merged=true Summary: 1 merged, 1 stale, 1 deleted across 1 repo(s). $ ruby git_branch_hygiene.rb --repo /srv/repos/repo-a --delete-stale --stale-days 90 == /srv/repos/repo-a == default branch: main ACTIVE feature/active-recent age=0d merged=false STALE feature/stale-abandoned age=797d merged=false -> skipped_needs_confirm_force PROTECTED main age=0d merged=true Summary: 0 merged, 1 stale, 0 deleted across 1 repo(s). $ ruby git_branch_hygiene.rb --repo /srv/repos/repo-a --delete-stale --stale-days 90 --confirm-force == /srv/repos/repo-a == default branch: main ACTIVE feature/active-recent age=0d merged=false STALE feature/stale-abandoned age=797d merged=false -> deleted PROTECTED main age=0d merged=true Summary: 0 merged, 1 stale, 1 deleted across 1 repo(s).
Every one of the audit scripts in this toolkit so far has pointed at the operating system — processes, services, the registry, the firewall. git_branch_hygiene.rb points at something just as neglected and just as easy to automate badly: the pile of local branches sitting in every long-lived git repo. It scans one repo or a whole directory of them, works out each branch’s relationship to the repo’s default branch, and produces a report you can act on — or, with an explicit flag, act on automatically, using git’s own safety mechanisms as the actual guardrail rather than trusting this script’s judgment alone.
Full script + README on GitHub: ruby-devops-toolkit/git-branch-hygiene
- Ruby ≥ 2.7 — developed and tested against Ruby 3.0.2.
gitonPATH— the script shells out to the realgitbinary viaOpen3; no gem wraps it.- Standard library only —
open3,optparse,json,time, andFile.fnmatchfor glob-style protected-branch patterns. - Works on Linux, macOS, and Windows (anywhere git and Ruby both run) since it only ever calls the portable
gitCLI.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# git_branch_hygiene.rb -- audit and prune stale/merged local git branches
# across a fleet of repos.
#
# Problem it solves:
# After a year or two of active development, `git branch` on a shared
# build box or a developer's machine turns into a wall of forgotten
# feature branches. Most are already merged and just haven't been
# deleted; a few are genuinely abandoned; a few might still matter.
# Nobody wants to `git branch -D` their way through 200 branches by
# hand, and doing it wrong (deleting something unmerged) is exactly the
# kind of mistake that erodes trust in automation. This script scans
# one or many repos, classifies every local branch as MERGED / STALE /
# ACTIVE relative to a default branch, and only ever deletes a branch
# two ways: safely (git's own merge check, via `branch -d`) or with an
# explicit, separately-flagged, force confirmation for stale-but-unmerged
# branches.
#
# Prerequisites:
# - Ruby >= 2.7
# - git available on PATH
# - stdlib only: open3, optparse, json, time, fnmatch (via File.fnmatch)
#
# Usage:
# # Report only (safe, default) -- one repo
# ruby git_branch_hygiene.rb --repo /srv/myapp
#
# # Report only -- every git repo one level under a directory
# ruby git_branch_hygiene.rb --repos-dir /srv/repos
#
# # Actually delete branches already merged into the default branch
# ruby git_branch_hygiene.rb --repo /srv/myapp --delete-merged
#
# # Force-delete branches with no activity in 180+ days, even if unmerged
# # (requires the explicit --confirm-force flag as a safety rail)
# ruby git_branch_hygiene.rb --repo /srv/myapp --delete-stale --stale-days 180 --confirm-force
#
# ruby git_branch_hygiene.rb --repos-dir /srv/repos --json
require "open3"
require "optparse"
require "json"
require "time"
class GitBranchHygiene
DEFAULT_PROTECTED = %w[main master develop HEAD].freeze
Branch = Struct.new(:name, :last_commit_at, :age_days, :merged, :status, :action, keyword_init: true) do
def to_h
{
name: name,
last_commit_at: last_commit_at&.iso8601,
age_days: age_days,
merged: merged,
status: status,
action: action
}
end
end
RepoReport = Struct.new(:repo, :default_branch, :branches, :error, keyword_init: true) do
def to_h
{ repo: repo, default_branch: default_branch, branches: branches&.map(&:to_h), error: error }.compact
end
end
# protected_patterns: array of glob-style patterns (File.fnmatch) that are
# never touched, e.g. ["main", "master", "release/*"].
# runner: object responding to #call(argv, chdir:) -> [stdout, status_success?]
# Injected so this class can be unit tested with a fake git binary,
# though in this script we always exercise it against real `git`
# in throwaway sandbox repos (see git_branch_hygiene_test.rb).
def initialize(stale_days: 90, protected_patterns: DEFAULT_PROTECTED, runner: nil)
@stale_days = stale_days
@protected_patterns = protected_patterns
@runner = runner || self.class.method(:run_git)
end
# Inspect one repo and classify every local branch. Does not modify
# anything -- deletion is a separate explicit step (see #delete!).
def scan(repo_path)
unless git_repo?(repo_path)
return RepoReport.new(repo: repo_path, branches: [], error: "not a git repository")
end
default = default_branch(repo_path)
current = current_branch(repo_path)
merged_set = merged_branches(repo_path, default)
branches = list_branches(repo_path).map do |name, commit_iso|
commit_time = commit_iso ? Time.parse(commit_iso) : nil
age = commit_time ? ((Time.now - commit_time) / 86_400).floor : nil
age = 0 if age&.negative? # guard against minor clock skew making a just-now commit look "future"
merged = merged_set.include?(name)
protected_branch = protected?(name) || name == current
status =
if protected_branch
:protected
elsif merged
:merged
elsif age && age >= @stale_days
:stale
else
:active
end
Branch.new(name: name, last_commit_at: commit_time, age_days: age, merged: merged, status: status, action: :none)
end
RepoReport.new(repo: repo_path, default_branch: default, branches: branches, error: nil)
rescue StandardError => e
RepoReport.new(repo: repo_path, branches: [], error: "#{e.class}: #{e.message}")
end
# Deletes branches from `report` according to policy. Mutates each
# Branch's #action field to record what happened (deleted / skipped / failed).
# delete_merged: if true, safely delete every :merged branch via `git branch -d`
# (git itself refuses -d on anything not fully merged, so this
# can never destroy unmerged work even if our own bookkeeping is wrong)
# delete_stale: if true AND confirm_force is true, force-delete every :stale
# branch via `git branch -D`. Requires confirm_force as a second,
# independent safety rail because -D discards unmerged commits.
def delete!(report, delete_merged: false, delete_stale: false, confirm_force: false)
return report if report.error
report.branches.each do |b|
if delete_merged && b.status == :merged
b.action = delete_branch(report.repo, b.name, force: false)
elsif delete_stale && b.status == :stale
if confirm_force
b.action = delete_branch(report.repo, b.name, force: true)
else
b.action = :skipped_needs_confirm_force
end
end
end
report
end
private
def protected?(name)
@protected_patterns.any? { |pat| File.fnmatch(pat, name) }
end
def git_repo?(path)
Dir.exist?(path) && (out, ok = @runner.call(%w[rev-parse --is-inside-work-tree], chdir: path); ok && out.strip == "true")
end
def default_branch(path)
# Prefer the branch origin/HEAD points at; fall back to main, then master.
out, ok = @runner.call(%w[symbolic-ref --short refs/remotes/origin/HEAD], chdir: path)
return out.strip.sub("origin/", "") if ok && !out.strip.empty?
%w[main master].each do |candidate|
_, exists = @runner.call(["show-ref", "--verify", "--quiet", "refs/heads/#{candidate}"], chdir: path)
return candidate if exists
end
current_branch(path)
end
def current_branch(path)
out, ok = @runner.call(%w[symbolic-ref --short HEAD], chdir: path)
ok ? out.strip : nil
end
def merged_branches(path, default)
return [] unless default
out, ok = @runner.call(["branch", "--merged", default, "--format=%(refname:short)"], chdir: path)
return [] unless ok
out.split("\n").map(&:strip).reject(&:empty?)
end
def list_branches(path)
out, ok = @runner.call(["for-each-ref", "refs/heads", "--format=%(refname:short)|%(committerdate:iso-strict)"], chdir: path)
return [] unless ok
out.split("\n").filter_map do |line|
name, date = line.split("|", 2)
next if name.nil? || name.strip.empty?
[name.strip, date&.strip]
end
end
def delete_branch(path, name, force:)
flag = force ? "-D" : "-d"
_, ok = @runner.call(["branch", flag, name], chdir: path)
ok ? :deleted : :failed
end
# Default runner: shells out to the real `git` binary via Open3.
# Returns [combined_output_string, success_boolean].
def self.run_git(argv, chdir:)
stdout, stderr, status = Open3.capture3("git", *argv, chdir: chdir)
[status.success? ? stdout : "#{stdout}#{stderr}", status.success?]
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = {
stale_days: 90,
protected: GitBranchHygiene::DEFAULT_PROTECTED.dup,
delete_merged: false,
delete_stale: false,
confirm_force: false,
json: false
}
OptionParser.new do |opts|
opts.banner = "Usage: git_branch_hygiene.rb (--repo PATH | --repos-dir DIR) [options]"
opts.on("--repo PATH", "Path to a single git repo") { |v| options[:repo] = v }
opts.on("--repos-dir DIR", "Directory containing multiple git repos (one level deep)") { |v| options[:repos_dir] = v }
opts.on("--stale-days N", Integer, "Days of inactivity before an unmerged branch is 'stale' (default 90)") { |v| options[:stale_days] = v }
opts.on("--protect LIST", "Comma-separated glob patterns never touched (default: main,master,develop,HEAD)") { |v| options[:protected] = v.split(",") }
opts.on("--delete-merged", "Safely delete branches already merged into the default branch") { options[:delete_merged] = true }
opts.on("--delete-stale", "Force-delete unmerged branches past --stale-days (needs --confirm-force too)") { options[:delete_stale] = true }
opts.on("--confirm-force", "Required alongside --delete-stale to actually run the destructive force-delete") { options[:confirm_force] = true }
opts.on("--json", "Emit machine-readable JSON") { options[:json] = true }
opts.on("-h", "--help", "Show this help") { puts opts; exit 0 }
end.parse!
repos =
if options[:repo]
[options[:repo]]
elsif options[:repos_dir]
Dir.children(options[:repos_dir]).map { |c| File.join(options[:repos_dir], c) }.select { |p| File.directory?(p) }.sort
else
abort "ERROR: pass --repo PATH or --repos-dir DIR"
end
hygiene = GitBranchHygiene.new(stale_days: options[:stale_days], protected_patterns: options[:protected])
reports = repos.map do |repo|
report = hygiene.scan(repo)
hygiene.delete!(report,
delete_merged: options[:delete_merged],
delete_stale: options[:delete_stale],
confirm_force: options[:confirm_force])
end
if options[:json]
puts JSON.pretty_generate(reports.map(&:to_h))
else
reports.each do |r|
puts "== #{r.repo} =="
if r.error
puts " ERROR: #{r.error}"
next
end
puts " default branch: #{r.default_branch}"
if r.branches.empty?
puts " (no local branches found)"
end
r.branches.each do |b|
line = format(" %-8s %-35s age=%-5s merged=%-5s", b.status.to_s.upcase, b.name, (b.age_days ? "#{b.age_days}d" : "?"), b.merged)
line += " -> #{b.action}" unless b.action == :none
puts line
end
end
end
merged_count = reports.sum { |r| (r.branches || []).count { |b| b.status == :merged } }
stale_count = reports.sum { |r| (r.branches || []).count { |b| b.status == :stale } }
deleted_count = reports.sum { |r| (r.branches || []).count { |b| b.action == :deleted } }
has_errors = reports.any?(&:error)
unless options[:json]
puts "\nSummary: #{merged_count} merged, #{stale_count} stale, #{deleted_count} deleted across #{reports.size} repo(s)."
end
exit(has_errors ? 2 : (deleted_count.positive? || merged_count.positive? || stale_count.positive? ? 1 : 0))
end
How it works
Classifying a branch: PROTECTED → MERGED → STALE → ACTIVE
#scan(repo_path) does three git queries per repo: for-each-ref refs/heads to list every local branch with its last commit date, branch --merged <default> to get the set already merged into the default branch, and a symbolic-ref check for the currently checked-out branch (which is always left alone, since deleting your own current branch is a git error anyway). Each branch is then classified in priority order: PROTECTED first (name matches a glob pattern like main or release/*, or it’s the currently checked-out branch), then MERGED (present in the --merged output), then STALE (unmerged, but last commit is older than --stale-days, default 90), and finally ACTIVE for everything else — unmerged, recent work nobody should touch.
Finding the default branch without assuming main or master
default_branch tries three things in order: the branch origin/HEAD points at (the most reliable signal, since it reflects what the remote actually considers default), then a literal check for local main, then master, and finally falls back to whatever branch is currently checked out. This matters because plenty of older repos still default to master, plenty of newer ones use main, and a script that hard-codes either one silently misclassifies every branch in the repos that use the other.
#delete! — two independent safety rails
This is the part worth reading twice. delete_branch uses -d (safe) unless explicitly asked for -D (force) — and #delete! only ever requests force when both delete_stale and confirm_force are true. A :merged branch deleted with -d is protected by git itself: if this script’s own merge-detection were somehow stale or wrong, git branch -d independently refuses to delete anything not actually merged and exits non-zero, which this script reports as :failed rather than silently succeeding. A :stale branch without --confirm-force gets marked :skipped_needs_confirm_force in the report — visible, not silent — so a dry run (the default) always shows you exactly what a real run would do.
The injected runner: testing against real git, not a mock
Every git call goes through @runner, which defaults to self.class.method(:run_git) — a thin wrapper around Open3.capture3("git", *argv, chdir: chdir). That’s injectable for testing, but unlike some of the WMI-dependent Windows scripts in this toolkit, git runs perfectly well in a Linux sandbox, so the test suite doesn’t need a fake — it builds two real throwaway repos with real backdated commits (GIT_COMMITTER_DATE set explicitly) and real merges, then runs the actual script against them end-to-end.
Example output
The test setup builds two real repos under /tmp/gbh-test: repo-a has an already-merged feature branch, a branch backdated to 2024 with no merge (stale), and a fresh unmerged branch (active); repo-b is a smaller repo on master instead of main, to exercise the default-branch fallback. Paths below are shown as /srv/repos/... to match a realistic fleet layout:
Then reconciling one repo with --delete-merged, and the two-step stale force-delete:
- A branch you expected to be MERGED shows as ACTIVE or STALE.
--mergedchecks ancestry against the default branch’s current tip — if the branch was merged via squash-and-merge (common on GitHub/GitLab PRs) rather than a real merge commit, git doesn’t consider the original commits ancestors of the default branch, so this script correctly won’t call it merged. Delete squash-merged branches manually or extend the classifier (see Extending below). not a git repositoryfor a directory you know is a repo. Check the path points at the repo root (containing.git), not a subdirectory inside it —--repos-dironly looks one level deep and expects each child to itself be a repo root.git branch -dfails even though the report said MERGED. The report’s merge check runs at scan time; if new commits landed on the default branch or the target branch between scanning and deleting, the classification can go stale. Re-run without--delete-mergedfirst to confirm current state before deleting in a fast-moving repo.- Wrong default branch detected. If the repo has no
originremote (like the test repos in this tutorial) and bothmainandmasterexist locally,mainwins by check order. Pass--protectwith an explicit list if your convention differs, or extenddefault_branchto accept an override flag. - Windows path handling. The script itself is platform-agnostic (it only shells out to
git), but when passing--repos-diron Windows, use the shell’s native path separator or forward slashes, both of which Ruby’sFilemethods accept.
- Squash-merge detection. GitHub/GitLab squash merges break ancestry-based
--mergeddetection. A more thorough classifier could check whether every commit’s patch-id on a branch already exists on the default branch (git cherryor comparinggit patch-idoutput) to catch these too. - Remote branch pruning. This script only ever touches local branches. A natural extension adds
--delete-remotethat runsgit push origin --delete <branch>for branches classified MERGED, ideally behind the same two-flag safety pattern used for stale deletion. - Author/team reporting.
for-each-refcan also emit%(authorname)— group the STALE report by author and you get a “these are the branches waiting on you” digest, which is a much friendlier prompt than a bare deletion warning. - Wire it into
alert_notifier.rb. Since a stale-branch count above some threshold is itself a signal worth surfacing, pipe a periodic--jsonscan’s stale count into the Slack alerting library from this series as a lightweight repo-hygiene metric.