1 | <?php |
||
17 | class Commit extends Base |
||
18 | { |
||
19 | |||
20 | public static $apresentationName = 'Commits'; |
||
21 | |||
22 | protected $organizationPerspective = true; |
||
23 | |||
24 | protected $table = 'repository_commits'; |
||
25 | |||
26 | /** |
||
27 | * The attributes that are mass assignable. |
||
28 | * |
||
29 | * @var array |
||
30 | */ |
||
31 | protected $fillable = [ |
||
32 | 'code', |
||
33 | 'date', |
||
34 | 'author', |
||
35 | 'message', |
||
36 | 'reference' |
||
37 | ]; |
||
38 | |||
39 | public function stayInBranchOfAmbiente(Ambiente $ambiente) |
||
44 | |||
45 | public function getApresentationName() |
||
49 | |||
50 | public function repository() |
||
54 | |||
55 | |||
56 | /*# coding: utf-8 |
||
57 | # frozen_string_literal: true |
||
58 | |||
59 | class Commit |
||
60 | extend ActiveModel::Naming |
||
61 | extend Gitlab::Cache::RequestCache |
||
62 | |||
63 | include ActiveModel::Conversion |
||
64 | include Noteable |
||
65 | include Participable |
||
66 | include Mentionable |
||
67 | include Referable |
||
68 | include StaticModel |
||
69 | include Presentable |
||
70 | include ::Gitlab::Utils::StrongMemoize |
||
71 | |||
72 | attr_mentionable :safe_message, pipeline: :single_line |
||
73 | |||
74 | participant :author |
||
75 | participant :committer |
||
76 | participant :notes_with_associations |
||
77 | |||
78 | attr_accessor :project, :author |
||
79 | attr_accessor :redacted_description_html |
||
80 | attr_accessor :redacted_title_html |
||
81 | attr_accessor :redacted_full_title_html |
||
82 | attr_reader :gpg_commit |
||
83 | |||
84 | DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines] |
||
85 | |||
86 | # Commits above this size will not be rendered in HTML |
||
87 | DIFF_HARD_LIMIT_FILES = 1000 |
||
88 | DIFF_HARD_LIMIT_LINES = 50000 |
||
89 | |||
90 | MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH |
||
91 | COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze |
||
92 | # Used by GFM to match and present link extensions on node texts and hrefs. |
||
93 | LINK_EXTENSION_PATTERN = /(patch)/.freeze |
||
94 | |||
95 | def banzai_render_context(field) |
||
96 | pipeline = field == :description ? :commit_description : :single_line |
||
97 | context = { pipeline: pipeline, project: self.project } |
||
98 | context[:author] = self.author if self.author |
||
99 | |||
100 | context |
||
101 | end |
||
102 | |||
103 | class << self |
||
104 | def decorate(commits, project) |
||
105 | commits.map do |commit| |
||
106 | if commit.is_a?(Commit) |
||
107 | commit |
||
108 | else |
||
109 | self.new(commit, project) |
||
110 | end |
||
111 | end |
||
112 | end |
||
113 | |||
114 | # Calculate number of lines to render for diffs |
||
115 | def diff_line_count(diffs) |
||
116 | diffs.reduce(0) { |sum, d| sum + Gitlab::Git::Util.count_lines(d.diff) } |
||
117 | end |
||
118 | |||
119 | def order_by(collection:, order_by:, sort:) |
||
120 | return collection unless %w[email name commits].include?(order_by) |
||
121 | return collection unless %w[asc desc].include?(sort) |
||
122 | |||
123 | collection.sort do |a, b| |
||
124 | operands = [a, b].tap { |o| o.reverse! if sort == 'desc' } |
||
125 | |||
126 | attr1, attr2 = operands.first.public_send(order_by), operands.second.public_send(order_by) # rubocop:disable PublicSend |
||
127 | |||
128 | # use case insensitive comparison for string values |
||
129 | order_by.in?(%w[email name]) ? attr1.casecmp(attr2) : attr1 <=> attr2 |
||
130 | end |
||
131 | end |
||
132 | |||
133 | # Truncate sha to 8 characters |
||
134 | def truncate_sha(sha) |
||
135 | sha[0..MIN_SHA_LENGTH] |
||
136 | end |
||
137 | |||
138 | def max_diff_options |
||
139 | { |
||
140 | max_files: DIFF_HARD_LIMIT_FILES, |
||
141 | max_lines: DIFF_HARD_LIMIT_LINES |
||
142 | } |
||
143 | end |
||
144 | |||
145 | def from_hash(hash, project) |
||
146 | raw_commit = Gitlab::Git::Commit.new(project.repository.raw, hash) |
||
147 | new(raw_commit, project) |
||
148 | end |
||
149 | |||
150 | def valid_hash?(key) |
||
151 | !!(/\A#{COMMIT_SHA_PATTERN}\z/ =~ key) |
||
152 | end |
||
153 | |||
154 | def lazy(project, oid) |
||
155 | BatchLoader.for({ project: project, oid: oid }).batch do |items, loader| |
||
156 | items_by_project = items.group_by { |i| i[:project] } |
||
157 | |||
158 | items_by_project.each do |project, commit_ids| |
||
159 | oids = commit_ids.map { |i| i[:oid] } |
||
160 | |||
161 | project.repository.commits_by(oids: oids).each do |commit| |
||
162 | loader.call({ project: commit.project, oid: commit.id }, commit) if commit |
||
163 | end |
||
164 | end |
||
165 | end |
||
166 | end |
||
167 | |||
168 | def parent_class |
||
169 | ::Project |
||
170 | end |
||
171 | end |
||
172 | |||
173 | attr_accessor :raw |
||
174 | |||
175 | def initialize(raw_commit, project) |
||
176 | raise "Nil as raw commit passed" unless raw_commit |
||
177 | |||
178 | @raw = raw_commit |
||
179 | @project = project |
||
180 | @statuses = {} |
||
181 | @gpg_commit = Gitlab::Gpg::Commit.new(self) if project |
||
182 | end |
||
183 | |||
184 | def id |
||
185 | raw.id |
||
186 | end |
||
187 | |||
188 | def project_id |
||
189 | project.id |
||
190 | end |
||
191 | |||
192 | def ==(other) |
||
193 | other.is_a?(self.class) && raw == other.raw |
||
194 | end |
||
195 | |||
196 | def self.reference_prefix |
||
197 | '@' |
||
198 | end |
||
199 | |||
200 | # Pattern used to extract commit references from text |
||
201 | # |
||
202 | # This pattern supports cross-project references. |
||
203 | def self.reference_pattern |
||
204 | @reference_pattern ||= %r{ |
||
205 | (?:#{Project.reference_pattern}#{reference_prefix})? |
||
206 | (?<commit>#{COMMIT_SHA_PATTERN}) |
||
207 | }x |
||
208 | end |
||
209 | |||
210 | def self.link_reference_pattern |
||
211 | @link_reference_pattern ||= |
||
212 | super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})?(\.(?<extension>#{LINK_EXTENSION_PATTERN}))?/) |
||
213 | end |
||
214 | |||
215 | def to_reference(from = nil, full: false) |
||
216 | commit_reference(from, id, full: full) |
||
217 | end |
||
218 | |||
219 | def reference_link_text(from = nil, full: false) |
||
220 | commit_reference(from, short_id, full: full) |
||
221 | end |
||
222 | |||
223 | def diff_line_count |
||
224 | @diff_line_count ||= Commit.diff_line_count(raw_diffs) |
||
225 | @diff_line_count |
||
226 | end |
||
227 | |||
228 | # Returns the commits title. |
||
229 | # |
||
230 | # Usually, the commit title is the first line of the commit message. |
||
231 | # In case this first line is longer than 100 characters, it is cut off |
||
232 | # after 80 characters + `...` |
||
233 | def title |
||
234 | return full_title if full_title.length < 100 |
||
235 | |||
236 | # Use three dots instead of the ellipsis Unicode character because |
||
237 | # some clients show the raw Unicode value in the merge commit. |
||
238 | full_title.truncate(81, separator: ' ', omission: '...') |
||
239 | end |
||
240 | |||
241 | # Returns the full commits title |
||
242 | def full_title |
||
243 | @full_title ||= |
||
244 | if safe_message.blank? |
||
245 | no_commit_message |
||
246 | else |
||
247 | safe_message.split(/[\r\n]/, 2).first |
||
248 | end |
||
249 | end |
||
250 | |||
251 | # Returns full commit message if title is truncated (greater than 99 characters) |
||
252 | # otherwise returns commit message without first line |
||
253 | def description |
||
254 | return safe_message if full_title.length >= 100 |
||
255 | return no_commit_message if safe_message.blank? |
||
256 | |||
257 | safe_message.split("\n", 2)[1].try(:chomp) |
||
258 | end |
||
259 | |||
260 | def description? |
||
261 | description.present? |
||
262 | end |
||
263 | |||
264 | def hook_attrs(with_changed_files: false) |
||
265 | data = { |
||
266 | id: id, |
||
267 | message: safe_message, |
||
268 | timestamp: committed_date.xmlschema, |
||
269 | url: Gitlab::UrlBuilder.build(self), |
||
270 | author: { |
||
271 | name: author_name, |
||
272 | email: author_email |
||
273 | } |
||
274 | } |
||
275 | |||
276 | if with_changed_files |
||
277 | data.merge!(repo_changes) |
||
278 | end |
||
279 | |||
280 | data |
||
281 | end |
||
282 | |||
283 | # Discover issues should be closed when this commit is pushed to a project's |
||
284 | # default branch. |
||
285 | def closes_issues(current_user = self.committer) |
||
286 | Gitlab::ClosingIssueExtractor.new(project, current_user).closed_by_message(safe_message) |
||
287 | end |
||
288 | |||
289 | def lazy_author |
||
290 | BatchLoader.for(author_email.downcase).batch do |emails, loader| |
||
291 | users = User.by_any_email(emails).includes(:emails) |
||
292 | |||
293 | emails.each do |email| |
||
294 | user = users.find { |u| u.any_email?(email) } |
||
295 | |||
296 | loader.call(email, user) |
||
297 | end |
||
298 | end |
||
299 | end |
||
300 | |||
301 | def author |
||
302 | # We use __sync so that we get the actual objects back (including an actual |
||
303 | # nil), instead of a wrapper, as returning a wrapped nil breaks a lot of |
||
304 | # code. |
||
305 | lazy_author.__sync |
||
306 | end |
||
307 | request_cache(:author) { author_email.downcase } |
||
308 | |||
309 | def committer |
||
310 | @committer ||= User.find_by_any_email(committer_email) |
||
311 | end |
||
312 | |||
313 | def parents |
||
314 | @parents ||= parent_ids.map { |oid| Commit.lazy(project, oid) } |
||
315 | end |
||
316 | |||
317 | def parent |
||
318 | strong_memoize(:parent) do |
||
319 | project.commit_by(oid: self.parent_id) if self.parent_id |
||
320 | end |
||
321 | end |
||
322 | |||
323 | def notes |
||
324 | project.notes.for_commit_id(self.id) |
||
325 | end |
||
326 | |||
327 | def discussion_notes |
||
328 | notes.non_diff_notes |
||
329 | end |
||
330 | |||
331 | def notes_with_associations |
||
332 | notes.includes(:author, :award_emoji) |
||
333 | end |
||
334 | |||
335 | def merge_requests |
||
336 | @merge_requests ||= project.merge_requests.by_commit_sha(sha) |
||
337 | end |
||
338 | |||
339 | def method_missing(method, *args, &block) |
||
340 | @raw.__send__(method, *args, &block) # rubocop:disable GitlabSecurity/PublicSend |
||
341 | end |
||
342 | |||
343 | def respond_to_missing?(method, include_private = false) |
||
344 | @raw.respond_to?(method, include_private) || super |
||
345 | end |
||
346 | |||
347 | def short_id |
||
348 | @raw.short_id(MIN_SHA_LENGTH) |
||
349 | end |
||
350 | |||
351 | def diff_refs |
||
352 | Gitlab::Diff::DiffRefs.new( |
||
353 | base_sha: self.parent_id || Gitlab::Git::BLANK_SHA, |
||
354 | head_sha: self.sha |
||
355 | ) |
||
356 | end |
||
357 | |||
358 | def pipelines |
||
359 | project.ci_pipelines.where(sha: sha) |
||
360 | end |
||
361 | |||
362 | def last_pipeline |
||
363 | strong_memoize(:last_pipeline) do |
||
364 | pipelines.last |
||
365 | end |
||
366 | end |
||
367 | |||
368 | def status(ref = nil) |
||
369 | return @statuses[ref] if @statuses.key?(ref) |
||
370 | |||
371 | @statuses[ref] = status_for_project(ref, project) |
||
372 | end |
||
373 | |||
374 | def status_for_project(ref, pipeline_project) |
||
375 | pipeline_project.ci_pipelines.latest_status_per_commit(id, ref)[id] |
||
376 | end |
||
377 | |||
378 | def set_status_for_ref(ref, status) |
||
379 | @statuses[ref] = status |
||
380 | end |
||
381 | |||
382 | def signature |
||
383 | return @signature if defined?(@signature) |
||
384 | |||
385 | @signature = gpg_commit.signature |
||
386 | end |
||
387 | |||
388 | delegate :has_signature?, to: :gpg_commit |
||
389 | |||
390 | def revert_branch_name |
||
391 | "revert-#{short_id}" |
||
392 | end |
||
393 | |||
394 | def cherry_pick_branch_name |
||
395 | project.repository.next_branch("cherry-pick-#{short_id}", mild: true) |
||
396 | end |
||
397 | |||
398 | def cherry_pick_description(user) |
||
399 | message_body = ["(cherry picked from commit #{sha})"] |
||
400 | |||
401 | if merged_merge_request?(user) |
||
402 | commits_in_merge_request = merged_merge_request(user).commits |
||
403 | |||
404 | if commits_in_merge_request.present? |
||
405 | message_body << "" |
||
406 | |||
407 | commits_in_merge_request.reverse.each do |commit_in_merge| |
||
408 | message_body << "#{commit_in_merge.short_id} #{commit_in_merge.title}" |
||
409 | end |
||
410 | end |
||
411 | end |
||
412 | |||
413 | message_body.join("\n") |
||
414 | end |
||
415 | |||
416 | def cherry_pick_message(user) |
||
417 | %Q{#{message}\n\n#{cherry_pick_description(user)}} |
||
418 | end |
||
419 | |||
420 | def revert_description(user) |
||
421 | if merged_merge_request?(user) |
||
422 | "This reverts merge request #{merged_merge_request(user).to_reference}" |
||
423 | else |
||
424 | "This reverts commit #{sha}" |
||
425 | end |
||
426 | end |
||
427 | |||
428 | def revert_message(user) |
||
429 | %Q{Revert "#{title.strip}"\n\n#{revert_description(user)}} |
||
430 | end |
||
431 | |||
432 | def reverts_commit?(commit, user) |
||
433 | description? && description.include?(commit.revert_description(user)) |
||
434 | end |
||
435 | |||
436 | def merge_commit? |
||
437 | parent_ids.size > 1 |
||
438 | end |
||
439 | |||
440 | def merged_merge_request(current_user) |
||
441 | # Memoize with per-user access check |
||
442 | @merged_merge_request_hash ||= Hash.new do |hash, user| |
||
443 | hash[user] = merged_merge_request_no_cache(user) |
||
444 | end |
||
445 | |||
446 | @merged_merge_request_hash[current_user] |
||
447 | end |
||
448 | |||
449 | def has_been_reverted?(current_user, notes_association = nil) |
||
450 | ext = all_references(current_user) |
||
451 | notes_association ||= notes_with_associations |
||
452 | |||
453 | notes_association.system.each do |note| |
||
454 | note.all_references(current_user, extractor: ext) |
||
455 | end |
||
456 | |||
457 | ext.commits.any? { |commit_ref| commit_ref.reverts_commit?(self, current_user) } |
||
458 | end |
||
459 | |||
460 | def change_type_title(user) |
||
461 | merged_merge_request?(user) ? 'merge request' : 'commit' |
||
462 | end |
||
463 | |||
464 | # Get the URI type of the given path |
||
465 | # |
||
466 | # Used to build URLs to files in the repository in GFM. |
||
467 | # |
||
468 | # path - String path to check |
||
469 | # |
||
470 | # Examples: |
||
471 | # |
||
472 | # uri_type('doc/README.md') # => :blob |
||
473 | # uri_type('doc/logo.png') # => :raw |
||
474 | # uri_type('doc/api') # => :tree |
||
475 | # uri_type('not/found') # => nil |
||
476 | # |
||
477 | # Returns a symbol |
||
478 | def uri_type(path) |
||
479 | entry = @raw.tree_entry(path) |
||
480 | return unless entry |
||
481 | |||
482 | if entry[:type] == :blob |
||
483 | blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project) |
||
484 | blob.image? || blob.video? ? :raw : :blob |
||
485 | else |
||
486 | entry[:type] |
||
487 | end |
||
488 | end |
||
489 | |||
490 | def raw_diffs(*args) |
||
491 | raw.diffs(*args) |
||
492 | end |
||
493 | |||
494 | def raw_deltas |
||
495 | @deltas ||= raw.deltas |
||
496 | end |
||
497 | |||
498 | def diffs(diff_options = {}) |
||
499 | Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options) |
||
500 | end |
||
501 | |||
502 | def persisted? |
||
503 | true |
||
504 | end |
||
505 | |||
506 | def to_ability_name |
||
507 | model_name.singular |
||
508 | end |
||
509 | |||
510 | def touch |
||
511 | # no-op but needs to be defined since #persisted? is defined |
||
512 | end |
||
513 | |||
514 | def touch_later |
||
515 | # No-op. |
||
516 | # This method is called by ActiveRecord. |
||
517 | # We don't want to do anything for `Commit` model, so this is empty. |
||
518 | end |
||
519 | |||
520 | WIP_REGEX = /\A\s*(((?i)(\[WIP\]|WIP:|WIP)\s|WIP$))|(fixup!|squash!)\s/.freeze |
||
521 | |||
522 | def work_in_progress? |
||
523 | !!(title =~ WIP_REGEX) |
||
524 | end |
||
525 | |||
526 | def merged_merge_request?(user) |
||
527 | !!merged_merge_request(user) |
||
528 | end |
||
529 | |||
530 | def cache_key |
||
531 | "commit:#{sha}" |
||
532 | end |
||
533 | |||
534 | private |
||
535 | |||
536 | def commit_reference(from, referable_commit_id, full: false) |
||
537 | reference = project.to_reference(from, full: full) |
||
538 | |||
539 | if reference.present? |
||
540 | "#{reference}#{self.class.reference_prefix}#{referable_commit_id}" |
||
541 | else |
||
542 | referable_commit_id |
||
543 | end |
||
544 | end |
||
545 | |||
546 | def repo_changes |
||
547 | changes = { added: [], modified: [], removed: [] } |
||
548 | |||
549 | raw_deltas.each do |diff| |
||
550 | if diff.deleted_file |
||
551 | changes[:removed] << diff.old_path |
||
552 | elsif diff.renamed_file || diff.new_file |
||
553 | changes[:added] << diff.new_path |
||
554 | else |
||
555 | changes[:modified] << diff.new_path |
||
556 | end |
||
557 | end |
||
558 | |||
559 | changes |
||
560 | end |
||
561 | |||
562 | def merged_merge_request_no_cache(user) |
||
563 | MergeRequestsFinder.new(user, project_id: project.id).find_by(merge_commit_sha: id) if merge_commit? |
||
564 | end |
||
565 | end |
||
566 | */ |
||
567 | } |
||
568 |