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