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

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