comparison lib/redmine/scm/adapters/git_adapter.rb @ 511:107d36338b70 live

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