Permalink
Browse files

git wtf

  • Loading branch information...
1 parent 2cdc1db commit ccfd4bda35e9af4adf0ab88e72a69d82f3515a43 @lukaszx0 committed Nov 28, 2011
Showing with 364 additions and 0 deletions.
  1. +364 −0 bin/git-wtf
View
@@ -0,0 +1,364 @@
+#!/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!

0 comments on commit ccfd4bd

Please sign in to comment.