File: lib/redmine/scm/adapters/git_adapter.rb

Overview
Module Structure
Class Hierarchy
Code

Overview

Module Structure

  module: <Toplevel Module>
  module: Redmine#20
  module: Scm#21
  module: Adapters#22
  class: GitAdapter#23
inherits from
  AbstractAdapter ( Redmine::Scm::Adapters )
has properties
constant: GIT_BIN #26
class method: client_command #33
class method: sq_bin #37
class method: client_version #41
class method: client_available #45
class method: scm_command_version #49
class method: scm_version_from_command_line #59
method: initialize / 5 #64
method: path_encoding #69
method: info #73
method: branches #81
method: tags #100
method: default_branch #110
method: entry / 2 #119
method: entries / 3 #134
method: lastrev / 2 #170
method: revisions / 4 #198
method: diff / 3 #314
method: annotate / 2 #334
method: cat / 2 #367
method: git_cmd / 3 #390
  class: GitBranch#28
inherits from
  Branch ( Redmine::Scm::Adapters )
has properties
attribute: is_default [RW] #29
  class: Revision#383
inherits from
  Revision ( Redmine::Scm::Adapters )
has properties
method: format_identifier #385

Code

   1  # Redmine - project management software
   2  # Copyright (C) 2006-2011  Jean-Philippe Lang
   3  #
   4  # This program is free software; you can redistribute it and/or
   5  # modify it under the terms of the GNU General Public License
   6  # as published by the Free Software Foundation; either version 2
   7  # of the License, or (at your option) any later version.
   8  #
   9  # This program is distributed in the hope that it will be useful,
  10  # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  # GNU General Public License for more details.
  13  #
  14  # You should have received a copy of the GNU General Public License
  15  # along with this program; if not, write to the Free Software
  16  # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  17 
  18  require 'redmine/scm/adapters/abstract_adapter'
  19 
  20  module Redmine
  21    module Scm
  22      module Adapters
  23        class GitAdapter < AbstractAdapter
  24 
  25          # Git executable name
  26          GIT_BIN = Redmine::Configuration['scm_git_command'] || "git"
  27 
  28          class GitBranch < Branch 
  29            attr_accessor :is_default
  30          end
  31 
  32          class << self
  33            def client_command
  34              @@bin    ||= GIT_BIN
  35            end
  36 
  37            def sq_bin
  38              @@sq_bin ||= shell_quote_command
  39            end
  40 
  41            def client_version
  42              @@client_version ||= (scm_command_version || [])
  43            end
  44 
  45            def client_available
  46              !client_version.empty?
  47            end
  48 
  49            def scm_command_version
  50              scm_version = scm_version_from_command_line.dup
  51              if scm_version.respond_to?(:force_encoding)
  52                scm_version.force_encoding('ASCII-8BIT')
  53              end
  54              if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)})
  55                m[2].scan(%r{\d+}).collect(&:to_i)
  56              end
  57            end
  58 
  59            def scm_version_from_command_line
  60              shellout("#{sq_bin} --version --no-color") { |io| io.read }.to_s
  61            end
  62          end
  63 
  64          def initialize(url, root_url=nil, login=nil, password=nil, path_encoding=nil)
  65            super
  66            @path_encoding = path_encoding.blank? ? 'UTF-8' : path_encoding
  67          end
  68 
  69          def path_encoding
  70            @path_encoding
  71          end
  72 
  73          def info
  74            begin
  75              Info.new(:root_url => url, :lastrev => lastrev('',nil))
  76            rescue
  77              nil
  78            end
  79          end
  80 
  81          def branches
  82            return @branches if @branches
  83            @branches = []
  84            cmd_args = %w|branch --no-color --verbose --no-abbrev|
  85            git_cmd(cmd_args) do |io|
  86              io.each_line do |line|
  87                branch_rev = line.match('\s*(\*?)\s*(.*?)\s*([0-9a-f]{40}).*$')
  88                bran = GitBranch.new(branch_rev[2])
  89                bran.revision =  branch_rev[3]
  90                bran.scmid    =  branch_rev[3]
  91                bran.is_default = ( branch_rev[1] == '*' )
  92                @branches << bran
  93              end
  94            end
  95            @branches.sort!
  96          rescue ScmCommandAborted
  97            nil
  98          end
  99 
 100          def tags
 101            return @tags if @tags
 102            cmd_args = %w|tag|
 103            git_cmd(cmd_args) do |io|
 104              @tags = io.readlines.sort!.map{|t| t.strip}
 105            end
 106          rescue ScmCommandAborted
 107            nil
 108          end
 109 
 110          def default_branch
 111            bras = self.branches
 112            return nil if bras.nil?
 113            default_bras = bras.select{|x| x.is_default == true}
 114            return default_bras.first.to_s if ! default_bras.empty?
 115            master_bras = bras.select{|x| x.to_s == 'master'}
 116            master_bras.empty? ? bras.first.to_s : 'master' 
 117          end
 118 
 119          def entry(path=nil, identifier=nil)
 120            parts = path.to_s.split(%r{[\/\\]}).select {|n| !n.blank?}
 121            search_path = parts[0..-2].join('/')
 122            search_name = parts[-1]
 123            if search_path.blank? && search_name.blank?
 124              # Root entry
 125              Entry.new(:path => '', :kind => 'dir')
 126            else
 127              # Search for the entry in the parent directory
 128              es = entries(search_path, identifier,
 129                           options = {:report_last_commit => false})
 130              es ? es.detect {|e| e.name == search_name} : nil
 131            end
 132          end
 133 
 134          def entries(path=nil, identifier=nil, options={})
 135            path ||= ''
 136            p = scm_iconv(@path_encoding, 'UTF-8', path)
 137            entries = Entries.new
 138            cmd_args = %w|ls-tree -l|
 139            cmd_args << "HEAD:#{p}"          if identifier.nil?
 140            cmd_args << "#{identifier}:#{p}" if identifier
 141            git_cmd(cmd_args) do |io|
 142              io.each_line do |line|
 143                e = line.chomp.to_s
 144                if e =~ /^\d+\s+(\w+)\s+([0-9a-f]{40})\s+([0-9-]+)\t(.+)$/
 145                  type = $1
 146                  sha  = $2
 147                  size = $3
 148                  name = $4
 149                  if name.respond_to?(:force_encoding)
 150                    name.force_encoding(@path_encoding)
 151                  end
 152                  full_path = p.empty? ? name : "#{p}/#{name}"
 153                  n      = scm_iconv('UTF-8', @path_encoding, name)
 154                  full_p = scm_iconv('UTF-8', @path_encoding, full_path)
 155                  entries << Entry.new({:name => n,
 156                   :path => full_p,
 157                   :kind => (type == "tree") ? 'dir' : 'file',
 158                   :size => (type == "tree") ? nil : size,
 159                   :lastrev => options[:report_last_commit] ?
 160                                   lastrev(full_path, identifier) : Revision.new
 161                  }) unless entries.detect{|entry| entry.name == name}
 162                end
 163              end
 164            end
 165            entries.sort_by_name
 166          rescue ScmCommandAborted
 167            nil
 168          end
 169 
 170          def lastrev(path, rev)
 171            return nil if path.nil?
 172            cmd_args = %w|log --no-color --encoding=UTF-8 --date=iso --pretty=fuller --no-merges -n 1|
 173            cmd_args << rev if rev
 174            cmd_args << "--" << path unless path.empty?
 175            lines = []
 176            git_cmd(cmd_args) { |io| lines = io.readlines }
 177            begin
 178                id = lines[0].split[1]
 179                author = lines[1].match('Author:\s+(.*)$')[1]
 180                time = Time.parse(lines[4].match('CommitDate:\s+(.*)$')[1])
 181 
 182                Revision.new({
 183                  :identifier => id,
 184                  :scmid      => id,
 185                  :author     => author,
 186                  :time       => time,
 187                  :message    => nil,
 188                  :paths      => nil
 189                  })
 190            rescue NoMethodError => e
 191                logger.error("The revision '#{path}' has a wrong format")
 192                return nil
 193            end
 194          rescue ScmCommandAborted
 195            nil
 196          end
 197 
 198          def revisions(path, identifier_from, identifier_to, options={})
 199            revs = Revisions.new
 200            cmd_args = %w|log --no-color --encoding=UTF-8 --raw --date=iso --pretty=fuller --parents --stdin|
 201            cmd_args << "--reverse" if options[:reverse]
 202            cmd_args << "-n" << "#{options[:limit].to_i}" if options[:limit]
 203            cmd_args << "--" << scm_iconv(@path_encoding, 'UTF-8', path) if path && !path.empty?
 204            revisions = []
 205            if identifier_from || identifier_to
 206              revisions << ""
 207              revisions[0] << "#{identifier_from}.." if identifier_from
 208              revisions[0] << "#{identifier_to}" if identifier_to
 209            else
 210              unless options[:includes].blank?
 211                revisions += options[:includes]
 212              end
 213              unless options[:excludes].blank?
 214                revisions += options[:excludes].map{|r| "^#{r}"}
 215              end
 216            end
 217 
 218            git_cmd(cmd_args, {:write_stdin => true}) do |io|
 219              io.binmode
 220              io.puts(revisions.join("\n"))
 221              io.close_write
 222              files=[]
 223              changeset = {}
 224              parsing_descr = 0  #0: not parsing desc or files, 1: parsing desc, 2: parsing files
 225 
 226              io.each_line do |line|
 227                if line =~ /^commit ([0-9a-f]{40})(( [0-9a-f]{40})*)$/
 228                  key = "commit"
 229                  value = $1
 230                  parents_str = $2
 231                  if (parsing_descr == 1 || parsing_descr == 2)
 232                    parsing_descr = 0
 233                    revision = Revision.new({
 234                      :identifier => changeset[:commit],
 235                      :scmid      => changeset[:commit],
 236                      :author     => changeset[:author],
 237                      :time       => Time.parse(changeset[:date]),
 238                      :message    => changeset[:description],
 239                      :paths      => files,
 240                      :parents    => changeset[:parents]
 241                    })
 242                    if block_given?
 243                      yield revision
 244                    else
 245                      revs << revision
 246                    end
 247                    changeset = {}
 248                    files = []
 249                  end
 250                  changeset[:commit] = $1
 251                  unless parents_str.nil? or parents_str == ""
 252                    changeset[:parents] = parents_str.strip.split(' ')
 253                  end
 254                elsif (parsing_descr == 0) && line =~ /^(\w+):\s*(.*)$/
 255                  key = $1
 256                  value = $2
 257                  if key == "Author"
 258                    changeset[:author] = value
 259                  elsif key == "CommitDate"
 260                    changeset[:date] = value
 261                  end
 262                elsif (parsing_descr == 0) && line.chomp.to_s == ""
 263                  parsing_descr = 1
 264                  changeset[:description] = ""
 265                elsif (parsing_descr == 1 || parsing_descr == 2) \
 266                    && line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\t(.+)$/
 267                  parsing_descr = 2
 268                  fileaction    = $1
 269                  filepath      = $2
 270                  p = scm_iconv('UTF-8', @path_encoding, filepath)
 271                  files << {:action => fileaction, :path => p}
 272                elsif (parsing_descr == 1 || parsing_descr == 2) \
 273                    && line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\d+\s+(\S+)\t(.+)$/
 274                  parsing_descr = 2
 275                  fileaction    = $1
 276                  filepath      = $3
 277                  p = scm_iconv('UTF-8', @path_encoding, filepath)
 278                  files << {:action => fileaction, :path => p}
 279                elsif (parsing_descr == 1) && line.chomp.to_s == ""
 280                  parsing_descr = 2
 281                elsif (parsing_descr == 1)
 282                  changeset[:description] << line[4..-1]
 283                end
 284              end
 285 
 286              if changeset[:commit]
 287                revision = Revision.new({
 288                  :identifier => changeset[:commit],
 289                  :scmid      => changeset[:commit],
 290                  :author     => changeset[:author],
 291                  :time       => Time.parse(changeset[:date]),
 292                  :message    => changeset[:description],
 293                  :paths      => files,
 294                  :parents    => changeset[:parents]
 295                   })
 296                if block_given?
 297                  yield revision
 298                else
 299                  revs << revision
 300                end
 301              end
 302            end
 303            revs
 304          rescue ScmCommandAborted => e
 305            err_msg = "git log error: #{e.message}"
 306            logger.error(err_msg)
 307            if block_given?
 308              raise CommandFailed, err_msg
 309            else
 310              revs
 311            end
 312          end
 313 
 314          def diff(path, identifier_from, identifier_to=nil)
 315            path ||= ''
 316            cmd_args = []
 317            if identifier_to
 318              cmd_args << "diff" << "--no-color" <<  identifier_to << identifier_from
 319            else
 320              cmd_args << "show" << "--no-color" << identifier_from
 321            end
 322            cmd_args << "--" <<  scm_iconv(@path_encoding, 'UTF-8', path) unless path.empty?
 323            diff = []
 324            git_cmd(cmd_args) do |io|
 325              io.each_line do |line|
 326                diff << line
 327              end
 328            end
 329            diff
 330          rescue ScmCommandAborted
 331            nil
 332          end
 333 
 334          def annotate(path, identifier=nil)
 335            identifier = 'HEAD' if identifier.blank?
 336            cmd_args = %w|blame|
 337            cmd_args << "-p" << identifier << "--" <<  scm_iconv(@path_encoding, 'UTF-8', path)
 338            blame = Annotate.new
 339            content = nil
 340            git_cmd(cmd_args) { |io| io.binmode; content = io.read }
 341            # git annotates binary files
 342            return nil if content.is_binary_data?
 343            identifier = ''
 344            # git shows commit author on the first occurrence only
 345            authors_by_commit = {}
 346            content.split("\n").each do |line|
 347              if line =~ /^([0-9a-f]{39,40})\s.*/
 348                identifier = $1
 349              elsif line =~ /^author (.+)/
 350                authors_by_commit[identifier] = $1.strip
 351              elsif line =~ /^\t(.*)/
 352                blame.add_line($1, Revision.new(
 353                                      :identifier => identifier,
 354                                      :revision   => identifier,
 355                                      :scmid      => identifier,
 356                                      :author     => authors_by_commit[identifier]
 357                                      ))
 358                identifier = ''
 359                author = ''
 360              end
 361            end
 362            blame
 363          rescue ScmCommandAborted
 364            nil
 365          end
 366 
 367          def cat(path, identifier=nil)
 368            if identifier.nil?
 369              identifier = 'HEAD'
 370            end
 371            cmd_args = %w|show --no-color|
 372            cmd_args << "#{identifier}:#{scm_iconv(@path_encoding, 'UTF-8', path)}"
 373            cat = nil
 374            git_cmd(cmd_args) do |io|
 375              io.binmode
 376              cat = io.read
 377            end
 378            cat
 379          rescue ScmCommandAborted
 380            nil
 381          end
 382 
 383          class Revision < Redmine::Scm::Adapters::Revision
 384            # Returns the readable identifier
 385            def format_identifier
 386              identifier[0,8]
 387            end
 388          end
 389 
 390          def git_cmd(args, options = {}, &block)
 391            repo_path = root_url || url
 392            full_args = ['--git-dir', repo_path]
 393            if self.class.client_version_above?([1, 7, 2])
 394              full_args << '-c' << 'core.quotepath=false'
 395              full_args << '-c' << 'log.decorate=no'
 396            end
 397            full_args += args
 398            ret = shellout(
 399                     self.class.sq_bin + ' ' + full_args.map { |e| shell_quote e.to_s }.join(' '),
 400                     options,
 401                     &block
 402                     )
 403            if $? && $?.exitstatus != 0
 404              raise ScmCommandAborted, "git exited with non-zero status: #{$?.exitstatus}"
 405            end
 406            ret
 407          end
 408          private :git_cmd
 409        end
 410      end
 411    end
 412  end