Created
April 17, 2013 23:55
-
-
Save fendent/5408749 to your computer and use it in GitHub Desktop.
VIM bundle manager
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# Reads bundles to be installed from the .vimrc file then synchronizes | |
# .vim/bundles by downloading new repositories as needed. It also removes | |
# bundles that are no longer used. | |
# This software is covered by the MIT License. | |
require 'fileutils' | |
require 'open-uri' | |
Version = '0.82+' | |
def ensure_dir dir | |
Dir.mkdir dir unless test ?d, dir | |
end | |
def download_file url, file | |
open(url) do |r| | |
File.open(file, 'w') do |w| | |
w.write(r.read) | |
end | |
end | |
end | |
def run *cmd | |
# Runs cmd, returns its stdout, and bails on error. | |
# Mostly a backport of Ruby 1.9's IO.popen for 1.8. | |
options = { :acceptable_exit_codes => [0], :stderr => nil } | |
options.merge!(cmd.pop) if cmd.last.kind_of?(Hash) | |
puts "-> #{[cmd].join(" ")}" if $verbose | |
outr, outw = IO::pipe | |
pid = fork { | |
outr.close; STDOUT.reopen outw; outw.close | |
STDERR.reopen '/dev/null', 'w' if options[:stderr] == :suppress | |
STDERR.reopen STDOUT if options[:stderr] == :merge | |
exec *cmd.flatten.map { |c| c.to_s } | |
} | |
outw.close | |
result = outr.read | |
outr.close | |
Process.waitpid pid | |
if options[:acceptable_exit_codes] != :any && !options[:acceptable_exit_codes].include?($?.exitstatus) | |
raise "'#{[cmd].join(" ")}' in #{Dir.pwd} exited with code #{$?.exitstatus}" | |
end | |
puts "RESULT #{$?.exitstatus}: <<#{result}>>" if $verbose && $verbose.to_i >= 2 | |
result | |
end | |
def git *cmd | |
if !$verbose && %w{checkout clone fetch pull}.include?(cmd.first.to_s) | |
cmd.insert 1, '-q' | |
end | |
run :git, *cmd | |
end | |
def describe_head | |
# Don't want to use 'git describe --all' because branch names change too often. | |
# This will fail if there's a directory in .vim/bundle that isn't git-revisioned. | |
version = git(:describe, '--tags', :acceptable_exit_codes => :any, :stderr => :suppress).chomp | |
version = git('rev-parse', 'HEAD', :acceptable_exit_codes => :any, :stderr => :suppress)[0..12] unless version =~ /\S/ | |
version | |
end | |
def current_date | |
# Ruby's Time.now.to_s just doesn't produce very good output | |
$current_date ||= run(:date).chomp | |
end | |
def print_doc_header doc | |
doc.printf "%-34s %s\n\n\n", "*bundles* *bundles.txt*", "Installed Bundles" | |
doc.puts "Lists the currently installed bundles. Also see the |bundle-log|." | |
doc.puts "Last updated by vim-update-bundles on #{current_date}.\n\n" | |
doc.printf " %-32s %-22s %s\n", "PLUGIN", "VERSION", "RELEASE DATE" | |
doc.puts "-" * 72 | |
end | |
def print_doc_entry dir, doc | |
version = describe_head | |
date = git(:log, '-1', '--pretty=format:%ai').chomp | |
doc.printf " %-32s %-22s %s\n", "|#{dir}|", version, date.split(' ').first | |
end | |
def print_log_header log | |
log.printf "%-34s %s\n\n\n", "*bundle-log.txt*", "Bundle Install Log" | |
log.puts "Logs bundle install activity. Also see the list of installed |bundles|.\n\n" | |
end | |
def print_log_entry log, action, dir, rev, notes="" | |
message = " %-3s %-26s %-18s %s" % [action, "|#{dir}|", rev, notes] | |
log.puts message.sub /\s+$/, '' | |
end | |
def log_error log, message | |
log.print " #{message}\n\n" # puts suppresses trailing newline | |
STDERR.puts message | |
end | |
def ignore_doc_tags | |
exclude = File.read ".git/info/exclude" | |
if exclude !~ /doc\/tags/ | |
File.open(".git/info/exclude", "w") { |f| | |
f.write exclude.chomp + "\ndoc/tags\n" | |
} | |
end | |
end | |
# Work around Ruby's useless "conflicting chdir during another chdir block" warning | |
# A better_chdir block can contain a Dir.chdir block, | |
# but a Dir.chdir block can't contain better_chdir. | |
def better_chdir dir | |
orig = Dir.pwd | |
begin | |
Dir.chdir dir | |
yield | |
ensure | |
# If the last bundle is removed, git will remove ~/.vim/bundle too. | |
ensure_dir orig | |
Dir.chdir orig | |
end | |
end | |
# If running in a submodule environment, chdirs to the submodule root | |
# and then calls the block with inpath converted to be relative to the | |
# subodule root. If not running with submodules, returns false. | |
def in_submodule_root inpath=nil | |
path = File.join Dir.pwd, inpath if inpath | |
parent = git 'rev-parse', '--show-cdup', :acceptable_exit_codes => :any, :stderr => :suppress | |
if $?.exitstatus == 0 | |
better_chdir("./" + parent.chomp) do | |
path.sub! /^#{Dir.pwd}\/?/, '' if path | |
yield path | |
end | |
return true | |
end | |
return false | |
end | |
def clone_bundle config, dir, url, tagstr, log | |
if in_submodule_root(dir) { |mod| | |
puts "adding submodule #{dir} from #{url}#{tagstr}" | |
git :submodule, :add, url, mod | |
} | |
else | |
puts "cloning #{dir} from #{url}#{tagstr}" | |
git :clone, url, dir | |
end | |
Dir.chdir(dir) { print_log_entry log, 'Add', dir, describe_head, "#{url}#{tagstr}" } | |
$bundles_added += 1 | |
end | |
def remove_bundle_to config, dir, destination | |
puts "Removing #{dir}, find it in #{destination}" | |
FileUtils.mv dir, destination | |
in_submodule_root(dir) do |mod| | |
git :rm, mod | |
fn = nil | |
['.gitmodules', '.git/config'].each do |filename| | |
begin | |
fn = filename | |
text = File.read filename | |
File.open(filename, 'w+') do |file| | |
file.puts text.gsub(/\[submodule "#{mod}"\][^\[]+/m,'') | |
end | |
rescue Exception => e | |
raise "could not delete #{dir} from #{fn}: #{e}" | |
end | |
end | |
end | |
end | |
def remove_bundle config, dir, log | |
Dir.chdir(dir) { print_log_entry log, 'Del', dir, describe_head } | |
trash_dir = "#{config[:vimdir_path]}/Trashed-Bundles" | |
ensure_dir trash_dir | |
suffixes = [''] + (1..99).map { |i| "-#{"%02d" % i}" } | |
suffixes.each do |suffix| | |
destination = "#{trash_dir}/#{dir}#{suffix}" | |
unless test ?d, destination | |
remove_bundle_to config, dir, destination | |
$bundles_removed += 1 | |
return | |
end | |
end | |
raise "unable to remove #{dir}, please delete #{trash_dir}" | |
end | |
def reset_bundle config, dir, url, tagstr, log | |
remove_bundle config, dir, log | |
ensure_dir "#{config[:vimdir_path]}/bundle" | |
clone_bundle config, dir, url, tagstr, log | |
end | |
def pull_bundle dir, tag, log | |
git :fetch, :origin, :stderr => :suppress # git prints some needless warnings during fetch | |
git :checkout, tag || :master | |
# if it's a branch, we need to merge in upstream changes | |
if system 'git symbolic-ref HEAD -q >/dev/null' | |
output = git(:merge, '--ff-only', "origin/#{tag || :master}", :acceptable_exit_codes => :any, :stderr => :merge) | |
unless $?.success? | |
log_error log, output.gsub(/\s+/, ' ') | |
return false # bundle is not OK and needs to be reset | |
end | |
end | |
return true # bundle is good, let's continue | |
end | |
def install_bundle config, dir, url, tag, doc, log | |
tagstr = " at #{tag}" if tag | |
previous_version = nil | |
only_updating = false | |
if url.match /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/ # User/repository. | |
url = "https://github.com/#{url}.git" | |
end | |
if url.match /^[A-Za-z0-9._-]+$/ # Plain repository. | |
url = "https://github.com/vim-scripts/#{url}.git" | |
end | |
# fetch bundle | |
if test ?d, dir | |
remote = Dir.chdir(dir) { git(:config, '--get', 'remote.origin.url').chomp } | |
if remote == url | |
only_updating = true | |
unless config[:no_updates] | |
Dir.chdir(dir) { previous_version = describe_head } | |
puts "updating #{dir} from #{url}#{tagstr}" | |
end | |
else | |
log_error log, "bundle for #{dir} changed from #{remote} to #{url}" | |
reset_bundle config, dir, url, tagstr, log | |
end | |
else | |
clone_bundle config, dir, url, tagstr, log | |
end | |
# pull bundle | |
unless only_updating && config[:no_updates] | |
unless Dir.chdir(dir) { pull_bundle dir, tag, log } | |
reset_bundle config, dir, url, tagstr, log | |
end | |
Dir.chdir(dir) do | |
ignore_doc_tags | |
if previous_version | |
new_version = describe_head | |
if new_version != previous_version | |
print_log_entry log, 'up', dir, "#{new_version}#{tagstr}", "<- #{previous_version}" | |
$bundles_updated += 1 if only_updating | |
end | |
end | |
end | |
end | |
Dir.chdir(dir) { print_doc_entry dir, doc } | |
in_submodule_root(dir) { |mod| git :add, mod } | |
end | |
def read_vimrc config | |
File.open(config[:vimrc_path]) do |file| | |
file.each_line { |line| yield line } | |
end | |
end | |
class BundleCommandError < RuntimeError | |
def exit_code; 47; end | |
end | |
def run_bundle_command dir, cmd | |
puts "BundleCommand: #{cmd}" | |
status = Dir.chdir(dir) { system(cmd); $? } | |
unless status.success? | |
raise BundleCommandError.new("BundleCommand #{cmd} in #{Dir.pwd} failed!") | |
end | |
end | |
def vim_string str | |
# quick & dirty, parse a single or double quoted string the way Vim would | |
if str.slice(0,1) == "'" | |
str =~ /^\s*'(.*)'\s*$/ | |
return $1.gsub "''", "'" | |
elsif str.slice(0,1) == '"' | |
str =~ /^\s*"(.*)"\s*$/ | |
return $1 # could do escape substitution here | |
else | |
return str | |
end | |
end | |
def update_bundles config, doc, log | |
existing_bundles = Dir['*'] | |
updated_bundles = {} | |
# Ignore files in the bundle directory, e.g., READMEs. | |
existing_bundles.reject! { |path| FileTest.file? path } | |
puts "# reading vimrc" if config[:verbose] | |
string_re = %q{'([^']+|'')*'|"[^"]*"} # captures single and double quoted Vim strings | |
read_vimrc(config) do |line| | |
if line =~ /^\s*"\s*bundle:\s*(.*)$/i || | |
line =~ /^\s*Bundle\s*(#{string_re})/ | |
url, tag = vim_string($1).split | |
puts "# processing '#{url}' at '#{tag}'" if config[:verbose] | |
dir = url.split('/').last.sub(/\.git$/, '') | |
# quick sanity check | |
raise "duplicate entry for #{url}" if updated_bundles[dir] == url | |
raise "urls map to the same bundle: #{updated_bundles[dir]} and #{url}" if updated_bundles[dir] | |
install_bundle config, dir, url, tag, doc, log | |
updated_bundles[dir] = url | |
existing_bundles.delete dir | |
elsif line =~ /^\s*"\s*bundle[ -]?command:\s*(.*)$/i || | |
line =~ /^\s*BundleCommand\s*(#{string_re})$/ | |
# Want BundleCommand but BUNDLE COMMAND and Bundle-Command used to be legal too | |
run_bundle_command "#{config[:vimdir_path]}/bundle", vim_string($1) | |
elsif line =~ /^\s*"\s*static:\s*(.*)$/i || | |
line =~ /^\s*Bundle!\s*(#{string_re})$/i | |
dir = vim_string $1 | |
puts " leaving #{dir} alone" | |
existing_bundles.delete dir | |
end | |
end | |
existing_bundles.each { |dir| remove_bundle config, dir, log } | |
in_submodule_root do | |
puts " updating submodules" | |
git :submodule, :init | |
git :submodule, :update | |
end | |
end | |
def bundleize count | |
"#{count} bundle#{count != 1 ? 's' : ''}" | |
end | |
def bundle_count_string | |
str = [] | |
str << "#{bundleize $bundles_added} added" if $bundles_added > 0 | |
str << "#{bundleize $bundles_removed} removed" if $bundles_removed > 0 | |
str << "#{bundleize $bundles_updated} updated" if $bundles_updated > 0 | |
return "no updates" if str.empty? | |
str[-1] = "and #{str[-1]}" if str.size > 2 | |
str.join(", ") | |
end | |
def update_bundles_and_docs config | |
ensure_dir "#{config[:vimdir_path]}/doc" | |
bundle_dir = "#{config[:vimdir_path]}/bundle" | |
ensure_dir bundle_dir | |
$bundles_added = $bundles_removed = $bundles_updated = 0 | |
File.open("#{config[:vimdir_path]}/doc/bundles.txt", "w") do |doc| | |
print_doc_header doc | |
logfile = "#{config[:vimdir_path]}/doc/bundle-log.txt" | |
log_already_exists = test ?f, logfile | |
File.open(logfile, 'a') do |log| | |
print_log_header log unless log_already_exists | |
log.puts "#{current_date} by vim-update-bundles #{Version}" | |
begin | |
better_chdir(bundle_dir) { update_bundles config, doc, log } | |
rescue Exception => e | |
message = e.is_a?(Interrupt) ? "Interrupted" : "Aborted: #{e.message}" | |
log_error log, message | |
doc.puts message | |
STDERR.puts e.backtrace if ENV['TRACE'] | |
exit e.respond_to?(:exit_code) ? e.exit_code : 1 | |
end | |
log.puts " " + bundle_count_string | |
log.puts | |
end | |
doc.puts | |
end | |
end | |
def interpolate options, val, message, i | |
raise "Interpolation is now $#{$1} instead of ENV[#{$1}] #{message} #{i}" if val =~ /ENV\[['"]?([^\]]*)['"]?\]/ | |
STDERR.puts "WARNING: putting quotes in a config item is probably a mistake #{message} #{i}" if val =~ /["']/ | |
val.gsub(/\$([A-Za-z0-9_]+)/) { options[$1.to_sym] || ENV[$1] || raise("$#{$1} is not defined #{message} #{i}") } | |
end | |
def process_options options, args, message | |
args.each_with_index do |arg,i| | |
arg = arg.gsub /^\s*-?-?|\s*$/, '' # Leading dashes in front of options are optional. | |
return if arg == '' || arg =~ /^#/ | |
k,v = arg.split /\s*=\s*/, 2 | |
k = options[k.to_sym].to_s while options[k.to_sym].is_a? Symbol # expand 1-letter options, :v -> :verbose | |
k.gsub! '-', '_' # underscorize args, 'no-updates' -> 'no_updates' | |
unless options.has_key? k.to_sym | |
STDERR.puts "Unknown option: #{k.inspect} #{message} #{i}" | |
puts "Usage: #{help}" if args.equal? ARGV | |
exit 1 | |
end | |
v = options[k.to_sym].call(v) if options[k.to_sym].is_a? Proc | |
options[k.to_sym] = v ? interpolate(options,v,message,i).split("'").join("\\'") : 1 + (options[k.to_sym] || 0) | |
end | |
end | |
# Returns the first path that exists or the last one if nothing exists. | |
def choose_path *paths | |
paths.find { |p| test ?e, p } || paths[-1] | |
end | |
def generate_helptags | |
puts "updating helptags..." | |
# Vim on a Mac often exits with 1, even when doing nothing. | |
run :vim, '-e', '-c', 'call pathogen#helptags()', '-c', 'q', :acceptable_exit_codes => [0, 1] unless ENV['TESTING'] | |
end | |
def locate_vim_files config | |
vimdir_guess = choose_path "#{ENV['HOME']}/.dotfiles/vim", "#{ENV['HOME']}/.vim" | |
vimrc_guesses = [] | |
if config[:vimdir_path] | |
vimrc_guesses.push "#{config[:vimdir_path]}/.vimrc", "#{config[:vimdir_path]}/vimrc" | |
end | |
vimrc_guesses.push "#{ENV['HOME']}/.dotfiles/vimrc", "#{ENV['HOME']}/.vimrc" | |
vimrc_guess = choose_path *vimrc_guesses | |
config[:vimdir_path] ||= vimdir_guess | |
config[:vimrc_path] ||= vimrc_guess | |
end | |
def read_configuration config | |
process_options config, ARGV, "in command line argument" | |
locate_vim_files config | |
actual_keys = config.keys.reject { |k| config[k].is_a? Proc or config[k].is_a? Symbol } | |
actual_keys.map { |k| k.to_s }.sort.each do |k| | |
puts "# option #{k} = #{config[k.to_sym].inspect}" | |
end if config[:verbose] | |
end | |
def help | |
<<EOL | |
vim-update-bundles [options...] | |
Updates the installed Vim plugins. | |
-n --no-updates: don't update bundles, only add or delete (faster) | |
-h -? --help: print this message | |
-v --verbose: print what's happening (multiple -v for more verbose) | |
optional configurations: | |
--vimdir-path: path to ~/.vim directory | |
--vimrc-path: path to ~/.vimrc directory | |
EOL | |
end | |
config = { | |
:verbose => nil, # Git commands are quiet by default; set verbose=true to see everything. | |
:no_updates => false, # If true then don't update repos, only add or delete. | |
:help => lambda { |v| puts help; exit }, | |
:version => lambda { |v| puts "vim-update-bundles #{Version}"; exit }, | |
# single-character aliases for command-line options | |
:v => :verbose, :n => :no_updates, | |
:h => :help, :'?' => :help, :V => :version, | |
:vimdir_path => nil, # Full path to ~/.vim | |
:vimrc_path => nil, # Full path to ~/.vimrc | |
# Used when spinning up a new Vim environment. | |
:starter_url => "https://github.com/bronson/dotfiles/raw/master/.vimrc", | |
} | |
unless $load_only # to read the version number | |
read_configuration config | |
$verbose = config[:verbose] | |
# warning for now, remove after a few releases | |
if test ?f, "#{config[:vimdir_path]}/autoload/pathogen.vim" | |
puts "Please remove #{config[:vimdir_path]}/autoload/pathogen.vim." | |
puts "See 'Runtime Path' in the vim-update-bundles README for why." | |
exit 1 | |
end | |
ensure_dir config[:vimdir_path] | |
unless test(?f, config[:vimrc_path]) | |
puts "Downloading starter vimrc..." | |
download_file config[:starter_url], config[:vimrc_path] | |
end | |
update_bundles_and_docs config | |
generate_helptags | |
puts "done! Start Vim and type ':help bundles' to see what has been installed." | |
end | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment