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

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