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

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