OSDN Git Service

scm: git: add "--no-decorate" option in "git log".
[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           bras = self.branches
105           return nil if bras.nil?
106           bras.include?('master') ? 'master' : bras.first
107         end
108
109         def entries(path=nil, identifier=nil)
110           path ||= ''
111           p = scm_iconv(@path_encoding, 'UTF-8', path)
112           entries = Entries.new
113           cmd_args = %w|ls-tree -l|
114           cmd_args << "HEAD:#{p}"          if identifier.nil?
115           cmd_args << "#{identifier}:#{p}" if identifier
116           scm_cmd(*cmd_args) do |io|
117             io.each_line do |line|
118               e = line.chomp.to_s
119               if e =~ /^\d+\s+(\w+)\s+([0-9a-f]{40})\s+([0-9-]+)\t(.+)$/
120                 type = $1
121                 sha  = $2
122                 size = $3
123                 name = $4
124                 if name.respond_to?(:force_encoding)
125                   name.force_encoding(@path_encoding)
126                 end
127                 full_path = p.empty? ? name : "#{p}/#{name}"
128                 n      = scm_iconv('UTF-8', @path_encoding, name)
129                 full_p = scm_iconv('UTF-8', @path_encoding, full_path)
130                 entries << Entry.new({:name => n,
131                  :path => full_p,
132                  :kind => (type == "tree") ? 'dir' : 'file',
133                  :size => (type == "tree") ? nil : size,
134                  :lastrev => @flag_report_last_commit ? lastrev(full_path, identifier) : Revision.new
135                 }) unless entries.detect{|entry| entry.name == name}
136               end
137             end
138           end
139           entries.sort_by_name
140         rescue ScmCommandAborted
141           nil
142         end
143
144         def lastrev(path, rev)
145           return nil if path.nil?
146           cmd_args = %w|log --no-decorate --no-color --encoding=UTF-8 --date=iso --pretty=fuller --no-merges -n 1|
147           cmd_args << rev if rev 
148           cmd_args << "--" << path unless path.empty?
149           lines = []
150           scm_cmd(*cmd_args) { |io| lines = io.readlines }
151           begin
152               id = lines[0].split[1]
153               author = lines[1].match('Author:\s+(.*)$')[1]
154               time = Time.parse(lines[4].match('CommitDate:\s+(.*)$')[1])
155
156               Revision.new({
157                 :identifier => id,
158                 :scmid => id,
159                 :author => author, 
160                 :time => time,
161                 :message => nil, 
162                 :paths => nil 
163                 })
164           rescue NoMethodError => e
165               logger.error("The revision '#{path}' has a wrong format")
166               return nil
167           end
168         rescue ScmCommandAborted
169           nil
170         end
171
172         def revisions(path, identifier_from, identifier_to, options={})
173           revisions = Revisions.new
174           cmd_args = %w|log --no-decorate --no-color --encoding=UTF-8 --raw --date=iso --pretty=fuller|
175           cmd_args << "--reverse" if options[:reverse]
176           cmd_args << "--all" if options[:all]
177           cmd_args << "-n" << "#{options[:limit].to_i}" if options[:limit]
178           from_to = ""
179           from_to << "#{identifier_from}.." if identifier_from
180           from_to << "#{identifier_to}" if identifier_to
181           cmd_args << from_to if !from_to.empty?
182           cmd_args << "--since=#{options[:since].strftime("%Y-%m-%d %H:%M:%S")}" if options[:since]
183           cmd_args << "--" << scm_iconv(@path_encoding, 'UTF-8', path) if path && !path.empty?
184
185           scm_cmd *cmd_args do |io|
186             files=[]
187             changeset = {}
188             parsing_descr = 0  #0: not parsing desc or files, 1: parsing desc, 2: parsing files
189
190             io.each_line do |line|
191               if line =~ /^commit ([0-9a-f]{40})$/
192                 key = "commit"
193                 value = $1
194                 if (parsing_descr == 1 || parsing_descr == 2)
195                   parsing_descr = 0
196                   revision = Revision.new({
197                     :identifier => changeset[:commit],
198                     :scmid => changeset[:commit],
199                     :author => changeset[:author],
200                     :time => Time.parse(changeset[:date]),
201                     :message => changeset[:description],
202                     :paths => files
203                   })
204                   if block_given?
205                     yield revision
206                   else
207                     revisions << revision
208                   end
209                   changeset = {}
210                   files = []
211                 end
212                 changeset[:commit] = $1
213               elsif (parsing_descr == 0) && line =~ /^(\w+):\s*(.*)$/
214                 key = $1
215                 value = $2
216                 if key == "Author"
217                   changeset[:author] = value
218                 elsif key == "CommitDate"
219                   changeset[:date] = value
220                 end
221               elsif (parsing_descr == 0) && line.chomp.to_s == ""
222                 parsing_descr = 1
223                 changeset[:description] = ""
224               elsif (parsing_descr == 1 || parsing_descr == 2) \
225                   && line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\t(.+)$/
226                 parsing_descr = 2
227                 fileaction    = $1
228                 filepath      = $2
229                 p = scm_iconv('UTF-8', @path_encoding, filepath)
230                 files << {:action => fileaction, :path => p}
231               elsif (parsing_descr == 1 || parsing_descr == 2) \
232                   && line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\d+\s+(\S+)\t(.+)$/
233                 parsing_descr = 2
234                 fileaction    = $1
235                 filepath      = $3
236                 p = scm_iconv('UTF-8', @path_encoding, filepath)
237                 files << {:action => fileaction, :path => p}
238               elsif (parsing_descr == 1) && line.chomp.to_s == ""
239                 parsing_descr = 2
240               elsif (parsing_descr == 1)
241                 changeset[:description] << line[4..-1]
242               end
243             end
244
245             if changeset[:commit]
246               revision = Revision.new({
247                 :identifier => changeset[:commit],
248                 :scmid => changeset[:commit],
249                 :author => changeset[:author],
250                 :time => Time.parse(changeset[:date]),
251                 :message => changeset[:description],
252                 :paths => files
253               })
254
255               if block_given?
256                 yield revision
257               else
258                 revisions << revision
259               end
260             end
261           end
262           revisions
263         rescue ScmCommandAborted
264           revisions
265         end
266
267         def diff(path, identifier_from, identifier_to=nil)
268           path ||= ''
269           cmd_args = []
270           if identifier_to
271             cmd_args << "diff" << "--no-color" <<  identifier_to << identifier_from
272           else
273             cmd_args << "show" << "--no-color" << identifier_from
274           end
275           cmd_args << "--" <<  scm_iconv(@path_encoding, 'UTF-8', path) unless path.empty?
276           diff = []
277           scm_cmd *cmd_args do |io|
278             io.each_line do |line|
279               diff << line
280             end
281           end
282           diff
283         rescue ScmCommandAborted
284           nil
285         end
286
287         def annotate(path, identifier=nil)
288           identifier = 'HEAD' if identifier.blank?
289           cmd_args = %w|blame|
290           cmd_args << "-p" << identifier << "--" <<  scm_iconv(@path_encoding, 'UTF-8', path)
291           blame = Annotate.new
292           content = nil
293           scm_cmd(*cmd_args) { |io| io.binmode; content = io.read }
294           # git annotates binary files
295           return nil if content.is_binary_data?
296           identifier = ''
297           # git shows commit author on the first occurrence only
298           authors_by_commit = {}
299           content.split("\n").each do |line|
300             if line =~ /^([0-9a-f]{39,40})\s.*/
301               identifier = $1
302             elsif line =~ /^author (.+)/
303               authors_by_commit[identifier] = $1.strip
304             elsif line =~ /^\t(.*)/
305               blame.add_line($1, Revision.new(
306                                     :identifier => identifier,
307                                     :author => authors_by_commit[identifier]))
308               identifier = ''
309               author = ''
310             end
311           end
312           blame
313         rescue ScmCommandAborted
314           nil
315         end
316
317         def cat(path, identifier=nil)
318           if identifier.nil?
319             identifier = 'HEAD'
320           end
321           cmd_args = %w|show --no-color|
322           cmd_args << "#{identifier}:#{scm_iconv(@path_encoding, 'UTF-8', path)}"
323           cat = nil
324           scm_cmd(*cmd_args) do |io|
325             io.binmode
326             cat = io.read
327           end
328           cat
329         rescue ScmCommandAborted
330           nil
331         end
332
333         class Revision < Redmine::Scm::Adapters::Revision
334           # Returns the readable identifier
335           def format_identifier
336             identifier[0,8]
337           end
338         end
339
340         def scm_cmd(*args, &block)
341           repo_path = root_url || url
342           full_args = [GIT_BIN, '--git-dir', repo_path]
343           if self.class.client_version_above?([1, 7, 2])
344             full_args << '-c' << 'core.quotepath=false'
345           end
346           full_args += args
347           ret = shellout(full_args.map { |e| shell_quote e.to_s }.join(' '), &block)
348           if $? && $?.exitstatus != 0
349             raise ScmCommandAborted, "git exited with non-zero status: #{$?.exitstatus}"
350           end
351           ret
352         end
353         private :scm_cmd
354       end
355     end
356   end
357 end