364 lines
		
	
	
		
			No EOL
		
	
	
		
			12 KiB
		
	
	
	
		
			Ruby
		
	
	
		
			Executable file
		
	
	
	
	
			
		
		
	
	
			364 lines
		
	
	
		
			No EOL
		
	
	
		
			12 KiB
		
	
	
	
		
			Ruby
		
	
	
		
			Executable file
		
	
	
	
	
| #!/usr/bin/env ruby
 | |
| 
 | |
| HELP = <<EOS
 | |
| git-wtf displays the state of your repository in a readable, easy-to-scan
 | |
| format. It's useful for getting a summary of how a branch relates to a remote
 | |
| server, and for wrangling many topic branches.
 | |
| 
 | |
| git-wtf can show you:
 | |
| - How a branch relates to the remote repo, if it's a tracking branch.
 | |
| - How a branch relates to integration branches, if it's a feature branch.
 | |
| - How a branch relates to the feature branches, if it's an integration
 | |
|   branch.
 | |
| 
 | |
| git-wtf is best used before a git push, or between a git fetch and a git
 | |
| merge. Be sure to set color.ui to auto or yes for maximum viewing pleasure.
 | |
| EOS
 | |
| 
 | |
| KEY = <<EOS
 | |
| KEY:
 | |
| () branch only exists locally
 | |
| {} branch only exists on a remote repo
 | |
| [] branch exists locally and remotely
 | |
| 
 | |
| x merge occurs both locally and remotely
 | |
| ~ merge occurs only locally
 | |
|   (space) branch isn't merged in
 | |
| 
 | |
| (It's possible for merges to occur remotely and not locally, of course, but
 | |
| that's a less common case and git-wtf currently doesn't display anything
 | |
| special for it.)
 | |
| EOS
 | |
| 
 | |
| USAGE = <<EOS
 | |
| Usage: git wtf [branch+] [options]
 | |
| 
 | |
| If [branch] is not specified, git-wtf will use the current branch. The possible
 | |
| [options] are:
 | |
| 
 | |
|   -l, --long          include author info and date for each commit
 | |
|   -a, --all           show all branches across all remote repos, not just
 | |
|                       those from origin
 | |
|   -A, --all-commits   show all commits, not just the first 5
 | |
|   -s, --short         don't show commits
 | |
|   -k, --key           show key
 | |
|   -r, --relations     show relation to features / integration branches
 | |
|       --dump-config   print out current configuration and exit
 | |
| 
 | |
| git-wtf uses some heuristics to determine which branches are integration
 | |
| branches, and which are feature branches. (Specifically, it assumes the
 | |
| integration branches are named "master", "next" and "edge".) If it guesses
 | |
| incorrectly, you will have to create a .git-wtfrc file.
 | |
| 
 | |
| To start building a configuration file, run "git-wtf --dump-config >
 | |
| .git-wtfrc" and edit it. The config file is a YAML file that specifies the
 | |
| integration branches, any branches to ignore, and the max number of commits to
 | |
| display when --all-commits isn't used.  git-wtf will look for a .git-wtfrc file
 | |
| starting in the current directory, and recursively up to the root.
 | |
| 
 | |
| IMPORTANT NOTE: all local branches referenced in .git-wtfrc must be prefixed
 | |
| with heads/, e.g. "heads/master". Remote branches must be of the form
 | |
| remotes/<remote>/<branch>.
 | |
| EOS
 | |
| 
 | |
| COPYRIGHT = <<EOS
 | |
| git-wtf Copyright 2008--2009 William Morgan <wmorgan at the masanjin dot nets>.
 | |
| This program is free software: you can redistribute it and/or modify it
 | |
| under the terms of the GNU General Public License as published by the Free
 | |
| Software Foundation, either version 3 of the License, or (at your option)
 | |
| any later version.
 | |
| 
 | |
| This program is distributed in the hope that it will be useful, but WITHOUT
 | |
| ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 | |
| FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
 | |
| more details.
 | |
| 
 | |
| You can find the GNU General Public License at: http://www.gnu.org/licenses/
 | |
| EOS
 | |
| 
 | |
| require 'yaml'
 | |
| CONFIG_FN = ".git-wtfrc"
 | |
| 
 | |
| class Numeric; def pluralize s; "#{to_s} #{s}" + (self != 1 ? "s" : "") end end
 | |
| 
 | |
| if ARGV.delete("--help") || ARGV.delete("-h")
 | |
|   puts USAGE
 | |
|   exit
 | |
| end
 | |
| 
 | |
| ## poor man's trollop
 | |
| $long = ARGV.delete("--long") || ARGV.delete("-l")
 | |
| $short = ARGV.delete("--short") || ARGV.delete("-s")
 | |
| $all = ARGV.delete("--all") || ARGV.delete("-a")
 | |
| $all_commits = ARGV.delete("--all-commits") || ARGV.delete("-A")
 | |
| $dump_config = ARGV.delete("--dump-config")
 | |
| $key = ARGV.delete("--key") || ARGV.delete("-k")
 | |
| $show_relations = ARGV.delete("--relations") || ARGV.delete("-r")
 | |
| ARGV.each { |a| abort "Error: unknown argument #{a}." if a =~ /^--/ }
 | |
| 
 | |
| ## search up the path for a file
 | |
| def find_file fn
 | |
|   while true
 | |
|     return fn if File.exist? fn
 | |
|     fn2 = File.join("..", fn)
 | |
|     return nil if File.expand_path(fn2) == File.expand_path(fn)
 | |
|     fn = fn2
 | |
|   end
 | |
| end
 | |
| 
 | |
| want_color = `git config color.wtf`
 | |
| want_color = `git config color.ui` if want_color.empty?
 | |
| $color = case want_color.chomp
 | |
|   when "true"; true
 | |
|   when "auto"; $stdout.tty?
 | |
| end
 | |
| 
 | |
| def red s; $color ? "\033[31m#{s}\033[0m" : s end
 | |
| def green s; $color ? "\033[32m#{s}\033[0m" : s end
 | |
| def yellow s; $color ? "\033[33m#{s}\033[0m" : s end
 | |
| def cyan s; $color ? "\033[36m#{s}\033[0m" : s end
 | |
| def grey s; $color ? "\033[1;30m#{s}\033[0m" : s end
 | |
| def purple s; $color ? "\033[35m#{s}\033[0m" : s end
 | |
| 
 | |
| ## the set of commits in 'to' that aren't in 'from'.
 | |
| ## if empty, 'to' has been merged into 'from'.
 | |
| def commits_between from, to
 | |
|   if $long
 | |
|     `git log --pretty=format:"- %s [#{yellow "%h"}] (#{purple "%ae"}; %ar)" #{from}..#{to}`
 | |
|   else
 | |
|     `git log --pretty=format:"- %s [#{yellow "%h"}]" #{from}..#{to}`
 | |
|   end.split(/[\r\n]+/)
 | |
| end
 | |
| 
 | |
| def show_commits commits, prefix="    "
 | |
|   if commits.empty?
 | |
|     puts "#{prefix} none"
 | |
|   else
 | |
|     max = $all_commits ? commits.size : $config["max_commits"]
 | |
|     max -= 1 if max == commits.size - 1 # never show "and 1 more"
 | |
|     commits[0 ... max].each { |c| puts "#{prefix}#{c}" }
 | |
|     puts grey("#{prefix}... and #{commits.size - max} more (use -A to see all).") if commits.size > max
 | |
|   end
 | |
| end
 | |
| 
 | |
| def ahead_behind_string ahead, behind
 | |
|   [ahead.empty? ? nil : "#{ahead.size.pluralize 'commit'} ahead",
 | |
|    behind.empty? ? nil : "#{behind.size.pluralize 'commit'} behind"].
 | |
|    compact.join("; ")
 | |
| end
 | |
| 
 | |
| def widget merged_in, remote_only=false, local_only=false, local_only_merge=false
 | |
|   left, right = case
 | |
|     when remote_only; %w({ })
 | |
|     when local_only; %w{( )}
 | |
|     else %w([ ])
 | |
|   end
 | |
|   middle = case
 | |
|     when merged_in && local_only_merge; green("~")
 | |
|     when merged_in; green("x")
 | |
|     else " "
 | |
|   end
 | |
|   print left, middle, right
 | |
| end
 | |
| 
 | |
| def show b
 | |
|   have_both = b[:local_branch] && b[:remote_branch]
 | |
| 
 | |
|   pushc, pullc, oosync = if have_both
 | |
|     [x = commits_between(b[:remote_branch], b[:local_branch]),
 | |
|      y = commits_between(b[:local_branch], b[:remote_branch]),
 | |
|      !x.empty? && !y.empty?]
 | |
|   end
 | |
| 
 | |
|   if b[:local_branch]
 | |
|     puts "Local branch: " + green(b[:local_branch].sub(/^heads\//, ""))
 | |
| 
 | |
|     if have_both
 | |
|       if pushc.empty?
 | |
|         puts "#{widget true} in sync with remote"
 | |
|       else
 | |
|         action = oosync ? "push after rebase / merge" : "push"
 | |
|         puts "#{widget false} NOT in sync with remote (you should #{action})"
 | |
|         show_commits pushc unless $short
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   if b[:remote_branch]
 | |
|     puts "Remote branch: #{cyan b[:remote_branch]} (#{b[:remote_url]})"
 | |
| 
 | |
|     if have_both
 | |
|       if pullc.empty?
 | |
|         puts "#{widget true} in sync with local"
 | |
|       else
 | |
|         action = pushc.empty? ? "merge" : "rebase / merge"
 | |
|         puts "#{widget false} NOT in sync with local (you should #{action})"
 | |
|         show_commits pullc unless $short
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   puts "\n#{red "WARNING"}: local and remote branches have diverged. A merge will occur unless you rebase." if oosync
 | |
| end
 | |
| 
 | |
| def show_relations b, all_branches
 | |
|   ibs, fbs = all_branches.partition { |name, br| $config["integration-branches"].include?(br[:local_branch]) || $config["integration-branches"].include?(br[:remote_branch]) }
 | |
|   if $config["integration-branches"].include? b[:local_branch]
 | |
|     puts "\nFeature branches:" unless fbs.empty?
 | |
|     fbs.each do |name, br|
 | |
|       next if $config["ignore"].member?(br[:local_branch]) || $config["ignore"].member?(br[:remote_branch])
 | |
|       next if br[:ignore]
 | |
|       local_only = br[:remote_branch].nil?
 | |
|       remote_only = br[:local_branch].nil?
 | |
|       name = if local_only
 | |
|         purple br[:name]
 | |
|       elsif remote_only
 | |
|         cyan br[:name]
 | |
|       else
 | |
|         green br[:name]
 | |
|       end
 | |
| 
 | |
|       ## for remote_only branches, we'll compute wrt the remote branch head. otherwise, we'll
 | |
|       ## use the local branch head.
 | |
|       head = remote_only ? br[:remote_branch] : br[:local_branch]
 | |
| 
 | |
|       remote_ahead = b[:remote_branch] ? commits_between(b[:remote_branch], head) : []
 | |
|       local_ahead = b[:local_branch] ? commits_between(b[:local_branch], head) : []
 | |
| 
 | |
|       if local_ahead.empty? && remote_ahead.empty?
 | |
|         puts "#{widget true, remote_only, local_only} #{name} #{local_only ? "(local-only) " : ""}is merged in"
 | |
|       elsif local_ahead.empty?
 | |
|         puts "#{widget true, remote_only, local_only, true} #{name} merged in (only locally)"
 | |
|       else
 | |
|         behind = commits_between head, (br[:local_branch] || br[:remote_branch])
 | |
|         ahead = remote_only ? remote_ahead : local_ahead
 | |
|         puts "#{widget false, remote_only, local_only} #{name} #{local_only ? "(local-only) " : ""}is NOT merged in (#{ahead_behind_string ahead, behind})"
 | |
|         show_commits ahead unless $short
 | |
|       end
 | |
|     end
 | |
|   else
 | |
|     puts "\nIntegration branches:" unless ibs.empty? # unlikely
 | |
|     ibs.sort_by { |v, br| v }.each do |v, br|
 | |
|       next if $config["ignore"].member?(br[:local_branch]) || $config["ignore"].member?(br[:remote_branch])
 | |
|       next if br[:ignore]
 | |
|       local_only = br[:remote_branch].nil?
 | |
|       remote_only = br[:local_branch].nil?
 | |
|       name = remote_only ? cyan(br[:name]) : green(br[:name])
 | |
| 
 | |
|       ahead = commits_between v, (b[:local_branch] || b[:remote_branch])
 | |
|       if ahead.empty?
 | |
|         puts "#{widget true, local_only} merged into #{name}"
 | |
|       else
 | |
|         #behind = commits_between b[:local_branch], v
 | |
|         puts "#{widget false, local_only} NOT merged into #{name} (#{ahead.size.pluralize 'commit'} ahead)"
 | |
|         show_commits ahead unless $short
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| end
 | |
| 
 | |
| #### EXECUTION STARTS HERE ####
 | |
| 
 | |
| ## find config file and load it
 | |
| $config = { "integration-branches" => %w(heads/master heads/next heads/edge), "ignore" => [], "max_commits" => 5 }.merge begin
 | |
|   fn = find_file CONFIG_FN
 | |
|   if fn && (h = YAML::load_file(fn)) # yaml turns empty files into false
 | |
|     h["integration-branches"] ||= h["versions"] # support old nomenclature
 | |
|     h
 | |
|   else
 | |
|     {}
 | |
|   end
 | |
| end
 | |
| 
 | |
| if $dump_config
 | |
|   puts $config.to_yaml
 | |
|   exit
 | |
| end
 | |
| 
 | |
| ## first, index registered remotes
 | |
| remotes = `git config --get-regexp ^remote\.\*\.url`.split(/[\r\n]+/).inject({}) do |hash, l|
 | |
|   l =~ /^remote\.(.+?)\.url (.+)$/ or next hash
 | |
|   hash[$1] ||= $2
 | |
|   hash
 | |
| end
 | |
| 
 | |
| ## next, index followed branches
 | |
| branches = `git config --get-regexp ^branch\.`.split(/[\r\n]+/).inject({}) do |hash, l|
 | |
|   case l
 | |
|   when /branch\.(.*?)\.remote (.+)/
 | |
|     name, remote = $1, $2
 | |
| 
 | |
|     hash[name] ||= {}
 | |
|     hash[name].merge! :remote => remote, :remote_url => remotes[remote]
 | |
|   when /branch\.(.*?)\.merge ((refs\/)?heads\/)?(.+)/
 | |
|     name, remote_branch = $1, $4
 | |
|     hash[name] ||= {}
 | |
|     hash[name].merge! :remote_mergepoint => remote_branch
 | |
|   end
 | |
|   hash
 | |
| end
 | |
| 
 | |
| ## finally, index all branches
 | |
| remote_branches = {}
 | |
| `git show-ref`.split(/[\r\n]+/).each do |l|
 | |
|   sha1, ref = l.chomp.split " refs/"
 | |
| 
 | |
|   if ref =~ /^heads\/(.+)$/ # local branch
 | |
|     name = $1
 | |
|     next if name == "HEAD"
 | |
|     branches[name] ||= {}
 | |
|     branches[name].merge! :name => name, :local_branch => ref
 | |
|   elsif ref =~ /^remotes\/(.+?)\/(.+)$/ # remote branch
 | |
|     remote, name = $1, $2
 | |
|     remote_branches["#{remote}/#{name}"] = true
 | |
|     next if name == "HEAD"
 | |
|     ignore = !($all || remote == "origin")
 | |
| 
 | |
|     branch = name
 | |
|     if branches[name] && branches[name][:remote] == remote
 | |
|       # nothing
 | |
|     else
 | |
|       name = "#{remote}/#{branch}"
 | |
|     end
 | |
| 
 | |
|     branches[name] ||= {}
 | |
|     branches[name].merge! :name => name, :remote => remote, :remote_branch => "#{remote}/#{branch}", :remote_url => remotes[remote], :ignore => ignore
 | |
|   end
 | |
| end
 | |
| 
 | |
| ## assemble remotes
 | |
| branches.each do |k, b|
 | |
|   next unless b[:remote] && b[:remote_mergepoint]
 | |
|   b[:remote_branch] = if b[:remote] == "."
 | |
|     b[:remote_mergepoint]
 | |
|   else
 | |
|     t = "#{b[:remote]}/#{b[:remote_mergepoint]}"
 | |
|     remote_branches[t] && t # only if it's still alive
 | |
|   end
 | |
| end
 | |
| 
 | |
| show_dirty = ARGV.empty?
 | |
| targets = if ARGV.empty?
 | |
|   [`git symbolic-ref HEAD`.chomp.sub(/^refs\/heads\//, "")]
 | |
| else
 | |
|   ARGV.map { |x| x.sub(/^heads\//, "") }
 | |
| end.map { |t| branches[t] or abort "Error: can't find branch #{t.inspect}." }
 | |
| 
 | |
| targets.each do |t|
 | |
|   show t
 | |
|   show_relations t, branches if $show_relations || t[:remote_branch].nil?
 | |
| end
 | |
| 
 | |
| modified = show_dirty && `git ls-files -m` != ""
 | |
| uncommitted = show_dirty &&  `git diff-index --cached HEAD` != ""
 | |
| 
 | |
| if $key
 | |
|   puts
 | |
|   puts KEY
 | |
| end
 | |
| 
 | |
| puts if modified || uncommitted
 | |
| puts "#{red "NOTE"}: working directory contains modified files." if modified
 | |
| puts "#{red "NOTE"}: staging area contains staged but uncommitted files." if uncommitted
 | |
| 
 | |
| # the end! |