#!/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 ] [--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 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 specify a specific upstream branch (defaults to master) | --exclude [,,...] specify a comma-separated list of branches to exclude | --only [,,...] 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 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