the shed // ruby / devops / git / fleet automation

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:

git_branch_hygiene.rb

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.

Get the code

Full script + README on GitHub: ruby-devops-toolkit/git-branch-hygiene

GIT_BRANCH_HYGIENE.RB — CLASSIFY & PRUNE ACROSS A FLEET OF REPOS// fleet of local repos// classificationrepo-a/repo-b/repo-c/GitBranchHygiene#scan(repo_path)for-each-ref refs/headsbranch –merged <default>age = now – committerdateclassify per branchPROTECTEDmain / master — never touchedACTIVErecent, unmerged — left aloneMERGEDgit branch -d (safe)STALEneeds –confirm-force–delete-merged–delete-stale+ –confirm-force

Figure: git_branch_hygiene.rb scan/classify/prune flow across a fleet of repos.
Prerequisites
  • Ruby ≥ 2.7 — developed and tested against Ruby 3.0.2.
  • git on PATH — the script shells out to the real git binary via Open3; no gem wraps it.
  • Standard library onlyopen3, optparse, json, time, and File.fnmatch for glob-style protected-branch patterns.
  • Works on Linux, macOS, and Windows (anywhere git and Ruby both run) since it only ever calls the portable git CLI.
git_branch_hygiene.rbruby
#!/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
walkthrough

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.

output

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:

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).

Then reconciling one repo with --delete-merged, and the two-step stale force-delete:

ruby git_branch_hygiene.rb –repo /srv/repos/repo-a –delete-merged
MERGED feature/already-merged age=578d merged=true -> deleted
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
STALE feature/stale-abandoned age=797d merged=false -> skipped_needs_confirm_force
ruby git_branch_hygiene.rb –repo /srv/repos/repo-a –delete-stale –stale-days 90 –confirm-force
STALE feature/stale-abandoned age=797d merged=false -> deleted
19
assertions passed
2
real test repos
0
unmerged branches ever force-deleted without –confirm-force
Troubleshooting
  • A branch you expected to be MERGED shows as ACTIVE or STALE. --merged checks 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 repository for a directory you know is a repo. Check the path points at the repo root (containing .git), not a subdirectory inside it — --repos-dir only looks one level deep and expects each child to itself be a repo root.
  • git branch -d fails 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-merged first to confirm current state before deleting in a fast-moving repo.
  • Wrong default branch detected. If the repo has no origin remote (like the test repos in this tutorial) and both main and master exist locally, main wins by check order. Pass --protect with an explicit list if your convention differs, or extend default_branch to accept an override flag.
  • Windows path handling. The script itself is platform-agnostic (it only shells out to git), but when passing --repos-dir on Windows, use the shell’s native path separator or forward slashes, both of which Ruby’s File methods accept.
Extending it
  • Squash-merge detection. GitHub/GitLab squash merges break ancestry-based --merged detection. A more thorough classifier could check whether every commit’s patch-id on a branch already exists on the default branch (git cherry or comparing git patch-id output) to catch these too.
  • Remote branch pruning. This script only ever touches local branches. A natural extension adds --delete-remote that runs git 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-ref can 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 --json scan’s stale count into the Slack alerting library from this series as a lightweight repo-hygiene metric.