#!/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!