argent-smith/girror

View on GitHub
lib/girror.rb

Summary

Maintainability
D
2 days
Test Coverage
# Author::    Pavel Argentov <argentoff@gmail.com>
# Copyright:: (c) 2010-2011 Pavel Argentov
# License::   see LICENSE.txt
#
# Girror library code, the internals. Since the whole app is a CLI utility,
# there's not a lot to document here.
#
# The library now contains only one module Girror which contains class Application
# which is the app logic container. The methods have some documentation in the corresponding
# section (Girror::Application).
#
# == Disclaimer
#
# This documentation is written as an aid to further development of the program.
# For more user-friendly documentation see README.rdoc file.
#

######## Utility

$:.unshift File.dirname(__FILE__) # For use/testing when no gem is installed

# Require all of the Ruby files in the given directory.
#
# path - The String relative path from here to the directory.
#
# Returns nothing.
def require_all(path) # :nodoc:
  glob = File.join(File.dirname(__FILE__), path, '*.rb')
  Dir[glob].each do |f|
    require f
  end
end

######## Requires
require 'singleton'
require 'net/sftp'
require 'fileutils'
require 'git'
require 'iconv'

# This module encapsulates *girror*'s namespace.
module Girror
  # Version of the library.
  VERSION = "0.0.5"

  # Regexp to filter out 'technical' files which aren't normally a part of
  # the mirrored tree and therefore should be _ignored_.
  FILTER_RE = /^((\.((\.{0,1})|((git)(ignore)?)))|(_girror))$/

  # Application logic container.
  class Application
    include Singleton

    class << self # class things
      include FileUtils

      # Runs the app. Much like the C's main().
      def run ops
        # Logging setup
        @log = case ops[:log]
        when 'syslog'
          unless ENV['OS'] == 'Windows_NT'
            require 'syslog_logger'
            SyslogLogger.new('girror')
          else
            Logger.new STDERR
          end
        when nil
          Logger.new STDERR
        else
          Logger.new ops[:log]
        end
        @log.datetime_format = "%Y-%m-%d %H:%M:%S " if Logger.class == Logger
        log "Starting"
        @debug = true if ops[:verbose]
        debug "Current options are: #{ops.inspect}"

        # check the validity of a local directory
        @lpath = ops[:to]       # local save path
        log "Opening local git repo at #{@lpath}"
        @git = Git.open(@lpath) # local git repo

        cd ops[:to]; log "Changed to #{pwd}"

        # read the config and use CLI ops to override it
        $:.unshift(File.join(".", "_girror"))
        begin
          require 'config'
          ops = Config::OPTIONS.merge ops

          begin
            debug "Program options:"
            ops.each do |pair|
              debug pair.inspect
            end
          end

        rescue LoadError => d
          log "Not using stored config: #{d.message}"
        end

        # set commit message for git
        ops[:commit_msg].nil? ? @commit_msg = Proc.new { Time.now.to_s } : @commit_msg = ops[:commit_msg]

        # name conversion encodings for Iconv
        ops[:renc].nil? ? @renc = "utf-8" :  @renc = ops[:renc]
        ops[:lenc].nil? ? @lenc = "utf-8" :  @lenc = ops[:lenc]

        # Check the validity of a remote url and run the remote connection
        if ops[:from] =~ /^((\w+)(:(\w+))?@)?(.+):(.*)$/
          $2.nil? ? @user = ENV["USERNAME"] : @user = $2
          @pass = $4
          @host = $5
          @path = $6

          debug "Remote data specified as: login: #{@user}; pass: #{@pass.inspect}; host: #{@host}; path: #{@path}"
          Net::SFTP.start(@host, @user,
              :password => @pass,
              :keys => ops[:ssh][:keys],
              :compression => ops[:ssh][:compression]
             ) do |s|
            @sftp = s
            log "Connected to remote #{@host} as #{@user}"

            dl_if_needed @path

            log "Disconnected from remote #{@host}"

            # fix the local tree in the git repo
            begin
              log "Committing changes to local git repo"
              @git.add
              msg = if @commit_msg.class == Proc then @commit_msg.call
              else @commit_msg
              end . to_s
              @git.commit msg, :add_all => true
            rescue Git::GitExecuteError => detail
              case detail.message
              when /nothing to commit/
                log "Nothing to commit"
              else
                log detail.message
              end
            end

          end

        else
          raise "Bad remote specification!"
        end

        log "Finishing"
      end

      # Writes a debug message to @log.
      def debug string
        @log.debug string if @debug
      end

      # Writes a log message to @log.
      def log string
        @log.info string
      end

      # On-demand fetcher. Recursively fetches a directory entry 'name' (String)
      # if there's no local copy of it or the remote mtime is newer or the
      # attributes are to be updated.
      def dl_if_needed name
        debug "RNA: #{name}"
        lname = econv(File.join '.', name.gsub(/^#{@path}/,'')); debug "LNA: #{lname}"

        # get and hold the current direntry's stat in here
        begin
          rs  = @sftp.stat!(name); s_rs = [Time.at(rs.mtime), Time.at(rs.atime), rs.uid, rs.gid, "%o" % rs.permissions].inspect
        rescue Net::SFTP::StatusException => detail
          return if detail.code == 2 # silently ignore the broken remote link
        end
        debug "Remote stat for #{name} => #{s_rs}"

        # remote type filter: we only work with types 1..2 (regular, dir)
        begin
          debug "Remote file type #{rs.type} isn't supported, ignoring."
          return
        end if rs.type > 2

        # remove the local entry if local/remote entry type differ;
        # else compare remote/local owner/mode and schedule the update.
        if File.exist? lname
          if (
              rs.type != case File.ftype lname
              when "file"      then 1
              when "directory" then 2
              end
            )
            remove_entry_secure lname, :force => true
          else
            lrs = File.stat(lname)
            # we do mode comparison on Unices only,
            # and owner compaison only if we are root
            unless ENV['OS'] == "Windows_NT"
              set_attrs = true unless (
                if ENV['EUID'] == 0
                  debug "Comparing: #{[rs.permissions, rs.uid, rs.gid].inspect} <=> #{[lrs.mode, lrs.uid, lrs.gid].inspect}"
                  [lrs.mode, lrs.uid, lrs.gid] == [rs.permissions, rs.uid, rs.gid]
                else
                  debug "Comparing: #{rs.permissions} <=> #{lrs.mode}"
                  lrs.mode == rs.permissions
                end
              )
            end
          end
        end

        # do the type-specific fetch operations
        case rs.type
        when 1
          if (lrs.nil? or (lrs.mtime.to_i < rs.mtime))
            log "Fetching file #{name} -> #{lname.force_encoding("BINARY")} (#{rs.size} bytes)"
            @sftp.download! name, lname
            set_attrs = true
          end
        when 2
          # here we've got a dir
          # create the dir locally if needed
          unless File.exist?(lname)
            log "Fetching directory #{name} -> #{lname.force_encoding("BINARY")} | #{s_rs}"
            mkdir lname
            set_attrs = true
          end
          # recurse into the dir; get the remote list
          rlist = @sftp.dir.entries(name).map do |e|
            unless e.name =~ FILTER_RE
              dl_if_needed(File.join(name, e.name))
              Iconv.conv("utf-8", @renc, e.name)
            end
          end . compact

          # get the local list
          llist = Dir.entries(lname).map do |n|
            Iconv.conv("utf-8", @lenc, n) unless n =~ FILTER_RE
          end . compact

          # differentiate the lists; remove what's needed from local repo
          diff = llist - rlist
          diff.each do |n|
        # the string should be converted back to local encoding before any
        # operations
            n = Iconv.conv(@lenc, "utf-8", File.join(lname, n))
            log "Removing #{n}"
            begin
              @git.remove n, :recursive => true
            rescue Git::GitExecuteError => detail
              case detail.message
              when /did not match/
                log "#{n} has no match in the git repo: removing it forcefully!"
                rm_rf n
              else
                log detail.message
              end
            end
          end
        end

        # do the common after-fetch tasks (chown, chmod, utime)
        unless lname == "./"
          unless ENV['OS'] == "Windows_NT"     # chmod/chown issues on that platform
            if ENV['EUID'] == 0
              log "Setting owner: #{lname} => #{[rs.uid, rs.gid].inspect}"
              File.chown rs.uid, rs.gid, lname
            end
            log "Setting mode: #{lname} => #{"%o" % rs.permissions}"
            File.chmod rs.permissions, lname
          end
          log "Setting mtime: #{lname} => #{[rs.atime, rs.mtime].map{|t| Time.at(t).strftime("%Y-%m-%d %H:%M:%S")}.inspect}"
          File.utime rs.atime, rs.mtime, lname
        end if set_attrs
      end

      # Converts the String str from @renc to @lenc if both @renc and @lenc are
      # set and aren't equal.
      #
      # Returns the converted String.
      def econv str
        ((@lenc == @renc) or (@lenc.nil? or @renc.nil?)) ?
          str : Iconv.conv(@lenc, @renc, str)
      end

    end
  end
end