#!/usr/bin/env ruby
#coding: utf-8
require 'pathname'
require 'shellwords'
require 'fileutils'
require 'rake/clean'
begin
require 'readline'
include Readline
rescue LoadError
# Fall back to a plain prompt
def readline( text )
$stderr.print( text.chomp )
return $stdin.gets
end
end
class Hoe
### Prompting, command-execution, and other utility functions
module RakeHelpers
# The editor to invoke if ENV['EDITOR'] and ENV['VISUAL'] aren't set.
DEFAULT_EDITOR = 'vi'
# Set some ANSI escape code constants (Shamelessly stolen from Perl's
# Term::ANSIColor by Russ Allbery <rra@stanford.edu> and Zenin <zenin@best.com>
ANSI_ATTRIBUTES = {
'clear' => 0,
'reset' => 0,
'bold' => 1,
'dark' => 2,
'underline' => 4,
'underscore' => 4,
'blink' => 5,
'reverse' => 7,
'concealed' => 8,
'black' => 30, 'on_black' => 40,
'red' => 31, 'on_red' => 41,
'green' => 32, 'on_green' => 42,
'yellow' => 33, 'on_yellow' => 43,
'blue' => 34, 'on_blue' => 44,
'magenta' => 35, 'on_magenta' => 45,
'cyan' => 36, 'on_cyan' => 46,
'white' => 37, 'on_white' => 47
}
# Prompt for multiline input
MULTILINE_PROMPT = <<-'EOF'
Enter one or more values for '%s'.
A blank line finishes input.
EOF
# ANSI escapes for clearing to the end of the line and the entire line
CLEAR_TO_EOL = "\e[K"
CLEAR_CURRENT_LINE = "\e[2K"
###############
module_function
###############
### Create a string that contains the ANSI codes specified and return it
def ansi_code( *attributes )
attributes.flatten!
attributes.collect! {|at| at.to_s }
# $stderr.puts "Returning ansicode for TERM = %p: %p" %
# [ ENV['TERM'], attributes ]
return '' unless /(?:vt10[03]|xterm(?:-color)?|linux|screen)/i =~ ENV['TERM']
attributes = ANSI_ATTRIBUTES.values_at( *attributes ).compact.join(';')
# $stderr.puts " attr is: %p" % [attributes]
if attributes.empty?
return ''
else
return "\e[%sm" % attributes
end
end
### Colorize the given +string+ with the specified +attributes+ and return it, handling
### line-endings, color reset, etc.
def colorize( *args )
string = ''
if block_given?
string = yield
else
string = args.shift
end
ending = string[/(\s)$/] || ''
string = string.rstrip
return ansi_code( args.flatten ) + string + ansi_code( 'reset' ) + ending
end
### Output the specified <tt>msg</tt> as an ANSI-colored error message
### (white on red).
def error_message( msg, details='' )
$stderr.puts colorize( 'bold', 'white', 'on_red' ) { msg } + details
end
alias :error :error_message
### Make a prompt string that will always appear flush left.
def make_prompt_string( string )
return CLEAR_CURRENT_LINE + colorize( 'bold', 'green' ) { string + ' ' }
end
### Output the specified <tt>prompt_string</tt> as a prompt (in green) and
### return the user's input with leading and trailing spaces removed. If a
### test is provided, the prompt will repeat until the test returns true.
### An optional failure message can also be passed in.
def prompt( prompt_string, failure_msg="Try again." ) # :yields: response
prompt_string.chomp!
prompt_string << ":" unless /\W$/.match( prompt_string )
response = nil
begin
prompt = make_prompt_string( prompt_string )
response = readline( prompt ) || ''
response.strip!
if block_given? && ! yield( response )
error_message( failure_msg + "\n\n" )
response = nil
end
end while response.nil?
return response
end
### Prompt the user with the given <tt>prompt_string</tt> via #prompt,
### substituting the given <tt>default</tt> if the user doesn't input
### anything. If a test is provided, the prompt will repeat until the test
### returns true. An optional failure message can also be passed in.
def prompt_with_default( prompt_string, default, failure_msg="Try again." )
response = nil
begin
default ||= '~'
response = prompt( "%s [%s]" % [ prompt_string, default ] )
response = default.to_s if !response.nil? && response.empty?
trace "Validating response %p" % [ response ]
# the block is a validator. We need to make sure that the user didn't
# enter '~', because if they did, it's nil and we should move on. If
# they didn't, then call the block.
if block_given? && response != '~' && ! yield( response )
error_message( failure_msg + "\n\n" )
response = nil
end
end while response.nil?
return nil if response == '~'
return response
end
### Prompt for an array of values
def prompt_for_multiple_values( label, default=nil )
$stderr.puts( MULTILINE_PROMPT % [label] )
if default
$stderr.puts "Enter a single blank line to keep the default:\n %p" % [ default ]
end
results = []
result = nil
begin
result = readline( make_prompt_string("> ") )
if result.nil? || result.empty?
results << default if default && results.empty?
else
results << result
end
end until result.nil? || result.empty?
return results.flatten
end
### Display a description of a potentially-dangerous task, and prompt
### for confirmation. If the user answers with anything that begins
### with 'y', yield to the block. If +abort_on_decline+ is +true+,
### any non-'y' answer will fail with an error message.
def ask_for_confirmation( description, abort_on_decline=true )
prompt = 'Continue?'
# If the description looks like a question, use it for the prompt. Otherwise,
# print it out and
if description.strip.rindex( '?' )
prompt = description
else
log description
end
answer = prompt_with_default( prompt, 'n' ) do |input|
input =~ /^[yn]/i
end
if answer =~ /^y/i
return yield
elsif abort_on_decline
error "Aborted."
fail
end
return false
end
alias :prompt_for_confirmation :ask_for_confirmation
### Output a logging message
def log( *msg )
output = colorize( msg.flatten.join(' '), 'cyan' )
$stderr.puts( output )
end
### Output a logging message if tracing is on
def trace( *msg )
return unless Rake.application.options.trace
output = colorize( msg.flatten.join(' '), 'yellow' )
$stderr.puts( output )
end
### Return the specified args as a string, quoting any that have a space.
def quotelist( *args )
return args.flatten.collect {|part| part =~ /\s/ ? part.inspect : part}
end
### Run the specified command +cmd+ with system(), failing if the execution
### fails. Doesn't invoke a subshell (unlike 'sh').
def run( *cmd )
cmd.flatten!
if cmd.length > 1
trace( "Running:", quotelist(*cmd) )
else
trace( "Running:", cmd )
end
if Rake.application.options.dryrun
log "(dry run mode)"
else
system( *cmd )
unless $?.success?
fail "Command failed: [%s]" % [cmd.join(' ')]
end
end
end
### Invoke the user's editor on the given +filename+ and return the exit code
### from doing so.
def edit( filename )
editor = ENV['EDITOR'] || ENV['VISUAL'] || DEFAULT_EDITOR
system editor, filename
unless $?.success? || editor =~ /vim/i
fail "Editor exited uncleanly."
end
end
### Run the given +cmd+ with the specified +args+ without interpolation by the shell and
### return anything written to its STDOUT.
def read_command_output( cmd, *args )
# output = IO.read( '|-' ) or exec cmd, *args # No popen on some platforms. :(
argstr = Shellwords.join( args )
output = `#{cmd} #{argstr}`.chomp
return output
end
### Extract all the non Rake-target arguments from ARGV and return them.
def get_target_args
args = ARGV.reject {|arg| arg =~ /^-/ || Rake::Task.task_defined?(arg) }
return args
end
### Returns a human-scannable file list by joining and truncating the list if it's too long.
def humanize_file_list( list, indent=FILE_INDENT )
listtext = list[0..5].join( "\n#{indent}" )
if list.length > 5
listtext << " (and %d other/s)" % [ list.length - 5 ]
end
return listtext
end
end # module RakeHelpers
### Mercurial command wrapper functions.
module MercurialHelpers
include FileUtils,
Hoe::RakeHelpers
include FileUtils::DryRun if Rake.application.options.dryrun
# The name of the ignore file
IGNORE_FILE = Pathname( '.hgignore' )
### Generate a commit log from a diff and return it as a String. At the moment it just
### returns the diff as-is, but will (someday) do something better.
def make_commit_log
diff = read_command_output( 'hg', 'diff' )
fail "No differences." if diff.empty?
return diff
end
### Generate a commit log and invoke the user's editor on it.
def edit_commit_log( logfile )
diff = make_commit_log()
File.open( logfile, 'w' ) do |fh|
fh.print( diff )
end
edit( logfile )
end
### Generate a changelog.
def make_changelog
log = read_command_output( 'hg', 'log', '--style', 'changelog' )
return log
end
def get_manifest
raw = read_command_output( 'hg', 'manifest' )
return raw.split( $/ )
end
### Get the 'tip' info and return it as a Hash
def get_tip_info
data = read_command_output( 'hg', 'tip' )
return YAML.load( data )
end
### Return the ID for the current rev
def get_current_rev
id = read_command_output( 'hg', '-q', 'identify' )
return id.chomp
end
### Return the current numeric (local) rev number
def get_numeric_rev
id = read_command_output( 'hg', '-q', 'identify', '-n' )
return id.chomp[ /^(\d+)/, 1 ] || '0'
end
### Read the list of existing tags and return them as an Array
def get_tags
taglist = read_command_output( 'hg', 'tags' )
return taglist.split( /\n/ ).collect {|tag| tag[/^\S+/] }
end
### Read any remote repo paths known by the current repo and return them as a hash.
def get_repo_paths
paths = {}
pathspec = read_command_output( 'hg', 'paths' )
pathspec.split.each_slice( 3 ) do |name, _, url|
paths[ name ] = url
end
return paths
end
### Return the list of files which are not of status 'clean'
def get_uncommitted_files
list = read_command_output( 'hg', 'status', '-n', '--color', 'never' )
list = list.split( /\n/ )
trace "Changed files: %p" % [ list ]
return list
end
### Return the list of files which are of status 'unknown'
def get_unknown_files
list = read_command_output( 'hg', 'status', '-un', '--color', 'never' )
list = list.split( /\n/ )
trace "New files: %p" % [ list ]
return list
end
### Add the list of +pathnames+ to the .hgignore list.
def hg_ignore_files( *pathnames )
patterns = pathnames.flatten.collect do |path|
'^' + Regexp.escape(path) + '$'
end
trace "Ignoring %d files." % [ pathnames.length ]
IGNORE_FILE.open( File::CREAT|File::WRONLY|File::APPEND, 0644 ) do |fh|
fh.puts( patterns )
end
end
### Delete the files in the given +filelist+ after confirming with the user.
def delete_extra_files( filelist )
description = humanize_file_list( filelist, ' ' )
log "Files to delete:\n ", description
ask_for_confirmation( "Really delete them?", false ) do
filelist.each do |f|
rm_rf( f, :verbose => true )
end
end
end
end # module MercurialHelpers
### Hoe plugin module
module Mercurial
include Hoe::RakeHelpers,
Hoe::MercurialHelpers
VERSION = '1.4.1'
# The name of the file to edit for the commit message
COMMIT_MSG_FILE = 'commit-msg.txt'
attr_accessor :hg_release_tag_prefix
attr_accessor :hg_sign_tags
attr_accessor :check_history_on_release
### Set up defaults
def initialize_mercurial
# Follow semantic versioning tagging specification (http://semver.org/)
self.hg_release_tag_prefix = "v"
self.hg_sign_tags = false
self.check_history_on_release = false
minor_version = VERSION[ /^\d+\.\d+/ ]
self.extra_dev_deps << ['hoe-mercurial', "~> #{minor_version}"] unless
self.name == 'hoe-mercurial'
end
### Read the list of tags and return any that don't have a corresponding section
### in the history file.
def get_unhistoried_version_tags( include_pkg_version=true )
prefix = self.hg_release_tag_prefix
tag_pattern = /#{prefix}\d+(\.\d+)+/
release_tags = get_tags().grep( /^#{tag_pattern}$/ )
release_tags.unshift( "#{prefix}#{version}" ) if include_pkg_version
IO.readlines( self.history_file ).each do |line|
if line =~ /^(?:h\d\.|#+|=+)\s+(#{tag_pattern})\s+/
trace " found an entry for tag %p: %p" % [ $1, line ]
release_tags.delete( $1 )
else
trace " no tag on line %p" % [ line ]
end
end
return release_tags
end
### Hoe hook -- Define Rake tasks when the plugin is loaded.
def define_mercurial_tasks
return unless File.exist?( ".hg" ) &&
!Rake::Task.task_defined?( 'hg:checkin' )
file COMMIT_MSG_FILE do |task|
edit_commit_log( task.name )
end
namespace :hg do
desc "Prepare for a new release"
task :prep_release do
uncommitted_files = get_uncommitted_files()
unless uncommitted_files.empty?
log "Uncommitted files:\n",
*uncommitted_files.map {|fn| " #{fn}\n" }
ask_for_confirmation( "\nRelease anyway?", true ) do
log "Okay, releasing with uncommitted versions."
end
end
tags = get_tags()
rev = get_current_rev()
pkg_version_tag = "#{hg_release_tag_prefix}#{version}"
# Look for a tag for the current release version, and if it exists abort
if tags.include?( pkg_version_tag )
error "Version #{version} already has a tag."
fail
end
# Ensure that the History file contains an entry for every release
Rake::Task[ 'check_history' ].invoke if self.check_history_on_release
# Sign the current rev
if self.hg_sign_tags
log "Signing rev #{rev}"
run 'hg', 'sign'
end
# Tag the current rev
log "Tagging rev #{rev} as #{pkg_version_tag}"
run 'hg', 'tag', pkg_version_tag
# Offer to push
Rake::Task['hg:push'].invoke
end
desc "Check for new files and offer to add/ignore/delete them."
task :newfiles do
log "Checking for new files..."
entries = get_unknown_files()
unless entries.empty?
files_to_add = []
files_to_ignore = []
files_to_delete = []
entries.each do |entry|
action = prompt_with_default( " #{entry}: (a)dd, (i)gnore, (s)kip (d)elete", 's' )
case action
when 'a'
files_to_add << entry
when 'i'
files_to_ignore << entry
when 'd'
files_to_delete << entry
end
end
unless files_to_add.empty?
run 'hg', 'add', *files_to_add
end
unless files_to_ignore.empty?
hg_ignore_files( *files_to_ignore )
end
unless files_to_delete.empty?
delete_extra_files( files_to_delete )
end
end
end
task :add => :newfiles
desc "Pull and update from the default repo"
task :pull do
paths = get_repo_paths()
if origin_url = paths['default']
ask_for_confirmation( "Pull and update from '#{origin_url}'?", false ) do
Rake::Task['hg:pull_without_confirmation'].invoke
end
else
trace "Skipping pull: No 'default' path."
end
end
desc "Pull and update without confirmation"
task :pull_without_confirmation do
run 'hg', 'pull', '-u'
end
desc "Update to tip"
task :update do
run 'hg', 'update'
end
desc "Clobber all changes (hg up -C)"
task :update_and_clobber do
run 'hg', 'update', '-C'
end
task :precheckin do
trace "Pre-checkin hooks"
end
desc "Check the current code in if tests pass"
task :checkin => [:pull, :newfiles, :precheckin, COMMIT_MSG_FILE] do
targets = get_target_args()
$stderr.puts '---', File.read( COMMIT_MSG_FILE ), '---'
ask_for_confirmation( "Continue with checkin?" ) do
run 'hg', 'ci', '-l', COMMIT_MSG_FILE, targets
rm_f COMMIT_MSG_FILE
end
Rake::Task['hg:push'].invoke
end
task :commit => :checkin
task :ci => :checkin
CLEAN.include( COMMIT_MSG_FILE )
desc "Push to the default origin repo (if there is one)"
task :push do
paths = get_repo_paths()
if origin_url = paths['default']
ask_for_confirmation( "Push to '#{origin_url}'?", false ) do
Rake::Task['hg:push_without_confirmation'].invoke
end
else
trace "Skipping push: No 'default' path."
end
end
desc "Push to the default repo without confirmation"
task :push_without_confirmation do
run 'hg', 'push'
end
end
# Add a top-level 'ci' task for checkin
desc "Check in your changes"
task :ci => 'hg:checkin'
# Hook the release task and prep the repo first
task :prerelease => 'hg:prep_release'
desc "Check the history file to ensure it contains an entry for each release tag"
task :check_history do
log "Checking history..."
missing_tags = get_unhistoried_version_tags()
unless missing_tags.empty?
abort "%s needs updating; missing entries for tags: %p" %
[ self.history_file, missing_tags ]
end
end
rescue ::Exception => err
$stderr.puts "%s while defining Mercurial tasks: %s" % [ err.class.name, err.message ]
raise
end
end
end