rakelib/hglib.rb
author Jan Vrany <jan.vrany@fit.cvut.cz>
Wed, 02 Nov 2016 00:18:25 +0000
changeset 67 75b6eb7b781c
parent 66 8d2d5dfe94d0
child 68 61d8bee7c4d4
permissions -rwxr-xr-x
Added support for canonical, upstream and staging repositores. Each repository (forest) can now specify three repository URLS - (mandatory), "upstream: and "staging" repository (bothoptional). When a "staging" repository is configured, commits are first pulled from "staging" repository and then from "canonical" (assuming "staging" repository is local so this should avoid network trafic to canonical repositories). When an "upstream" repository is configured, changes are pulled from an "upstream" after pulling fron "staging" but before pulling from a canonical repository. This allows to define a hierarchy of repositories for staged development. This means, it allows for changes (commits) to go from one repository to another before eventually reaching a canonical repository from which official builds should be done. At each step commits should be verified and tested before they're pushed to upstream to avoid pushing broken code.

# 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 defined? RakeFileUtils then
      puts cmd.shelljoin
    end
    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

    # Initializes an empty repository in given directory. Returns an 
    # `HG::Repository` instance representing the created (empty) repository.
    def self.init(directory)
      HG::hg("init", directory)
      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

    # Return a hashmap with defined paths (alias => uri)
    def paths() 
      return @config['paths'].clone
    end

    # Set paths for given repository
    def paths=(paths)
      config = IniFile.new(:filename => self.hgrc())
      config['paths'] = paths
      config.write()
    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