You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

repository.rb 14KB

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