Restream/redmine_undev_git

View on GitHub
lib/redmine/scm/adapters/undev_git_adapter.rb

Summary

Maintainability
F
5 days
Test Coverage
require_dependency 'redmine/scm/adapters/git_adapter'
require_dependency 'redmine/scm/adapters/undev_git_revision'

module Redmine::Scm::Adapters
  class UndevGitAdapter < AbstractAdapter

    include RedmineUndevGit::Includes::GitShell

    # Git executable name
    GIT_BIN = Redmine::Configuration['scm_git_command'] || 'git'

    # default limit of commits for chunks
    cattr_accessor :default_chunk_size
    self.default_chunk_size = 150

    class GitBranch < Branch
      attr_accessor :is_default
    end

    class << self
      def client_command
        @@bin ||= GIT_BIN
      end

      def sq_bin
        @@sq_bin ||= shell_quote_command
      end

      def client_version
        @@client_version ||= (scm_command_version || [])
      end

      def client_version_eq_or_higher?(ver)
        ver = ver.split('.').map(&:to_i)
        (client_version.slice(0, ver.length) <=> ver) >= 0
      end

      def client_available
        !client_version.empty?
      end

      def scm_command_version
        scm_version = scm_version_from_command_line.dup
        if scm_version.respond_to?(:force_encoding)
          scm_version.force_encoding('UTF-8')
        end
        if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)})
          m[2].scan(%r{\d+}).collect(&:to_i)
        end
      end

      def scm_version_from_command_line
        shell_out("#{sq_bin} --version --no-color") { |stdin, stdout| return stdout.read.to_s }
      end

    end

    def initialize(url, root_url, login=nil, password=nil, path_encoding=nil)
      raise 'root_url must be provided' if root_url.blank?
      super
      @path_encoding = path_encoding.blank? ? 'UTF-8' : path_encoding
    end

    def path_encoding
      @path_encoding
    end

    def info
      Info.new(root_url: root_url, lastrev: lastrev('', nil))
    rescue
      nil
    end

    def branches
      return @branches if @branches
      @branches = []
      cmd_args  = %w{branch --no-color --verbose --no-abbrev}
      git_cmd(cmd_args) do |stdin, stdout|
        stdout.each_line do |line|
          branch_rev      = line.match('\s*(\*?)\s*(.*?)\s*([0-9a-f]{40}).*$')
          bran            = GitBranch.new(branch_rev[2])
          bran.revision   = branch_rev[3]
          bran.scmid      = branch_rev[3]
          bran.is_default = (branch_rev[1] == '*')
          @branches << bran
        end
      end
      @branches.sort!
    rescue Redmine::Scm::Adapters::CommandFailed
      nil
    end

    def tags
      @tags ||= git_readlines('tag').sort.map { |t| t.strip }
    rescue Redmine::Scm::Adapters::CommandFailed
      nil
    end

    def default_branch
      bras = self.branches
      return nil if bras.nil?
      default_bras = bras.select { |x| x.is_default == true }
      return default_bras.first.to_s unless default_bras.empty?
      master_bras = bras.select { |x| x.to_s == 'master' }
      master_bras.empty? ? bras.first.to_s : 'master'
    end

    def entry(path = nil, identifier = nil)
      parts       = path.to_s.split(%r{[\/\\]}).select { |n| !n.blank? }
      search_path = parts[0..-2].join('/')
      search_name = parts[-1]
      if search_path.blank? && search_name.blank?
        # Root entry
        Entry.new(path: '', kind: 'dir')
      else
        # Search for the entry in the parent directory
        es = entries(search_path, identifier,
          options = { report_last_commit: false })
        es ? es.detect { |e| e.name == search_name } : nil
      end
    end

    def entries(path, identifier = nil, options = {})
      path     ||= ''
      p        = scm_iconv(path_encoding, 'UTF-8', path)
      entries  = Entries.new
      cmd_args = %w{ls-tree -l}
      cmd_args << "HEAD:#{p}" if identifier.nil?
      cmd_args << "#{identifier}:#{p}" if identifier
      git_cmd(cmd_args) do |stdin, stdout|
        stdout.each_line do |line|
          e = line.chomp.to_s
          if e =~ /^\d+\s+(\w+)\s+([0-9a-f]{40})\s+([0-9-]+)\t(.+)$/
            type = $1
            sha  = $2
            size = $3
            name = $4
            if name.respond_to?(:force_encoding)
              name.force_encoding(path_encoding)
            end
            full_path = p.empty? ? name : "#{p}/#{name}"
            n         = scm_iconv('UTF-8', path_encoding, name)
            full_p    = scm_iconv('UTF-8', path_encoding, full_path)
            entries << Entry.new({ name: n,
              path:                      full_p,
              kind:                      (type == 'tree') ? 'dir' : 'file',
              size:                      (type == 'tree') ? nil : size,
              lastrev:                   options[:report_last_commit] ?
                                           lastrev(full_path, identifier) : Revision.new
            }) unless entries.detect { |entry| entry.name == name }
          end
        end
      end
      entries.sort_by_name
    rescue Redmine::Scm::Adapters::CommandFailed
      Redmine::Scm::Adapters::Entries.new
    end

    def lastrev(path, rev)
      return nil if path.nil?
      cmd_args = %w{log --no-color --encoding=UTF-8 --date=iso --pretty=fuller --no-merges -n 1}
      cmd_args << rev if rev
      cmd_args << '--' << path unless path.empty?
      lines = git_readlines(cmd_args)
      begin
        id     = lines[0].split[1]
        author = lines[1].match('Author:\s+(.*)$')[1]
        time   = Time.parse(lines[4].match('CommitDate:\s+(.*)$')[1])

        Revision.new({
          identifier: id,
          scmid:      id,
          author:     author,
          time:       time,
          message:    nil,
          paths:      nil
        })
      rescue NoMethodError => e
        logger.error("The revision '#{path}' has a wrong format")
        return nil
      end
    rescue Redmine::Scm::Adapters::CommandFailed
      nil
    end

    def diff(path, identifier_from, identifier_to = nil)
      path     ||= ''
      cmd_args = []
      if identifier_to
        cmd_args << 'diff' << '--no-color' << identifier_to << identifier_from
      else
        cmd_args << 'show' << '--no-color' << identifier_from
      end
      cmd_args << '--' << scm_iconv(path_encoding, 'UTF-8', path) unless path.empty?
      diff = []
      git_cmd(cmd_args) do |stdin, stdout|
        stdout.each_line do |line|
          diff << line
        end
      end
      diff
    rescue Redmine::Scm::Adapters::CommandFailed
      nil
    end

    def annotate(path, identifier = nil)
      identifier = 'HEAD' if identifier.blank?
      cmd_args   = %w{blame --encoding=UTF-8}
      cmd_args << '-p' << identifier << '--' << scm_iconv(path_encoding, 'UTF-8', path)
      blame   = Annotate.new
      content = git_binread(cmd_args)
      # git annotates binary files
      return nil if content.is_binary_data?
      identifier        = ''
      # git shows commit author on the first occurrence only
      authors_by_commit = {}
      content.split("\n").each do |line|
        if line =~ /^([0-9a-f]{39,40})\s.*/
          identifier = $1
        elsif line =~ /^author (.+)/
          authors_by_commit[identifier] = $1.strip
        elsif line =~ /^\t(.*)/
          blame.add_line($1, Revision.new(
            identifier: identifier,
            revision:   identifier,
            scmid:      identifier,
            author:     authors_by_commit[identifier]
          ))
          identifier = ''
          author     = ''
        end
      end
      blame
    rescue Redmine::Scm::Adapters::CommandFailed
      nil
    end

    def cat(path, identifier=nil)
      if identifier.nil?
        identifier = 'HEAD'
      end
      cmd_args = %w|show --no-color|
      cmd_args << "#{identifier}:#{scm_iconv(@path_encoding, 'UTF-8', path)}"
      git_binread(cmd_args)
    rescue Redmine::Scm::Adapters::CommandFailed
      nil
    end

    # if block is given then revisions will reading by chunks and
    # block will yields with hashed chunks (Redmine::Scm::Adapters::UndevGitRevision)
    # otherwise simply array of revisions will be returned
    # Note: With :reverse option method may only be called without block
    def revisions(path, identifier_from, identifier_to, options = {}, &block)
      if block_given?
        raise 'Can' 't read chunks with reverse option' if options[:reverse]
        revisions_in_chunks(path, identifier_from, identifier_to, options, &block)
      else
        revs = []
        revisions_in_chunks(path, identifier_from, identifier_to, options) do |chunk|
          revs += chunk.values
        end
        options[:reverse] ? revs.reverse : revs
      end
    rescue Redmine::Scm::Adapters::CommandFailed => e
      err_msg = "git log error: #{e.message}"
      logger.error(err_msg)
      if block_given?
        raise Redmine::Scm::Adapters::CommandFailed, err_msg
      else
        revs
      end
    end

    def revisions_in_chunks(path, identifier_from, identifier_to, options = {}, &block)

      revision_regexp = %r{
            (?<h>[0-9a-f]{40});\s
            (?<ai>.*);\s
            (?<d>.*);\s
            (?<p>.*);\s
            (?<cn>.*);\s
            (?<ce>.*);\s
            (?<ci>.*);\s
            (?<ai>.*)$
          }x

      # wee need to drag branches from head to parent commits
      # but git log will be read by block,
      # then we need to store boundary info between blocks
      boundary_drags  = nil

      options.merge!(
        format:          '%H; %ai; %d; %P; %cn; %ce; %ci; %ai%n%s%n%b%n%h',
        identifier_from: identifier_from,
        identifier_to:   identifier_to)

      if self.class.client_version_eq_or_higher?('1.7.2')
        options[:format] = '%H; %ai; %d; %P; %cn; %ce; %ci; %ai%n%B%h'
      end

      chunked_git_log(path, options) do |git_log, git_patch_ids|

        chunk, refs        = UndevGitRevisions.new(), []
        revision, wait_for = nil
        patch_ids_map      = {}

        git_patch_ids.each_line do |line|
          m                   = line.split(' ')
          patch_ids_map[m[1]] = m[0]
        end

        git_log.each_line do |line|
          if md = line.match(revision_regexp)
            branches = extract_branches(md[:d])
            ctime    = Time.parse(md[:ci]) unless md[:ci].blank?
            atime    = Time.parse(md[:ai]) unless md[:ai].blank?
            parents  = md[:p].blank? ? [] : md[:p].split(' ')

            revision = UndevGitRevision.new({
              identifier:  md[:h],
              scmid:       md[:h],
              author:      "#{md[:cn]} <#{md[:ce]}>",
              time:        ctime,
              authored_on: atime,
              message:     '',
              paths:       [],
              patch_id:    patch_ids_map[md[:h]],
              parents:     parents,
              branches:    branches,
              branch:      branches.first })

            chunk[revision.identifier] = revision

            # store commits with branches to drag them to their parents later
            refs << revision if branches.any?

            # message starts on next line
            wait_for = :message
          else
            # suppress empty trailing lines
            next unless revision

            # looking for divider between message and paths (it's a hash abbr)
            if line.present? && revision.identifier.index(line.chomp) == 0
              wait_for = :path
              next
            end

            case wait_for
              when :message
                revision.message << "\n" unless revision.message.blank?
                revision.message << line.chomp
              when :path
                if line.present? && line =~ /\A(\w+)\s(.+)$/
                  revision.paths << { action: $1, path: $2 }
                end
            end
          end
        end

        # first, apply boundary drags from previous block if any
        chunk.apply_delayed_drags!(boundary_drags) if boundary_drags

        # drag branches for every commits in the block
        refs.each do |rev|
          chunk.drag_branches_to_parents!(rev)
        end

        # store operations to missed commits for the next block
        boundary_drags = chunk.delayed_drags

        block.call(chunk)
      end
    end

    def fetch!
      args = %w{fetch origin --force}
      git_cmd(args)
    end

    def cloned?
      return false unless Dir.exist?(root_url)

      args = ['--git-dir', root_url, 'rev-parse']
      args = args.map { |arg| shell_quote(arg.to_s) }.join(' ')
      cmd  = [self.class.sq_bin, args].join(' ')

      existstatus, errors = shell_out(cmd)
      logger.warn "Fail check that repo [#{root_url}] is cloned: #{errors}" if existstatus != 0

      existstatus == 0
    rescue CommandFailed
      logger.error("The revision '#{path}' has a wrong format")
      false
    end

    def clone_repository
      FileUtils.mkdir_p(root_url)

      args = ['clone', url, root_url, '--mirror', '--quiet']
      args = args.map { |arg| shell_quote(arg.to_s) }.join(' ')
      cmd  = [self.class.sq_bin, args].join(' ')

      existstatus, errors = shell_out(cmd)

      raise(Redmine::Scm::Adapters::CommandFailed, errors) if existstatus != 0
    end

    private

    # execute git command and yield block for every chunk
    # chunk size gets from options[:chunk_size]
    def chunked_git_log(path, options, &block)
      skip = 0

      revisions = revisions_for_git_cmd(options)

      chunk_size = options[:chunk_size] || default_chunk_size

      while true

        if options[:limit]
          chunk_size = [options[:limit] - skip, chunk_size].min
        end

        cmd_args = %w{log --date=iso --date-order --name-status --no-color}
        cmd_args << "--format=#{options[:format]}"

        cmd_args << '--all' if revisions.empty? && options[:all]
        cmd_args << "--encoding=#{path_encoding}"
        cmd_args << "--skip=#{skip}" << "--max-count=#{chunk_size}"
        cmd_args << '--stdin'

        if path && !path.empty?
          cmd_args << '--' << scm_iconv(path_encoding, 'UTF-8', path)
        end

        git_log = nil
        git_cmd(cmd_args) do |stdin, stdout|
          stdin.binmode
          stdin.puts(revisions.join("\n"))
          stdin.close
          git_log = stdout.read.force_encoding(path_encoding)
        end

        begin
          return nil if git_log.blank?

        rescue ArgumentError #invalid byte sequence in UTF-8
          git_log = remove_invalid_characters(git_log)
        end

        # get patch_ids for commits

        git_patch_ids = patch_ids(path, revisions, chunk_size, skip)

        block.call(git_log, git_patch_ids)

        skip += chunk_size
      end
    end

    def remove_invalid_characters(s)
      s.chars.select { |c| c.valid_encoding? }.join
    end

    # get patch_ids for commits
    def patch_ids(path, revisions, limit, skip)
      git_patch_ids = ''
      git_cmd(%w{patch-id}) do |i_patch_id, o_patch_id|
        i_patch_id.binmode

        cmd_args = %w{log -p --no-color --date-order --format=%H}
        cmd_args << '--all' if revisions.empty?
        cmd_args << "--encoding=#{path_encoding}"
        cmd_args << "--skip=#{skip}" << "--max-count=#{limit}"
        cmd_args << '--stdin'

        if path && !path.empty?
          cmd_args << '--' << scm_iconv(path_encoding, 'UTF-8', path)
        end

        git_cmd(cmd_args) do |i_log, o_log|
          i_log.binmode
          i_log.puts(revisions.join("\n"))
          i_log.close
          IO.copy_stream(o_log, i_patch_id)
        end

        i_patch_id.close
        git_patch_ids = o_patch_id.read.force_encoding(path_encoding)
      end
      git_patch_ids
    end

    def revisions_for_git_cmd(options = {})
      revisions                      = []
      identifier_from, identifier_to = options[:identifier_from], options[:identifier_to]
      if identifier_from || identifier_to
        revisions << ''
        revisions[0] << "#{identifier_from}.." if identifier_from
        revisions[0] << "#{identifier_to}" if identifier_to
      else
        unless options[:includes].blank?
          revisions += options[:includes]
        end
        unless options[:excludes].blank?
          revisions += options[:excludes].map { |r| "^#{r}" }
        end
      end
      revisions
    end

    # extract branches from git log format: (HEAD, master)
    # reject tags from branches
    def extract_branches(decorate, remove_head = true)
      return [] if decorate.blank?
      decorate.strip[1...-1].split(', ').
        reject { |b| remove_head && b == 'HEAD' || b =~ /^tag:\s/ }
    end

  end
end