OSDN Git Service

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