# HG changeset patch # User Jan Vrany # Date 1477785252 0 # Node ID 8d2d5dfe94d045bc3692b5ec3c68e24b792227f4 # Parent 070c2f837f5948d398bc2c912af31003405f34ca Refactored SCM support to use `hglib.rb` for performing Mercurial related tasks ...rather than issuing hg commands by hand. `hglib.rb` provides higher-level API for repositories and thus would allow more complex logic (such as using mirrors to fetch base or smarter handling of bookmarks) being written in a more concise way. diff -r 070c2f837f59 -r 8d2d5dfe94d0 rakelib/hglib.rb --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/rakelib/hglib.rb Sat Oct 29 23:54:12 2016 +0000 @@ -0,0 +1,350 @@ +# This file is not a standalone script. It is a kind +# of lightweight Mercurial library used by other scripts. + +require 'uri' +require 'open3' +require 'shellwords' + +# Following hack is to make hglib.rb working wit both jv:scripts and +# Smalltalk/X rakefiles. +begin + require 'rakelib/inifile' +rescue + begin + require 'inifile' + rescue LoadError => ex + $LOGGER.error("Cannot load package 'inifile'") + $LOGGER.error("Run 'gem install inifile' to install it") + exit 1 + end +end + +if not $LOGGER then + if STDOUT.tty? then + require 'logger' + $LOGGER = Logger.new(STDOUT) + $LOGGER.level = Logger::INFO + else + require 'syslog/logger' + $LOGGER = Syslog::Logger.new($0) + end +end + +module HG + @@config = nil + + GLOBAL_OPTIONS= [:'cwd', :'repository', :'noninteractive', :'config', :'debug', :'debugger', + :'encoding',:'encodingmode',:'traceback',:'time', :'profile',:'version', :'help', + :'hidden' ] + # Execute `hg` command with given positional arguments and + # keyword arguments turned into command options. For example, + # + # HG::hg("heads", "default", cwd: '/tmp/testrepo') + # + # will result in executing + # + # hg --cwd '/tmp/testrepo' heads default + # + # In addition if block is passed, then the block is evaluate with + # `hg` command exit status (as Process::Status) and (optionally) + # with contents of `hg` command stdout and stderr. + # If no block is given, an exception is raised when `hg` command + # exit status IS NOT zero. + def self.hg(command, *args, **options, &block) + g_opts = [] + c_opts = [] + options.each do | k , v | + if v != false + o = k.size == 1 ? "-#{k}" : "--#{k}" + if GLOBAL_OPTIONS.include? k then + if v.kind_of?(Array) + v.each do | e | + g_opts << o << (e == true ? '' : e) + end + else + g_opts << o << (v == true ? '' : v) + end + else + if v.kind_of?(Array) + v.each do | e | + c_opts << o << (e == true ? '' : e) + end + else + c_opts << o << (v == true ? '' : v) + end + end + end + end + c_opts.reject! { | e | e.size == 0 } + cmd = ['hg'] + g_opts + [command] + c_opts + args + $LOGGER.debug("executing: #{cmd.shelljoin}") + if block_given? then + stdout, stderr, status = Open3.capture3(*cmd) + case block.arity + when 1 + yield status + when 2 + yield status, stdout + when 3 + yield status, stdout, stderr + else + raise Exception.new("invalid arity of given block") + end + else + if not system(*cmd) then + raise Exception.new("command failed: #{cmd.join(' ')}") + end + end + end + + def self.config() + if @@config == nil + files = Dir.glob('/etc/mercurial/hgrc.d/*.rc') + + [ '/etc/mercurial/hgrc' , + hgrc() ] + @@config = IniFile.new() + files.each do | file | + if File.exist?(file) + $LOGGER.debug("Loading global config from \"#{file}\"") + @@config.merge!(IniFile.new(:filename => file)) + end + end + end + return @@config + end + + def self.hgrc() + return File.expand_path('~/.hgrc') + end + + class Repository + attr_accessor :path, :config + + # Clone a repository from given `uri` to given `directory`. + # Returns an `HG::Repository` instance representing the repository + # clone. + # If `noupdate` is true, working copy is not updated, i.e., will be + # empty. Use this when you're going to issue `update(rev)` shortly after. + # + def self.clone(uri, directory, noupdate: false) + if noupdate then + HG::hg("clone", uri, directory, noupdate: true) + else + HG::hg("clone", uri, directory) + end + return HG::Repository.new(directory) + end + + # Like HG::hg, but passes --cwd @path + def hg(command, *args, **options, &block) + options[:cwd] = @path + HG::hg(command, *args, **options, &block) + end + + def hgrc() + return File.join(@path, '.hg', 'hgrc') + end + + def initialize(directory) + @path = directory + config_file = hgrc() + if File.exist? ( config_file ) + $LOGGER.debug("Loading repository config from \"#{config_file}\"") + @config = HG::config().merge(IniFile.new(:filename => config_file)) + else + @config = HG::config() + end + end + + def log(revset, template = "{node|short}\n") + log = [] + hg("log", rev: revset, template: template) do | status, out | + if status.success? + puts out + log = out.split("\n") + end + end + return log + end + + # Return changeset IDs of all head revisions. + # If `branch` is given, return only heads in given + # branch. + def heads(branch = nil) + if branch then + return log("head() and branch('#{branch}')") + else + return log("head()") + end + end + + # Return a hash "bookmark => revision" of all + # bookmarks. + def bookmarks(branch = nil) + revset = "bookmark()" + revset += " and branch('#{branch}')" if branch + bookmarks = {} + self.log(revset, "{bookmarks}|{node|short}\n").each do | line | + bookmark, changesetid = line.split("|") + bookmarks[bookmark] = changesetid + end + return bookmarks + end + + def pull(remote = 'default', user: nil, pass: nil) + authconf = [] + if pass != nil then + if user == nil then + raise Exception.new("Password given but not username! Use user: named param to specify username.") + end + # If user/password is provided, make sure we don't have + # username in remote URI. Otherwise Mercurial won't use + # password from config! + uri = URI.parse(remote) + uri.user = nil + remote = uri.to_s + authconf << "auth.bb.prefix=#{remote}" + authconf << "auth.bb.username=#{user}" + authconf << "auth.bb.password=#{pass}" + end + hg("pull", remote, config: authconf) do | status | + if not status.success? then + raise Exception.new("Failed to pull from #{remote}") + end + end + end + + def push(remote = 'default', user: nil, pass: nil) + authconf = [] + if pass != nil then + if user == nil then + raise Exception.new("Password given but not username! Use user: named param to specify username.") + end + # If user/password is provided, make sure we don't have + # username in remote URI. Otherwise Mercurial won't use + # password from config! + uri = URI.parse(remote) + uri.user = nil + remote = uri.to_s + authconf << "bb.prefix = #{remote}" + authconf << "bb.username = #{user}" + authconf << "bb.password = #{pass}" + end + hg("pull", remote, config: authconf) do | status | + if status.exitstatus != 0 and status.exitstatus != 1 then + raise Exception.new("Failed to pull from #{remote}") + end + end + end + + # Create a shared clone in given directory, Return a new + # HG::Repository object on the shared clone + def share(dst, rev = nil) + if File.exist? dst then + raise Exception.new("Destination file exists: #{dst}") + end + if rev == nil then + rev = log('.')[0] + end + if not has_revision?(rev) + raise Exception.new("Revision #{rev} does not exist") + end + mkdir_p File.dirname(dst); + HG::hg("share", path, dst, config: 'extensions.share=', noupdate: true, bookmarks: false) + share = Repository.new(dst) + share.update(rev); + return share + end + + # Updates the repository's working copy to given + # revision if given. If not, update to most-recent + # head, as plain + # + # hg update + # + # would do. + def update(rev = nil) + if rev + if not has_revision? rev then + raise Exception.new("Revision #{rev} does not exist") + end + hg("update", rev: rev) + else + hg("update") + end + end + + # Merge given revision. Return true, if the merge was + # successful, false otherwise + def merge(rev) + if not has_revision? rev then + raise Exception.new("Revision #{rev} does not exist") + end + hg("merge", rev) do | status | + return status.success? + end + end + + def commit(message) + user = '' + if not @config['ui'].has_key? 'username' then + user = @config['ui']['username'] + end + hg("commit", message: message, user: user) + end + + def has_revision?(rev) + revs = log(rev) + return revs.size > 0 + end + + # Lookup a repository in given `directory`. If found, + # return it as instance of HG::Repository. If not, + # `nil` is returned. + def self.lookup(directory) + return nil if not File.exist?(directory) + repo_dir = directory + while repo_dir != nil + if HG::repository? repo_dir + return Repository.new(repo_dir) + end + repo_dir_parent = File.dirname(repo_dir) + if repo_dir_parent == repo_dir + repo_dir = nil + else + repo_dir = repo_dir_parent + end + end + end + + # Initializes and empty Mercurial repository in given `directory` + def self.init(directory) + FileUtils.mkdir_p File.dirname(directory) + HG::hg("init", directory) + return Repository.new(directory) + end + end # class Repository + + # Return `true` if given `directory` is a root of mercurial + # repository, `false` otherwise. + def self.repository?(directory) + return File.directory? File.join(directory, '.hg') + end + + # Enumerate all repositories in given `directory` + def self.forest(directory, &block) + if repository? directory + yield Repository.new(directory) + end + Dir.foreach(directory) do |x| + path = File.join(directory, x) + if File.directory? path + if x == "." or x == ".." or x == ".svn" or x == '.git' + next + elsif File.directory?(path) + forest(path, &block) + end + end + end + end +end # module HG diff -r 070c2f837f59 -r 8d2d5dfe94d0 rakelib/inifile.rb --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/rakelib/inifile.rb Sat Oct 29 23:54:12 2016 +0000 @@ -0,0 +1,628 @@ +#encoding: UTF-8 + +# This class represents the INI file and can be used to parse, modify, +# and write INI files. +class IniFile + include Enumerable + + class Error < StandardError; end + VERSION = '3.0.0' + + # Public: Open an INI file and load the contents. + # + # filename - The name of the file as a String + # opts - The Hash of options (default: {}) + # :comment - String containing the comment character(s) + # :parameter - String used to separate parameter and value + # :encoding - Encoding String for reading / writing + # :default - The String name of the default global section + # + # Examples + # + # IniFile.load('file.ini') + # #=> IniFile instance + # + # IniFile.load('does/not/exist.ini') + # #=> nil + # + # Returns an IniFile instance or nil if the file could not be opened. + def self.load( filename, opts = {} ) + return unless File.file? filename + new(opts.merge(:filename => filename)) + end + + # Get and set the filename + attr_accessor :filename + + # Get and set the encoding + attr_accessor :encoding + + # Public: Create a new INI file from the given set of options. If :content + # is provided then it will be used to populate the INI file. If a :filename + # is provided then the contents of the file will be parsed and stored in the + # INI file. If neither the :content or :filename is provided then an empty + # INI file is created. + # + # opts - The Hash of options (default: {}) + # :content - The String/Hash containing the INI contents + # :comment - String containing the comment character(s) + # :parameter - String used to separate parameter and value + # :encoding - Encoding String for reading / writing + # :default - The String name of the default global section + # :filename - The filename as a String + # + # Examples + # + # IniFile.new + # #=> an empty IniFile instance + # + # IniFile.new( :content => "[global]\nfoo=bar" ) + # #=> an IniFile instance + # + # IniFile.new( :filename => 'file.ini', :encoding => 'UTF-8' ) + # #=> an IniFile instance + # + # IniFile.new( :content => "[global]\nfoo=bar", :comment => '#' ) + # #=> an IniFile instance + # + def initialize( opts = {} ) + @comment = opts.fetch(:comment, ';#') + @param = opts.fetch(:parameter, '=') + @encoding = opts.fetch(:encoding, nil) + @default = opts.fetch(:default, 'global') + @filename = opts.fetch(:filename, nil) + content = opts.fetch(:content, nil) + + @ini = Hash.new {|h,k| h[k] = Hash.new} + + if content.is_a?(Hash) then merge!(content) + elsif content then parse(content) + elsif @filename then read + end + end + + # Public: Write the contents of this IniFile to the file system. If left + # unspecified, the currently configured filename and encoding will be used. + # Otherwise the filename and encoding can be specified in the options hash. + # + # opts - The default options Hash + # :filename - The filename as a String + # :encoding - The encoding as a String + # + # Returns this IniFile instance. + def write( opts = {} ) + filename = opts.fetch(:filename, @filename) + encoding = opts.fetch(:encoding, @encoding) + mode = encoding ? "w:#{encoding}" : "w" + + File.open(filename, mode) do |f| + @ini.each do |section,hash| + f.puts "[#{section}]" + hash.each {|param,val| f.puts "#{param} #{@param} #{escape_value val}"} + f.puts + end + end + + self + end + alias :save :write + + # Public: Read the contents of the INI file from the file system and replace + # and set the state of this IniFile instance. If left unspecified the + # currently configured filename and encoding will be used when reading from + # the file system. Otherwise the filename and encoding can be specified in + # the options hash. + # + # opts - The default options Hash + # :filename - The filename as a String + # :encoding - The encoding as a String + # + # Returns this IniFile instance if the read was successful; nil is returned + # if the file could not be read. + def read( opts = {} ) + filename = opts.fetch(:filename, @filename) + encoding = opts.fetch(:encoding, @encoding) + return unless File.file? filename + + mode = encoding ? "r:#{encoding}" : "r" + File.open(filename, mode) { |fd| parse fd } + self + end + alias :restore :read + + # Returns this IniFile converted to a String. + def to_s + s = [] + @ini.each do |section,hash| + s << "[#{section}]" + hash.each {|param,val| s << "#{param} #{@param} #{escape_value val}"} + s << "" + end + s.join("\n") + end + + # Returns this IniFile converted to a Hash. + def to_h + @ini.dup + end + + # Public: Creates a copy of this inifile with the entries from the + # other_inifile merged into the copy. + # + # other - The other IniFile. + # + # Returns a new IniFile. + def merge( other ) + self.dup.merge!(other) + end + + # Public: Merges other_inifile into this inifile, overwriting existing + # entries. Useful for having a system inifile with user overridable settings + # elsewhere. + # + # other - The other IniFile. + # + # Returns this IniFile. + def merge!( other ) + return self if other.nil? + + my_keys = @ini.keys + other_keys = case other + when IniFile + other.instance_variable_get(:@ini).keys + when Hash + other.keys + else + raise Error, "cannot merge contents from '#{other.class.name}'" + end + + (my_keys & other_keys).each do |key| + case other[key] + when Hash + @ini[key].merge!(other[key]) + when nil + nil + else + raise Error, "cannot merge section #{key.inspect} - unsupported type: #{other[key].class.name}" + end + end + + (other_keys - my_keys).each do |key| + @ini[key] = case other[key] + when Hash + other[key].dup + when nil + {} + else + raise Error, "cannot merge section #{key.inspect} - unsupported type: #{other[key].class.name}" + end + end + + self + end + + # Public: Yield each INI file section, parameter, and value in turn to the + # given block. + # + # block - The block that will be iterated by the each method. The block will + # be passed the current section and the parameter/value pair. + # + # Examples + # + # inifile.each do |section, parameter, value| + # puts "#{parameter} = #{value} [in section - #{section}]" + # end + # + # Returns this IniFile. + def each + return unless block_given? + @ini.each do |section,hash| + hash.each do |param,val| + yield section, param, val + end + end + self + end + + # Public: Yield each section in turn to the given block. + # + # block - The block that will be iterated by the each method. The block will + # be passed the current section as a Hash. + # + # Examples + # + # inifile.each_section do |section| + # puts section.inspect + # end + # + # Returns this IniFile. + def each_section + return unless block_given? + @ini.each_key {|section| yield section} + self + end + + # Public: Remove a section identified by name from the IniFile. + # + # section - The section name as a String. + # + # Returns the deleted section Hash. + def delete_section( section ) + @ini.delete section.to_s + end + + # Public: Get the section Hash by name. If the section does not exist, then + # it will be created. + # + # section - The section name as a String. + # + # Examples + # + # inifile['global'] + # #=> global section Hash + # + # Returns the Hash of parameter/value pairs for this section. + def []( section ) + return nil if section.nil? + @ini[section.to_s] + end + + # Public: Set the section to a hash of parameter/value pairs. + # + # section - The section name as a String. + # value - The Hash of parameter/value pairs. + # + # Examples + # + # inifile['tenderloin'] = { 'gritty' => 'yes' } + # #=> { 'gritty' => 'yes' } + # + # Returns the value Hash. + def []=( section, value ) + @ini[section.to_s] = value + end + + # Public: Create a Hash containing only those INI file sections whose names + # match the given regular expression. + # + # regex - The Regexp used to match section names. + # + # Examples + # + # inifile.match(/^tree_/) + # #=> Hash of matching sections + # + # Return a Hash containing only those sections that match the given regular + # expression. + def match( regex ) + @ini.dup.delete_if { |section, _| section !~ regex } + end + + # Public: Check to see if the IniFile contains the section. + # + # section - The section name as a String. + # + # Returns true if the section exists in the IniFile. + def has_section?( section ) + @ini.has_key? section.to_s + end + + # Returns an Array of section names contained in this IniFile. + def sections + @ini.keys + end + + # Public: Freeze the state of this IniFile object. Any attempts to change + # the object will raise an error. + # + # Returns this IniFile. + def freeze + super + @ini.each_value {|h| h.freeze} + @ini.freeze + self + end + + # Public: Mark this IniFile as tainted -- this will traverse each section + # marking each as tainted. + # + # Returns this IniFile. + def taint + super + @ini.each_value {|h| h.taint} + @ini.taint + self + end + + # Public: Produces a duplicate of this IniFile. The duplicate is independent + # of the original -- i.e. the duplicate can be modified without changing the + # original. The tainted state of the original is copied to the duplicate. + # + # Returns a new IniFile. + def dup + other = super + other.instance_variable_set(:@ini, Hash.new {|h,k| h[k] = Hash.new}) + @ini.each_pair {|s,h| other[s].merge! h} + other.taint if self.tainted? + other + end + + # Public: Produces a duplicate of this IniFile. The duplicate is independent + # of the original -- i.e. the duplicate can be modified without changing the + # original. The tainted state and the frozen state of the original is copied + # to the duplicate. + # + # Returns a new IniFile. + def clone + other = dup + other.freeze if self.frozen? + other + end + + # Public: Compare this IniFile to some other IniFile. For two INI files to + # be equivalent, they must have the same sections with the same parameter / + # value pairs in each section. + # + # other - The other IniFile. + # + # Returns true if the INI files are equivalent and false if they differ. + def eql?( other ) + return true if equal? other + return false unless other.instance_of? self.class + @ini == other.instance_variable_get(:@ini) + end + alias :== :eql? + + # Escape special characters. + # + # value - The String value to escape. + # + # Returns the escaped value. + def escape_value( value ) + value = value.to_s.dup + value.gsub!(%r/\\([0nrt])/, '\\\\\1') + value.gsub!(%r/\n/, '\n') + value.gsub!(%r/\r/, '\r') + value.gsub!(%r/\t/, '\t') + value.gsub!(%r/\0/, '\0') + value + end + + # Parse the given content and store the information in this IniFile + # instance. All data will be cleared out and replaced with the information + # read from the content. + # + # content - A String or a file descriptor (must respond to `each_line`) + # + # Returns this IniFile. + def parse( content ) + parser = Parser.new(@ini, @param, @comment, @default) + parser.parse(content) + self + end + + # The IniFile::Parser has the responsibility of reading the contents of an + # .ini file and storing that information into a ruby Hash. The object being + # parsed must respond to `each_line` - this includes Strings and any IO + # object. + class Parser + + attr_writer :section + attr_accessor :property + attr_accessor :value + + # Create a new IniFile::Parser that can be used to parse the contents of + # an .ini file. + # + # hash - The Hash where parsed information will be stored + # param - String used to separate parameter and value + # comment - String containing the comment character(s) + # default - The String name of the default global section + # + def initialize( hash, param, comment, default ) + @hash = hash + @default = default + + comment = comment.to_s.empty? ? "\\z" : "\\s*(?:[#{comment}].*)?\\z" + + @section_regexp = %r/\A\s*\[([^\]]+)\]#{comment}/ + @ignore_regexp = %r/\A#{comment}/ + @property_regexp = %r/\A(.*?)(? true + # "false" --> false + # "" --> nil + # "42" --> 42 + # "3.14" --> 3.14 + # "foo" --> "foo" + # + # Returns the typecast value. + def typecast( value ) + case value + when %r/\Atrue\z/i; true + when %r/\Afalse\z/i; false + when %r/\A\s*\z/i; nil + else + Integer(value) rescue \ + Float(value) rescue \ + unescape_value(value) + end + end + + # Unescape special characters found in the value string. This will convert + # escaped null, tab, carriage return, newline, and backslash into their + # literal equivalents. + # + # value - The String value to unescape. + # + # Returns the unescaped value. + def unescape_value( value ) + value = value.to_s + value.gsub!(%r/\\[0nrt\\]/) { |char| + case char + when '\0'; "\0" + when '\n'; "\n" + when '\r'; "\r" + when '\t'; "\t" + when '\\\\'; "\\" + end + } + value + end + end + +end # IniFile + diff -r 070c2f837f59 -r 8d2d5dfe94d0 rakelib/scm.rb --- a/rakelib/scm.rb Thu Oct 27 09:01:50 2016 +0000 +++ b/rakelib/scm.rb Sat Oct 29 23:54:12 2016 +0000 @@ -1,3 +1,5 @@ +require 'rakelib/hglib' + module Rake end @@ -59,27 +61,12 @@ separator = p[:separator] || '.' url = "#{repository}/#{directory.gsub('/', separator)}" end - if not sh %W{hg --cwd #{wc} pull #{url}} then - raise CheckoutException.new("HG: Cannot pull #{wc}") - end - - # Get bookmark if any... - bookmark = nil - Rake::FileUtilsExt::chdir(wc) do - Rake::FileUtilsExt::when_writing("hg log --template #...") do - bookmarks = `hg log --template "{bookmarks}" -r "p1()"` - puts "bookmarks: >>#{bookmarks}<<" - bookmark = bookmarks.split(' ')[0] - end - end - - cmd = %W{hg --cwd #{wc} update} - puts "bookmark: #{bookmark}" - if bookmark && bookmark != '' then - cmd << '-r' << "#{bookmark}" - end - if not sh cmd - raise CheckoutException.new("HG: Cannot update #{wc}") + repo = HG::Repository.new(wc) + begin + repo.pull(url) + repo.update() + rescue Exception => ex + raise CheckoutException.new("HG: Cannot update #{wc}: #{ex.message}") end end @@ -148,16 +135,6 @@ end end - def self._checkout_hg_impl(repository, package, directory, branch, root, *params) - url = "#{repository}/#{package}" - if not sh %W{hg clone #{url} #{root / directory}} - raise CheckoutException.new("HG: Cannot clone from #{url}") - end - if not sh %W{hg --cwd #{root / directory}/ update #{branch}} - raise CheckoutException.new("HG: Cannot switch to branch #{branch}") - end - end - def self._checkout_hg(repository, directory, branch, root, *params) if params.size() > 0 p = params.last @@ -166,7 +143,13 @@ end separator = p[:separator] || '.' - _checkout_hg_impl(repository, directory.gsub('/', separator), directory, branch, root) + url = "#{repository}/#{directory.gsub('/', separator)}" + begin + repo = HG::Repository::clone(url, root / directory, noupdate: true) + repo.update(branch) + #rescue Exception => e + # raise CheckoutException.new("HG: Cannot clone from #{url}: #{e.message}") + end end def self._checkout_cvs(repository, directory, branch, root, *params)