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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. # frozen_string_literal: true
  2. # Redmine - project management software
  3. # Copyright (C) 2006-2023 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 => true
  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. ActiveSupport::Deprecation.warn 'Repository#supports_all_revisions? is deprecated and will be removed in Redmine 6.0. Please use #supports_history instead.'
  156. supports_history?
  157. end
  158. def supports_history?
  159. true
  160. end
  161. def supports_directory_revisions?
  162. false
  163. end
  164. def supports_revision_graph?
  165. false
  166. end
  167. def entry(path=nil, identifier=nil)
  168. scm.entry(path, identifier)
  169. end
  170. def scm_entries(path=nil, identifier=nil)
  171. scm.entries(path, identifier)
  172. end
  173. protected :scm_entries
  174. def entries(path=nil, identifier=nil)
  175. entries = scm_entries(path, identifier)
  176. load_entries_changesets(entries)
  177. entries
  178. end
  179. def branches
  180. scm.branches
  181. end
  182. def tags
  183. scm.tags
  184. end
  185. def default_branch
  186. nil
  187. end
  188. def properties(path, identifier=nil)
  189. scm.properties(path, identifier)
  190. end
  191. def cat(path, identifier=nil)
  192. scm.cat(path, identifier)
  193. end
  194. def diff(path, rev, rev_to)
  195. scm.diff(path, rev, rev_to)
  196. end
  197. def diff_format_revisions(cs, cs_to, sep=':')
  198. text = ""
  199. text += cs_to.format_identifier + sep if cs_to
  200. text += cs.format_identifier if cs
  201. text
  202. end
  203. # Returns a path relative to the url of the repository
  204. def relative_path(path)
  205. path
  206. end
  207. # Finds and returns a revision with a number or the beginning of a hash
  208. def find_changeset_by_name(name)
  209. return nil if name.blank?
  210. s = name.to_s
  211. if /^\d*$/.match?(s)
  212. changesets.find_by(:revision => s)
  213. else
  214. changesets.where("revision LIKE ?", s + '%').first
  215. end
  216. end
  217. def latest_changeset
  218. @latest_changeset ||= changesets.first
  219. end
  220. # Returns the latest changesets for +path+
  221. # Default behaviour is to search in cached changesets
  222. def latest_changesets(path, rev, limit=10)
  223. if path.blank?
  224. changesets.
  225. reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
  226. limit(limit).
  227. preload(:user).
  228. to_a
  229. else
  230. filechanges.
  231. where("path = ?", path.with_leading_slash).
  232. reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
  233. limit(limit).
  234. preload(:changeset => :user).
  235. collect(&:changeset)
  236. end
  237. end
  238. def scan_changesets_for_issue_ids
  239. self.changesets.each(&:scan_comment_for_issue_ids)
  240. end
  241. # Returns an array of committers usernames and associated user_id
  242. def committers
  243. @committers ||= Changeset.where(:repository_id => id).distinct.pluck(:committer, :user_id)
  244. end
  245. # Maps committers username to a user ids
  246. def committer_ids=(h)
  247. if h.is_a?(Hash)
  248. committers.each do |committer, user_id|
  249. new_user_id = h[committer]
  250. if new_user_id && (new_user_id.to_i != user_id.to_i)
  251. new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
  252. Changeset.where(["repository_id = ? AND committer = ?", id, committer]).
  253. update_all("user_id = #{new_user_id.nil? ? 'NULL' : new_user_id}")
  254. end
  255. end
  256. @committers = nil
  257. @found_committer_users = nil
  258. true
  259. else
  260. false
  261. end
  262. end
  263. # Returns the Redmine User corresponding to the given +committer+
  264. # It will return nil if the committer is not yet mapped and if no User
  265. # with the same username or email was found
  266. def find_committer_user(committer)
  267. unless committer.blank?
  268. @found_committer_users ||= {}
  269. return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
  270. user = nil
  271. c = changesets.where(:committer => committer).
  272. includes(:user).references(:user).first
  273. if c && c.user
  274. user = c.user
  275. elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
  276. username, email = $1.strip, $3
  277. u = User.find_by_login(username)
  278. u ||= User.find_by_mail(email) unless email.blank?
  279. user = u
  280. end
  281. @found_committer_users[committer] = user
  282. user
  283. end
  284. end
  285. def repo_log_encoding
  286. encoding = log_encoding.to_s.strip
  287. encoding.blank? ? 'UTF-8' : encoding
  288. end
  289. # Fetches new changesets for all repositories of active projects
  290. # Can be called periodically by an external script
  291. # eg. ruby script/runner "Repository.fetch_changesets"
  292. def self.fetch_changesets
  293. Project.active.has_module(:repository).all.each do |project|
  294. project.repositories.each do |repository|
  295. begin
  296. repository.fetch_changesets
  297. rescue Redmine::Scm::Adapters::CommandFailed => e
  298. logger.error "scm: error during fetching changesets: #{e.message}"
  299. end
  300. end
  301. end
  302. end
  303. # scan changeset comments to find related and fixed issues for all repositories
  304. def self.scan_changesets_for_issue_ids
  305. all.each(&:scan_changesets_for_issue_ids)
  306. end
  307. def self.scm_name
  308. 'Abstract'
  309. end
  310. def self.available_scm
  311. subclasses.collect {|klass| [klass.scm_name, klass.name]}
  312. end
  313. def self.factory(klass_name, *args)
  314. repository_class(klass_name).new(*args) rescue nil
  315. end
  316. def self.repository_class(class_name)
  317. class_name = class_name.to_s.camelize
  318. if Redmine::Scm::Base.all.include?(class_name)
  319. "Repository::#{class_name}".constantize
  320. end
  321. end
  322. def self.scm_adapter_class
  323. nil
  324. end
  325. def self.scm_command
  326. ret = ""
  327. begin
  328. ret = self.scm_adapter_class.client_command if self.scm_adapter_class
  329. rescue => e
  330. logger.error "scm: error during get command: #{e.message}"
  331. end
  332. ret
  333. end
  334. def self.scm_version_string
  335. ret = ""
  336. begin
  337. ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
  338. rescue => e
  339. logger.error "scm: error during get version string: #{e.message}"
  340. end
  341. ret
  342. end
  343. def self.scm_available
  344. ret = false
  345. begin
  346. ret = self.scm_adapter_class.client_available if self.scm_adapter_class
  347. rescue => e
  348. logger.error "scm: error during get scm available: #{e.message}"
  349. end
  350. ret
  351. end
  352. def set_as_default?
  353. new_record? && project && Repository.where(:project_id => project.id).empty?
  354. end
  355. # Returns a hash with statistics by author in the following form:
  356. # {
  357. # "John Smith" => { :commits => 45, :changes => 324 },
  358. # "Bob" => { ... }
  359. # }
  360. #
  361. # Notes:
  362. # - this hash honnors the users mapping defined for the repository
  363. def stats_by_author
  364. commits = Changeset.where("repository_id = ?", id).
  365. select("committer, user_id, count(*) as count").group("committer, user_id")
  366. # TODO: restore ordering ; this line probably never worked
  367. # commits.to_a.sort! {|x, y| x.last <=> y.last}
  368. changes = Change.joins(:changeset).where("#{Changeset.table_name}.repository_id = ?", id).
  369. select("committer, user_id, count(*) as count").group("committer, user_id")
  370. user_ids = changesets.filter_map(&:user_id).uniq
  371. authors_names = User.where(:id => user_ids).inject({}) do |memo, user|
  372. memo[user.id] = user.to_s
  373. memo
  374. end
  375. (commits + changes).inject({}) do |hash, element|
  376. mapped_name = element.committer
  377. if username = authors_names[element.user_id.to_i]
  378. mapped_name = username
  379. end
  380. hash[mapped_name] ||= {:commits_count => 0, :changes_count => 0}
  381. if element.is_a?(Changeset)
  382. hash[mapped_name][:commits_count] += element.count.to_i
  383. else
  384. hash[mapped_name][:changes_count] += element.count.to_i
  385. end
  386. hash
  387. end
  388. end
  389. # Returns a scope of changesets that come from the same commit as the given changeset
  390. # in different repositories that point to the same backend
  391. def same_commits_in_scope(scope, changeset)
  392. scope = scope.joins(:repository).where(:repositories => {:url => url, :root_url => root_url, :type => type})
  393. if changeset.scmid.present?
  394. scope = scope.where(:scmid => changeset.scmid)
  395. else
  396. scope = scope.where(:revision => changeset.revision)
  397. end
  398. scope
  399. end
  400. def valid_name?(name)
  401. scm.valid_name?(name)
  402. end
  403. protected
  404. # Validates repository url based against an optional regular expression
  405. # that can be set in the Redmine configuration file.
  406. def validate_repository_path(attribute=:url)
  407. regexp = Redmine::Configuration["scm_#{scm_name.to_s.downcase}_path_regexp"]
  408. if changes[attribute] && regexp.present?
  409. regexp = regexp.to_s.strip.gsub('%project%') {Regexp.escape(project.try(:identifier).to_s)}
  410. unless Regexp.new("\\A#{regexp}\\z").match?(send(attribute).to_s)
  411. errors.add(attribute, :invalid)
  412. end
  413. end
  414. end
  415. def normalize_identifier
  416. self.identifier = identifier.to_s.strip
  417. end
  418. def check_default
  419. if !is_default? && set_as_default?
  420. self.is_default = true
  421. end
  422. if is_default? && is_default_changed?
  423. Repository.where(["project_id = ?", project_id]).update_all(["is_default = ?", false])
  424. end
  425. end
  426. def load_entries_changesets(entries)
  427. if entries
  428. entries.each do |entry|
  429. if entry.lastrev && entry.lastrev.identifier
  430. entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
  431. end
  432. end
  433. end
  434. end
  435. private
  436. # Deletes repository data
  437. def clear_changesets
  438. cs = Changeset.table_name
  439. ch = Change.table_name
  440. ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
  441. cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
  442. self.class.connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
  443. self.class.connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
  444. self.class.connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
  445. self.class.connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
  446. end
  447. end