midwire
8/16/2016 - 4:46 PM

A script to list all remote branches that have been merged into a specified branch. Optionally exclude branches with recent commits. Written

A script to list all remote branches that have been merged into a specified branch. Optionally exclude branches with recent commits. Written to help in deleting old merged branches.

#!/usr/bin/env ruby
#
# Get a list of merged branches
#
# You need to checkout the target remote branch before running. In other words,
# if your target branch is 'master', you have to have it checked out before you
# run this script, otherwise you will get an error like: `fatal: malformed
# object name master`. Git needs to have the branch checked out in order to find
# the branches that have/have-not been merged into it.
#
# To create a list of branch-names only, without date info
#
# git-merged-branches.rb --branch production --exclude-days 180 | cut -f1 -d'|'
#
# For help run: git-merged-branches.rb --help

require 'trollop'
require 'fileutils'
require 'colored'

class GitMergedBranches
  attr_accessor :branch_count, :options, :elapsed, :excluded_branches, :matched_count

  include FileUtils

  class BranchEntry
    attr_accessor :name, :last_commit_date, :relative_last_commit

    def initialize(name, last_commit_date, relative_last_commit)
      self.name = name
      self.last_commit_date = last_commit_date
      self.relative_last_commit = relative_last_commit
    end

    def <=>(other)
      self.last_commit_date <=> other.last_commit_date
    end

    def to_s
      "#{name} | #{last_commit_date} | #{relative_last_commit}"
    end
  end

  class << self
    def collect_args(*_args)
      opts = Trollop.options do
        opt(
          :branch,
          'Base branch - list branches merged into this branch. Uses current branch if not specified.',
          type: :string, short: 'b', required: false
        )
        opt(
          :exclude_days,
          'Exclude branches that have no commits within this many days',
          type: :integer, short: 'x', required: false, default: 0
        )
        opt(
          :color,
          'Use colored output',
          type: :boolean, short: 'c', required: false, default: true
        )
      end
      # Set branch to current if not given on command line
      opts[:branch] ||= `git rev-parse --abbrev-ref HEAD`.chomp
      opts
    end

    def run
      start_time = Time.now
      opts = collect_args(ARGV)

      instance = GitMergedBranches.new(opts)
      instance.process
      instance.elapsed = Time.now - start_time
      instance.report_summary
    end
  end

  def initialize(opts)
    self.branch_count = 0
    self.excluded_branches = []
    self.options = opts
  end

  def color(string, clr)
    if options[:color]
      puts(string.send(clr))
    else
      puts(string)
    end
  end

  def report_summary
    puts
    color(">>> Processed #{branch_count} branches in [#{elapsed}] seconds", :red)
    color(">>> Branches with NO commits in the last #{options[:exclude_days]} days: [#{matched_count}]", :red)
    color(">>> Branches with commits in the last #{options[:exclude_days]} days: [#{excluded_branches.count}]", :red)
  end

  def process
    self.matched_count = matched_branches.count
    color(">>> #{matched_count} remote branches with no commits in the last #{options[:exclude_days]} days:", :green)
    puts
    puts matched_branches.sort
  end

  def merged_branches
    @branches ||= begin
      current_origin = nil
      cmd = "git branch -r --merged #{options[:branch]}"
      merged_list = `#{cmd}`.split("\n").collect(&:strip)
      fail "Error running: #{cmd}. See output above ^^^" unless $?.exitstatus == 0
      # find and delete the HEAD pointer and current branch origin
      head_pointer = merged_list.grep(/ -> /).first
      current_origin = head_pointer.split(/ -> /).last
      merged_list.delete_if { |elem| [head_pointer, current_origin].include?(elem) }
    end
  end

  def matched_branches
    today = Date.today
    @sorted ||= merged_branches.map do |branch|
      self.branch_count += 1
      date_strings = `git show --format="%ci|%cr" #{branch} | head -n 1`.chomp.split('|')
      last_commit_date = Date.strptime(date_strings.first, '%Y-%m-%d')
      days_old = (today - last_commit_date).to_i
      if recent_branch?(days_old)
        excluded_branches << branch
        nil
      else
        BranchEntry.new(branch, last_commit_date, date_strings.last)
      end
    end.compact
  end

  # Determine if a branch has any commits within the last options[:exclude_days] days
  def recent_branch?(branch_age)
    branch_age.to_i <= options[:exclude_days].to_i
  end
end

GitMergedBranches.run