Git - find unmerged commits
Contents
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
The code
#!/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
Author Miro Adamy
LastMod 2016-03-22
License (c) 2006-2020 Miro Adamy