You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
364 lines
12 KiB
364 lines
12 KiB
#!/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! |