OSDN Git Service

scm: git: back out r5673 (#7146).
[redminele/redmine.git] / lib / redmine / scm / adapters / git_adapter.rb
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         # raised if scm command exited with error, e.g. unknown revision.
29         class ScmCommandAborted < CommandFailed; end
30
31         class << self
32           def client_command
33             @@bin    ||= GIT_BIN
34           end
35
36           def sq_bin
37             @@sq_bin ||= shell_quote(GIT_BIN)
38           end
39
40           def client_version
41             @@client_version ||= (scm_command_version || [])
42           end
43
44           def client_available
45             !client_version.empty?
46           end
47
48           def scm_command_version
49             scm_version = scm_version_from_command_line.dup
50             if scm_version.respond_to?(:force_encoding)
51               scm_version.force_encoding('ASCII-8BIT')
52             end
53             if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)})
54               m[2].scan(%r{\d+}).collect(&:to_i)
55             end
56           end
57
58           def scm_version_from_command_line
59             shellout("#{sq_bin} --version --no-color") { |io| io.read }.to_s
60           end
61         end
62
63         def initialize(url, root_url=nil, login=nil, password=nil, path_encoding=nil)
64           super
65           @path_encoding = path_encoding.blank? ? 'UTF-8' : path_encoding
66         end
67
68         def info
69           begin
70             Info.new(:root_url => url, :lastrev => lastrev('',nil))
71           rescue
72             nil
73           end
74         end
75
76         def branches
77           return @branches if @branches
78           @branches = []
79           cmd_args = %w|branch --no-color|
80           scm_cmd(*cmd_args) do |io|
81             io.each_line do |line|
82               @branches << line.match('\s*\*?\s*(.*)$')[1]
83             end
84           end
85           @branches.sort!
86         rescue ScmCommandAborted
87           nil
88         end
89
90         def tags
91           return @tags if @tags
92           cmd_args = %w|tag|
93           scm_cmd(*cmd_args) do |io|
94             @tags = io.readlines.sort!.map{|t| t.strip}
95           end
96         rescue ScmCommandAborted
97           nil
98         end
99
100         def default_branch
101           bras = self.branches
102           return nil if bras.nil?
103           bras.include?('master') ? 'master' : bras.first
104         end
105
106         def entry(path=nil, identifier=nil)
107           parts = path.to_s.split(%r{[\/\\]}).select {|n| !n.blank?}
108           search_path = parts[0..-2].join('/')
109           search_name = parts[-1]
110           if search_path.blank? && search_name.blank?
111             # Root entry
112             Entry.new(:path => '', :kind => 'dir')
113           else
114             # Search for the entry in the parent directory
115             es = entries(search_path, identifier,
116                          options = {:report_last_commit => false})
117             es ? es.detect {|e| e.name == search_name} : nil
118           end
119         end
120
121         def entries(path=nil, identifier=nil, options={})
122           path ||= ''
123           p = scm_iconv(@path_encoding, 'UTF-8', path)
124           entries = Entries.new
125           cmd_args = %w|ls-tree -l|
126           cmd_args << "HEAD:#{p}"          if identifier.nil?
127           cmd_args << "#{identifier}:#{p}" if identifier
128           scm_cmd(*cmd_args) do |io|
129             io.each_line do |line|
130               e = line.chomp.to_s
131               if e =~ /^\d+\s+(\w+)\s+([0-9a-f]{40})\s+([0-9-]+)\t(.+)$/
132                 type = $1
133                 sha  = $2
134                 size = $3
135                 name = $4
136                 if name.respond_to?(:force_encoding)
137                   name.force_encoding(@path_encoding)
138                 end
139                 full_path = p.empty? ? name : "#{p}/#{name}"
140                 n      = scm_iconv('UTF-8', @path_encoding, name)
141                 full_p = scm_iconv('UTF-8', @path_encoding, full_path)
142                 entries << Entry.new({:name => n,
143                  :path => full_p,
144                  :kind => (type == "tree") ? 'dir' : 'file',
145                  :size => (type == "tree") ? nil : size,
146                  :lastrev => options[:report_last_commit] ?
147                                  lastrev(full_path, identifier) : Revision.new
148                 }) unless entries.detect{|entry| entry.name == name}
149               end
150             end
151           end
152           entries.sort_by_name
153         rescue ScmCommandAborted
154           nil
155         end
156
157         def lastrev(path, rev)
158           return nil if path.nil?
159           cmd_args = %w|log --no-color --encoding=UTF-8 --date=iso --pretty=fuller --no-merges -n 1|
160           cmd_args << rev if rev
161           cmd_args << "--" << path unless path.empty?
162           lines = []
163           scm_cmd(*cmd_args) { |io| lines = io.readlines }
164           begin
165               id = lines[0].split[1]
166               author = lines[1].match('Author:\s+(.*)$')[1]
167               time = Time.parse(lines[4].match('CommitDate:\s+(.*)$')[1])
168
169               Revision.new({
170                 :identifier => id,
171                 :scmid      => id,
172                 :author     => author,
173                 :time       => time,
174                 :message    => nil,
175                 :paths      => nil
176                 })
177           rescue NoMethodError => e
178               logger.error("The revision '#{path}' has a wrong format")
179               return nil
180           end
181         rescue ScmCommandAborted
182           nil
183         end
184
185         def revisions(path, identifier_from, identifier_to, options={})
186           revs = Revisions.new
187           cmd_args = %w|log --no-color --encoding=UTF-8 --raw --date=iso --pretty=fuller|
188           cmd_args << "--reverse" if options[:reverse]
189           cmd_args << "--all" if options[:all]
190           cmd_args << "-n" << "#{options[:limit].to_i}" if options[:limit]
191           from_to = ""
192           from_to << "#{identifier_from}.." if identifier_from
193           from_to << "#{identifier_to}" if identifier_to
194           cmd_args << from_to if !from_to.empty?
195           cmd_args << "--since=#{options[:since].strftime("%Y-%m-%d %H:%M:%S")}" if options[:since]
196           cmd_args << "--" << scm_iconv(@path_encoding, 'UTF-8', path) if path && !path.empty?
197
198           scm_cmd *cmd_args do |io|
199             files=[]
200             changeset = {}
201             parsing_descr = 0  #0: not parsing desc or files, 1: parsing desc, 2: parsing files
202
203             io.each_line do |line|
204               if line =~ /^commit ([0-9a-f]{40})$/
205                 key = "commit"
206                 value = $1
207                 if (parsing_descr == 1 || parsing_descr == 2)
208                   parsing_descr = 0
209                   revision = Revision.new({
210                     :identifier => changeset[:commit],
211                     :scmid      => changeset[:commit],
212                     :author     => changeset[:author],
213                     :time       => Time.parse(changeset[:date]),
214                     :message    => changeset[:description],
215                     :paths      => files
216                   })
217                   if block_given?
218                     yield revision
219                   else
220                     revs << revision
221                   end
222                   changeset = {}
223                   files = []
224                 end
225                 changeset[:commit] = $1
226               elsif (parsing_descr == 0) && line =~ /^(\w+):\s*(.*)$/
227                 key = $1
228                 value = $2
229                 if key == "Author"
230                   changeset[:author] = value
231                 elsif key == "CommitDate"
232                   changeset[:date] = value
233                 end
234               elsif (parsing_descr == 0) && line.chomp.to_s == ""
235                 parsing_descr = 1
236                 changeset[:description] = ""
237               elsif (parsing_descr == 1 || parsing_descr == 2) \
238                   && line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\t(.+)$/
239                 parsing_descr = 2
240                 fileaction    = $1
241                 filepath      = $2
242                 p = scm_iconv('UTF-8', @path_encoding, filepath)
243                 files << {:action => fileaction, :path => p}
244               elsif (parsing_descr == 1 || parsing_descr == 2) \
245                   && line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\d+\s+(\S+)\t(.+)$/
246                 parsing_descr = 2
247                 fileaction    = $1
248                 filepath      = $3
249                 p = scm_iconv('UTF-8', @path_encoding, filepath)
250                 files << {:action => fileaction, :path => p}
251               elsif (parsing_descr == 1) && line.chomp.to_s == ""
252                 parsing_descr = 2
253               elsif (parsing_descr == 1)
254                 changeset[:description] << line[4..-1]
255               end
256             end
257
258             if changeset[:commit]
259               revision = Revision.new({
260                 :identifier => changeset[:commit],
261                 :scmid      => changeset[:commit],
262                 :author     => changeset[:author],
263                 :time       => Time.parse(changeset[:date]),
264                 :message    => changeset[:description],
265                 :paths      => files
266                  })
267               if block_given?
268                 yield revision
269               else
270                 revs << revision
271               end
272             end
273           end
274           revs
275         rescue ScmCommandAborted => e
276           logger.error("git log #{from_to.to_s} error: #{e.message}")
277           revs
278         end
279
280         def diff(path, identifier_from, identifier_to=nil)
281           path ||= ''
282           cmd_args = []
283           if identifier_to
284             cmd_args << "diff" << "--no-color" <<  identifier_to << identifier_from
285           else
286             cmd_args << "show" << "--no-color" << identifier_from
287           end
288           cmd_args << "--" <<  scm_iconv(@path_encoding, 'UTF-8', path) unless path.empty?
289           diff = []
290           scm_cmd *cmd_args do |io|
291             io.each_line do |line|
292               diff << line
293             end
294           end
295           diff
296         rescue ScmCommandAborted
297           nil
298         end
299
300         def annotate(path, identifier=nil)
301           identifier = 'HEAD' if identifier.blank?
302           cmd_args = %w|blame|
303           cmd_args << "-p" << identifier << "--" <<  scm_iconv(@path_encoding, 'UTF-8', path)
304           blame = Annotate.new
305           content = nil
306           scm_cmd(*cmd_args) { |io| io.binmode; content = io.read }
307           # git annotates binary files
308           return nil if content.is_binary_data?
309           identifier = ''
310           # git shows commit author on the first occurrence only
311           authors_by_commit = {}
312           content.split("\n").each do |line|
313             if line =~ /^([0-9a-f]{39,40})\s.*/
314               identifier = $1
315             elsif line =~ /^author (.+)/
316               authors_by_commit[identifier] = $1.strip
317             elsif line =~ /^\t(.*)/
318               blame.add_line($1, Revision.new(
319                                     :identifier => identifier,
320                                     :revision   => identifier,
321                                     :scmid      => identifier,
322                                     :author     => authors_by_commit[identifier]
323                                     ))
324               identifier = ''
325               author = ''
326             end
327           end
328           blame
329         rescue ScmCommandAborted
330           nil
331         end
332
333         def cat(path, identifier=nil)
334           if identifier.nil?
335             identifier = 'HEAD'
336           end
337           cmd_args = %w|show --no-color|
338           cmd_args << "#{identifier}:#{scm_iconv(@path_encoding, 'UTF-8', path)}"
339           cat = nil
340           scm_cmd(*cmd_args) do |io|
341             io.binmode
342             cat = io.read
343           end
344           cat
345         rescue ScmCommandAborted
346           nil
347         end
348
349         class Revision < Redmine::Scm::Adapters::Revision
350           # Returns the readable identifier
351           def format_identifier
352             identifier[0,8]
353           end
354         end
355
356         def scm_cmd(*args, &block)
357           repo_path = root_url || url
358           full_args = [GIT_BIN, '--git-dir', repo_path]
359           if self.class.client_version_above?([1, 7, 2])
360             full_args << '-c' << 'core.quotepath=false'
361             full_args << '-c' << 'log.decorate=no'
362           end
363           full_args += args
364           ret = shellout(full_args.map { |e| shell_quote e.to_s }.join(' '), &block)
365           if $? && $?.exitstatus != 0
366             raise ScmCommandAborted, "git exited with non-zero status: #{$?.exitstatus}"
367           end
368           ret
369         end
370         private :scm_cmd
371       end
372     end
373   end
374 end