This is Ruby script I found way back and was using it since.

The credits for creation goes to Zach Dennis. The script is decribed in his blog post

The source is here

Example

This is beginning of very long output from Groovy source

Groovy source

The code

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
#!/usr/bin/env ruby

require 'rubygems'

gem 'term-ansicolor', '>=1.0.5'
require 'term/ansicolor'

class GitCommit
  attr_reader :content

  def initialize(content)
    @content = content
  end

  def sha
    @content.split[1]
  end

  def to_s
    `git log --pretty=format:"%h %ad %an - %s" #{sha}~1..#{sha}`
  end

  def unmerged?
    content =~ /^\+/
  end

  def equivalent?
    content =~ /^\-/
  end
end

class GitBranch
  attr_reader :name, :commits

  def initialize(name, commits)
    @name = name
    @commits = commits
  end

  # Returns origin from origin/some/branch/here
  def repository
    name.split("/", 2).first
  end

  # Returns some/branch/here from origin/some/branch/here
  def branch_name
    name.split("/", 2).last
  end

  def unmerged_commits
    commits.select{ |commit| commit.unmerged? }
  end

  def equivalent_commits
    commits.select{ |commit| commit.equivalent? }
  end

end

class GitBranches < Array
  def self.clean_branch_output(str)
    str.split(/\n/).map{ |e| e.strip.gsub(/\*\s+/, '') }.reject{ |branch| branch =~ /\b#{Regexp.escape(UPSTREAM)}\b/ }.sort
  end

  def self.local_branches
    clean_branch_output `git branch`
  end

  def self.remote_branches
    clean_branch_output `git branch -r`
  end

  def self.load(options)
    git_branches = new
    branches = if options[:local]
      local_branches
    elsif options[:remote]
      remote_branches
    end

    branches.each do |branch|
      raw_commits = `git cherry -v #{UPSTREAM} #{branch}`.split(/\n/).map{ |c| GitCommit.new(c) }
      git_branches << GitBranch.new(branch, raw_commits)
    end
    git_branches
  end

  def unmerged
    reject{ |branch| branch.commits.empty? }.sort_by{ |branch| branch.name }
  end

  def any_missing_commits?
    select{ |branch| branch.commits.any? }.any?
  end
end

class GitUnmerged
  VERSION = "1.1"

  include Term::ANSIColor

  attr_reader :branches

  def initialize(args)
    @options = {}
    @branches_to_prune = []
    extract_options_from_args(args)
  end

  def load
    @branches ||= GitBranches.load(:local => local?, :remote => remote?)
    @branches.reject!{|b| @options[:exclude].include?(b.name)} if @options[:exclude].is_a?(Array)
    @branches.select!{|b| @options[:only].include?(b.name)} if @options[:only].is_a?(Array)
  end

  def print_overview
    load
    if @options[:exclude] && @options[:exclude].length > 0
      puts "The following branches have been excluded"
      @options[:exclude].each do |branch_name|
        puts "  #{branch_name}"
      end
      puts
    end

    if @options[:only] && @options[:only].length > 0
      puts "The following branches will be compared against:"
      @options[:only].each do |branch_name|
        puts "  #{branch_name}"
      end
      puts
    end

    if branches.any_missing_commits?
      puts "The following branches possibly have commits not merged to #{upstream}:"
      branches.each do |branch|
        num_unmerged = yellow(branch.unmerged_commits.size.to_s)
        num_equivalent = green(branch.equivalent_commits.size.to_s)
        puts %|  #{branch.name} (#{num_unmerged}/#{num_equivalent} commits)|
      end
    end
  end

  def print_help
    puts <<-EOT.gsub(/^\s+\|/, '')
      |Usage: #{$0} [-a] [--upstream <branch>] [--remote] [--prune]
      |
      |This script wraps the "git cherry" command. It reports the commits from all local
      |branches which have not been merged into an upstream branch.
      |
      |  #{yellow("yellow")} commits have not been merged
      |  #{green("green")} commits have equivalent changes in <upstream> but different SHAs
      |
      |The default upstream is 'master' (or 'origin/master' if running with --remote)
      |
      |OPTIONS:
      |  -a   display all unmerged commits (verbose)
      |  --remote (-r)  compare remote branches instead of local branches
      |  --upstream <branch>   specify a specific upstream branch (defaults to master)
      |  --exclude <branch>[,<branch>,...]   specify a comma-separated list of branches to exclude
      |  --only <branch>[,<branch>,...]   specify a comma-separated list of branches to include
      |  --prune  prompts user to delete branches which have no differences with the upstream
      |
      |EXAMPLE: check for all unmerged commits
      |  #{$0}
      |
      |EXAMPLE: check for all unmerged commits and merged commits (but with a different SHA)
      |  #{$0} -a
      |
      |EXAMPLE: use a different upstream than master
      |  #{$0} --upstream otherbranch
      |
      |EXAMPLE: compare remote branches against origin/master
      |  #{$0} --remote (-r)
      |
      |EXAMPLE: delete branches without unmerged commits
      |  #{$0} --prune
      |
      |EXAMPLE: delete remote branches without unmerged commits
      |  #{$0} --remote --prune
      |
      |GITCONFIG:
      |  If you name this file git-unmerged and place it somewhere in your PATH
      |  you will be able to type "git unmerged" to use it. If you'd like to name
      |  it something else and still refer to it with "git unmerged" then you'll
      |  need to set up an alias:
      |      git config --global alias.unmerged \\!#{$0}
      |
      |Version: #{VERSION}
      |Author: Zach Dennis <zdennis@mutuallyhuman.com>
    EOT
    exit
  end

  def print_version
    puts "#{VERSION}"
  end

  def branch_description
    local? ? "local" : "remote"
  end

  def print_specifics
    load

    if branches.any_missing_commits?
      print_breakdown
    else
      puts "There are no #{branch_description} branches out of sync with #{upstream}"
    end
  end

  def print_breakdown
    puts "Below is a breakdown for each branch. Here's a legend:"
    puts
    print_legend
    branches.each do |branch|
      puts
      print "#{branch.name}:"
      if branch.unmerged_commits.empty? && !show_equivalent_commits?
        print "(no unmerged commits"
        if prune?
          print ",", red(" this will be pruned")
          @branches_to_prune << branch
        end
        print ")\n"
      else
        puts
      end
      branch.unmerged_commits.each { |commit| puts yellow(commit.to_s) }

      if show_equivalent_commits?
        branch.equivalent_commits.each do |commit|
          puts green(commit.to_s)
        end
      end
    end
  end

  def print_legend
    load
    puts "  " + yellow("yellow") + " commits have not been merged"
    puts "  " + green("green") + " commits have equivalent changes in #{UPSTREAM} but different SHAs" if show_equivalent_commits?
  end

  def prune
    return unless prune?
    if @branches_to_prune.empty?
      puts "", "There are no branches to prune."
    else
      # Protects Heroku repo's
      rejected_master_branches = @branches_to_prune.reject!{|branch| branch.branch_name =~ /master/}
      puts "", "Are you sure you want to prune the following #{@branches_to_prune.size} branches?", ""
      puts red("(Keep in mind this will remove these branches from the remote repository)") if @remote
      @branches_to_prune.each do |branch|
        puts red(" #{branch.name}")
      end
      puts "(omitting branches named master)" if rejected_master_branches
      puts
      print "y or n: "
      if STDIN.gets=~/y/i
        @branches_to_prune.each do |branch|
          if local?
            `git branch -D #{branch.name}`
          elsif remote?
            puts "pruning: #{branch.branch_name} with 'git push #{branch.repository} :#{branch.branch_name}'"
            `git push #{branch.repository} :#{branch.branch_name}`
          end
        end
        puts "", "Pruned #{@branches_to_prune.size} branches."
      else
        puts "", "Pruning aborted. All branches were left unharmed."
      end
    end
  end

  def prune? ; @options[:prune ] ; end
  def show_help? ; @options[:show_help] ; end
  def show_equivalent_commits? ; @options[:show_equivalent_commits] ; end
  def show_version? ; @options[:show_version] ; end

  def upstream
    if @options[:upstream]
      @options[:upstream]
    elsif local?
      "master"
    elsif remote?
      "origin/master"
    end
  end

  private

  def extract_options_from_args(args)
    if args.include?("--remote") || args.include?("-r")
      @options[:remote] = true
    else
      @options[:local] = true
    end

    @options[:prune] = true if args.include?("--prune")
    @options[:show_help] = true if args.include?("-h") || args.include?("--help")
    @options[:show_equivalent_commits] = true if args.include?("-a")
    @options[:show_version] = true if args.include?("-v") || args.include?("--version")

    if index=args.index("--upstream")
      @options[:upstream] = args[index+1]
    end

    if index=args.index("--exclude")
      @options[:exclude] = args[index+1].split(',')
    end

    if index=args.index("--only")
      @options[:only] = args[index+1].split(',')
    end
  end

  def local? ; @options[:local] ; end
  def remote? ; @options[:remote] ; end
end


unmerged = GitUnmerged.new ARGV
UPSTREAM = unmerged.upstream
if unmerged.show_help?
  unmerged.print_help
  exit
elsif unmerged.show_version?
  unmerged.print_version
  exit
else
  unmerged.print_overview
  puts
  unmerged.print_specifics
  unmerged.prune
end