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 class ScmFetchError < Exception; end
19
20 class Repository < ActiveRecord::Base
21 include Redmine::Ciphering
22
23 belongs_to :project
24 has_many :changesets, :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC"
25 has_many :changes, :through => :changesets
26
27 serialize :extra_info
28
29 before_save :check_default
30
31 # Raw SQL to delete changesets and changes in the database
32 # has_many :changesets, :dependent => :destroy is too slow for big repositories
33 before_destroy :clear_changesets
34
35 validates_length_of :password, :maximum => 255, :allow_nil => true
36 validates_length_of :identifier, :maximum => 255, :allow_blank => true
37 validates_presence_of :identifier, :unless => Proc.new { |r| r.is_default? || r.set_as_default? }
38 validates_uniqueness_of :identifier, :scope => :project_id, :allow_blank => true
39 validates_exclusion_of :identifier, :in => %w(show entry raw changes annotate diff show stats graph)
40 # donwcase letters, digits, dashes but not digits only
41 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :allow_blank => true
42 # Checks if the SCM is enabled when creating a repository
43 validate :repo_create_validation, :on => :create
44
45 def repo_create_validation
46 unless Setting.enabled_scm.include?(self.class.name.demodulize)
47 errors.add(:type, :invalid)
48 end
49 end
50
51 def self.human_attribute_name(attribute_key_name, *args)
52 attr_name = attribute_key_name.to_s
53 if attr_name == "log_encoding"
54 attr_name = "commit_logs_encoding"
55 end
56 super(attr_name, *args)
57 end
58
59 alias :attributes_without_extra_info= :attributes=
60 def attributes=(new_attributes, guard_protected_attributes = true)
61 return if new_attributes.nil?
62 attributes = new_attributes.dup
63 attributes.stringify_keys!
64
65 p = {}
66 p_extra = {}
67 attributes.each do |k, v|
68 if k =~ /^extra_/
69 p_extra[k] = v
70 else
71 p[k] = v
72 end
73 end
74
75 send :attributes_without_extra_info=, p, guard_protected_attributes
76 if p_extra.keys.any?
77 merge_extra_info(p_extra)
78 end
79 end
80
81 # Removes leading and trailing whitespace
82 def url=(arg)
83 write_attribute(:url, arg ? arg.to_s.strip : nil)
84 end
85
86 # Removes leading and trailing whitespace
87 def root_url=(arg)
88 write_attribute(:root_url, arg ? arg.to_s.strip : nil)
89 end
90
91 def password
92 read_ciphered_attribute(:password)
93 end
94
95 def password=(arg)
96 write_ciphered_attribute(:password, arg)
97 end
98
99 def scm_adapter
100 self.class.scm_adapter_class
101 end
102
103 def scm
104 unless @scm
105 @scm = self.scm_adapter.new(url, root_url,
106 login, password, path_encoding)
107 if root_url.blank? && @scm.root_url.present?
108 update_attribute(:root_url, @scm.root_url)
109 end
110 end
111 @scm
112 end
113
114 def scm_name
115 self.class.scm_name
116 end
117
118 def name
119 if identifier.present?
120 identifier
121 elsif is_default?
122 l(:field_repository_is_default)
123 else
124 scm_name
125 end
126 end
127
128 def identifier_param
129 if is_default?
130 nil
131 elsif identifier.present?
132 identifier
133 else
134 id.to_s
135 end
136 end
137
138 def <=>(repository)
139 if is_default?
140 -1
141 elsif repository.is_default?
142 1
143 else
144 identifier <=> repository.identifier
145 end
146 end
147
148 def self.find_by_identifier_param(param)
149 if param.to_s =~ /^\d+$/
150 find_by_id(param)
151 else
152 find_by_identifier(param)
153 end
154 end
155
156 def merge_extra_info(arg)
157 h = extra_info || {}
158 return h if arg.nil?
159 h.merge!(arg)
160 write_attribute(:extra_info, h)
161 end
162
163 def report_last_commit
164 true
165 end
166
167 def supports_cat?
168 scm.supports_cat?
169 end
170
171 def supports_annotate?
172 scm.supports_annotate?
173 end
174
175 def supports_all_revisions?
176 true
177 end
178
179 def supports_directory_revisions?
180 false
181 end
182
183 def supports_revision_graph?
184 false
185 end
186
187 def entry(path=nil, identifier=nil)
188 scm.entry(path, identifier)
189 end
190
191 def entries(path=nil, identifier=nil)
192 scm.entries(path, identifier)
193 end
194
195 def branches
196 scm.branches
197 end
198
199 def tags
200 scm.tags
201 end
202
203 def default_branch
204 nil
205 end
206
207 def properties(path, identifier=nil)
208 scm.properties(path, identifier)
209 end
210
211 def cat(path, identifier=nil)
212 scm.cat(path, identifier)
213 end
214
215 def diff(path, rev, rev_to)
216 scm.diff(path, rev, rev_to)
217 end
218
219 def diff_format_revisions(cs, cs_to, sep=':')
220 text = ""
221 text << cs_to.format_identifier + sep if cs_to
222 text << cs.format_identifier if cs
223 text
224 end
225
226 # Returns a path relative to the url of the repository
227 def relative_path(path)
228 path
229 end
230
231 # Finds and returns a revision with a number or the beginning of a hash
232 def find_changeset_by_name(name)
233 return nil if name.blank?
234 s = name.to_s
235 changesets.find(:first, :conditions => (s.match(/^\d*$/) ?
236 ["revision = ?", s] : ["revision LIKE ?", s + '%']))
237 end
238
239 def latest_changeset
240 @latest_changeset ||= changesets.find(:first)
241 end
242
243 # Returns the latest changesets for +path+
244 # Default behaviour is to search in cached changesets
245 def latest_changesets(path, rev, limit=10)
246 if path.blank?
247 changesets.find(
248 :all,
249 :include => :user,
250 :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC",
251 :limit => limit)
252 else
253 changes.find(
254 :all,
255 :include => {:changeset => :user},
256 :conditions => ["path = ?", path.with_leading_slash],
257 :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC",
258 :limit => limit
259 ).collect(&:changeset)
260 end
261 end
262
263 def scan_changesets_for_issue_ids
264 self.changesets.each(&:scan_comment_for_issue_ids)
265 end
266
267 # Returns an array of committers usernames and associated user_id
268 def committers
269 @committers ||= Changeset.connection.select_rows(
270 "SELECT DISTINCT committer, user_id FROM #{Changeset.table_name} WHERE repository_id = #{id}")
271 end
272
273 # Maps committers username to a user ids
274 def committer_ids=(h)
275 if h.is_a?(Hash)
276 committers.each do |committer, user_id|
277 new_user_id = h[committer]
278 if new_user_id && (new_user_id.to_i != user_id.to_i)
279 new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
280 Changeset.update_all(
281 "user_id = #{ new_user_id.nil? ? 'NULL' : new_user_id }",
282 ["repository_id = ? AND committer = ?", id, committer])
283 end
284 end
285 @committers = nil
286 @found_committer_users = nil
287 true
288 else
289 false
290 end
291 end
292
293 # Returns the Redmine User corresponding to the given +committer+
294 # It will return nil if the committer is not yet mapped and if no User
295 # with the same username or email was found
296 def find_committer_user(committer)
297 unless committer.blank?
298 @found_committer_users ||= {}
299 return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
300
301 user = nil
302 c = changesets.find(:first, :conditions => {:committer => committer}, :include => :user)
303 if c && c.user
304 user = c.user
305 elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
306 username, email = $1.strip, $3
307 u = User.find_by_login(username)
308 u ||= User.find_by_mail(email) unless email.blank?
309 user = u
310 end
311 @found_committer_users[committer] = user
312 user
313 end
314 end
315
316 def repo_log_encoding
317 encoding = log_encoding.to_s.strip
318 encoding.blank? ? 'UTF-8' : encoding
319 end
320
321 # Fetches new changesets for all repositories of active projects
322 # Can be called periodically by an external script
323 # eg. ruby script/runner "Repository.fetch_changesets"
324 def self.fetch_changesets
325 Project.active.has_module(:repository).all.each do |project|
326 project.repositories.each do |repository|
327 begin
328 repository.fetch_changesets
329 rescue Redmine::Scm::Adapters::CommandFailed => e
330 logger.error "scm: error during fetching changesets: #{e.message}"
331 end
332 end
333 end
334 end
335
336 # scan changeset comments to find related and fixed issues for all repositories
337 def self.scan_changesets_for_issue_ids
338 find(:all).each(&:scan_changesets_for_issue_ids)
339 end
340
341 def self.scm_name
342 'Abstract'
343 end
344
345 def self.available_scm
346 subclasses.collect {|klass| [klass.scm_name, klass.name]}
347 end
348
349 def self.factory(klass_name, *args)
350 klass = "Repository::#{klass_name}".constantize
351 klass.new(*args)
352 rescue
353 nil
354 end
355
356 def self.scm_adapter_class
357 nil
358 end
359
360 def self.scm_command
361 ret = ""
362 begin
363 ret = self.scm_adapter_class.client_command if self.scm_adapter_class
364 rescue Exception => e
365 logger.error "scm: error during get command: #{e.message}"
366 end
367 ret
368 end
369
370 def self.scm_version_string
371 ret = ""
372 begin
373 ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
374 rescue Exception => e
375 logger.error "scm: error during get version string: #{e.message}"
376 end
377 ret
378 end
379
380 def self.scm_available
381 ret = false
382 begin
383 ret = self.scm_adapter_class.client_available if self.scm_adapter_class
384 rescue Exception => e
385 logger.error "scm: error during get scm available: #{e.message}"
386 end
387 ret
388 end
389
390 def set_as_default?
391 new_record? && project && !Repository.first(:conditions => {:project_id => project.id})
392 end
393
394 protected
395
396 def check_default
397 if !is_default? && set_as_default?
398 self.is_default = true
399 end
400 if is_default? && is_default_changed?
401 Repository.update_all(["is_default = ?", false], ["project_id = ?", project_id])
402 end
403 end
404
405 private
406
407 # Deletes repository data
408 def clear_changesets
409 cs = Changeset.table_name
410 ch = Change.table_name
411 ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
412 cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
413
414 connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
415 connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
416 connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
417 connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
418 end
419 end