commit.rb 14.2 KB
Newer Older
1
2
# frozen_string_literal: true

3
# Gitlab::Git::Commit is a wrapper around Gitaly::GitCommit
Robert Speicher's avatar
Robert Speicher committed
4
5
6
module Gitlab
  module Git
    class Commit
7
      include Gitlab::EncodingHelper
8
      prepend Gitlab::Git::RuggedImpl::Commit
9
      extend Gitlab::Git::WrapsGitalyErrors
10
      include Gitlab::Utils::StrongMemoize
Robert Speicher's avatar
Robert Speicher committed
11

12
      attr_accessor :raw_commit, :head
Robert Speicher's avatar
Robert Speicher committed
13

14
      MAX_COMMIT_MESSAGE_DISPLAY_SIZE = 10.megabytes
15
      MIN_SHA_LENGTH = 7
Robert Speicher's avatar
Robert Speicher committed
16
17
18
19
20
21
      SERIALIZE_KEYS = [
        :id, :message, :parent_ids,
        :authored_date, :author_name, :author_email,
        :committed_date, :committer_name, :committer_email
      ].freeze

22
      attr_accessor(*SERIALIZE_KEYS)
Robert Speicher's avatar
Robert Speicher committed
23
24
25
26

      def ==(other)
        return false unless other.is_a?(Gitlab::Git::Commit)

27
        id && id == other.id
Robert Speicher's avatar
Robert Speicher committed
28
29
      end

30
31
32
33
      def hpd_display_id
        @repository.commit_id_to_display_id(id)
      end

Robert Speicher's avatar
Robert Speicher committed
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
      class << self
        # Get commits collection
        #
        # Ex.
        #   Commit.where(
        #     repo: repo,
        #     ref: 'master',
        #     path: 'app/models',
        #     limit: 10,
        #     offset: 5,
        #   )
        #
        def where(options)
          repo = options.delete(:repo)
          raise 'Gitlab::Git::Repository is required' unless repo.respond_to?(:log)

50
          repo.log(options)
Robert Speicher's avatar
Robert Speicher committed
51
52
53
54
55
56
57
58
59
        end

        # Get single commit
        #
        # Ex.
        #   Commit.find(repo, '29eda46b')
        #
        #   Commit.find(repo, 'master')
        #
60
        # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/321
Robert Speicher's avatar
Robert Speicher committed
61
        def find(repo, commit_id = "HEAD")
62
          # Already a commit?
63
          return commit_id if commit_id.is_a?(Gitlab::Git::Commit)
64

Jacob Vosmaer's avatar
Jacob Vosmaer committed
65
          # This saves us an RPC round trip.
66
          return unless valid?(commit_id)
Robert Speicher's avatar
Robert Speicher committed
67

68
          commit = find_commit(repo, commit_id)
Robert Speicher's avatar
Robert Speicher committed
69

70
          decorate(repo, commit) if commit
71
        rescue Gitlab::Git::CommandError, Gitlab::Git::Repository::NoRepository, ArgumentError
Robert Speicher's avatar
Robert Speicher committed
72
73
74
          nil
        end

75
76
77
78
79
80
        def find_commit(repo, commit_id)
          wrapped_gitaly_errors do
            repo.gitaly_commit_client.find_commit(commit_id)
          end
        end

Robert Speicher's avatar
Robert Speicher committed
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
        # Get last commit for HEAD
        #
        # Ex.
        #   Commit.last(repo)
        #
        def last(repo)
          find(repo)
        end

        # Get last commit for specified path and ref
        #
        # Ex.
        #   Commit.last_for_path(repo, '29eda46b', 'app/models')
        #
        #   Commit.last_for_path(repo, 'master', 'Gemfile')
        #
97
        def last_for_path(repo, ref, path = nil, literal_pathspec: false)
98
99
          # rubocop: disable Rails/FindBy
          # This is not where..first from ActiveRecord
Robert Speicher's avatar
Robert Speicher committed
100
101
102
103
          where(
            repo: repo,
            ref: ref,
            path: path,
104
105
            limit: 1,
            literal_pathspec: literal_pathspec
Robert Speicher's avatar
Robert Speicher committed
106
          ).first
107
          # rubocop: enable Rails/FindBy
Robert Speicher's avatar
Robert Speicher committed
108
109
110
111
112
113
114
115
116
        end

        # Get commits between two revspecs
        # See also #repository.commits_between
        #
        # Ex.
        #   Commit.between(repo, '29eda46b', 'master')
        #
        def between(repo, base, head)
117
          wrapped_gitaly_errors do
118
            repo.gitaly_commit_client.between(base, head)
119
          end
Robert Speicher's avatar
Robert Speicher committed
120
121
        end

122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
        # Returns commits collection
        #
        # Ex.
        #   Commit.find_all(
        #     repo,
        #     ref: 'master',
        #     max_count: 10,
        #     skip: 5,
        #     order: :date
        #   )
        #
        #   +options+ is a Hash of optional arguments to git
        #     :ref is the ref from which to begin (SHA1 or name)
        #     :max_count is the maximum number of commits to fetch
        #     :skip is the number of commits to skip
        #     :order is the commits order and allowed value is :none (default), :date,
        #        :topo, or any combination of them (in an array). Commit ordering types
139
        #        are documented here: https://git-scm.com/docs/git-log#_commit_ordering
Robert Speicher's avatar
Robert Speicher committed
140
        def find_all(repo, options = {})
141
          wrapped_gitaly_errors do
142
            Gitlab::GitalyClient::CommitService.new(repo).find_all_commits(options)
143
144
145
          end
        end

146
147
        def decorate(repository, commit, ref = nil)
          Gitlab::Git::Commit.new(repository, commit, ref)
Robert Speicher's avatar
Robert Speicher committed
148
149
        end

150
        def shas_with_signatures(repository, shas)
151
          Gitlab::GitalyClient::CommitService.new(repository).filter_shas_with_signatures(shas)
152
        end
153
154
155
156
157

        # Only to be used when the object ids will not necessarily have a
        # relation to each other. The last 10 commits for a branch for example,
        # should go through .where
        def batch_by_oid(repo, oids)
158
          wrapped_gitaly_errors do
Jacob Vosmaer's avatar
Jacob Vosmaer committed
159
            repo.gitaly_commit_client.list_commits_by_oid(oids)
160
161
          end
        end
162

163
164
165
166
167
        # Mercurial equivalent of batch_by_oid
        def hg_batch_by_node_id(repo, node_ids)
          batch_by_oid(repo, repo.batch_by_hgsha(node_ids))
        end

168
        def extract_signature_lazily(repository, commit_id)
169
170
171
          BatchLoader.for(commit_id).batch(key: repository) do |commit_ids, loader, args|
            batch_signature_extraction(args[:key], commit_ids).each do |commit_id, signature_data|
              loader.call(commit_id, signature_data)
172
173
174
175
176
177
178
179
            end
          end
        end

        def batch_signature_extraction(repository, commit_ids)
          repository.gitaly_commit_client.get_commit_signatures(commit_ids)
        end

180
        def get_message(repository, commit_id)
181
182
183
          BatchLoader.for(commit_id).batch(key: repository) do |commit_ids, loader, args|
            get_messages(args[:key], commit_ids).each do |commit_id, message|
              loader.call(commit_id, message)
184
            end
185
186
187
          end
        end

188
        def get_messages(repository, commit_ids)
189
          repository.gitaly_commit_client.get_commit_messages(commit_ids)
190
        end
Robert Speicher's avatar
Robert Speicher committed
191
192
      end

193
      def initialize(repository, raw_commit, head = nil, lazy_load_parents: false)
Robert Speicher's avatar
Robert Speicher committed
194
195
        raise "Nil as raw commit passed" unless raw_commit

196
        @repository = repository.hpd_original
197
        @head = head
198
        @lazy_load_parents = lazy_load_parents
199

200
201
        init_commit(raw_commit)
      end
202

203
      def init_commit(raw_commit)
204
205
        case raw_commit
        when Hash
Robert Speicher's avatar
Robert Speicher committed
206
          init_from_hash(raw_commit)
207
        when Gitaly::GitCommit
208
          init_from_gitaly(raw_commit)
Robert Speicher's avatar
Robert Speicher committed
209
210
211
212
213
        else
          raise "Invalid raw commit type: #{raw_commit.class}"
        end
      end

214
215
216
217
218
219
220
221
222
      # Perform transitional back conversion for given repository
      #
      # Heptapod specific, and transitional: will be useful only until
      # we reach the HGitaly2 milestone
      def hpd_back_convert!(repository)
        @id = repository.sha_from_gitsha(@id)
        self
      end

Robert Speicher's avatar
Robert Speicher committed
223
224
225
226
227
228
229
230
      def sha
        id
      end

      def short_id(length = 10)
        id.to_s[0..length]
      end

231
      def hgid
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
        return @hgid unless @hgid.nil?

        @hgid = @repository.hgsha_from_sha sha
      end

      def parent_hgids
        return @parent_hgids unless @parent_hgids.nil?

        if parent_ids.nil?
          @parent_hgids = []
        else
          @parent_hgids = parent_ids.map do |parent_id|
            @repository.hgsha_from_sha parent_id
          end
        end
        @parent_hgids  # not really necessary, yet clearer
248
249
250
251
252
253
254
255
256
257
      end

      def hgsha
        hgid
      end

      def short_hgid(length = 10)
        hgid.to_s[0..length]
      end

Robert Speicher's avatar
Robert Speicher committed
258
259
260
261
262
263
264
265
266
267
268
269
270
      def safe_message
        @safe_message ||= message
      end

      def created_at
        committed_date
      end

      # Was this commit committed by a different person than the original author?
      def different_committer?
        author_name != committer_name || author_email != committer_email
      end

271
272
273
274
275
276
      def parent_ids
        return @parent_ids unless @lazy_load_parents

        @parent_ids ||= @repository.commit(id).parent_ids
      end

277
278
279
280
281
282
283
284
      def hpd_display_parent_ids
        # not yet loaded is probably just nil, but [] would be correct
        # as well since parent_ids uses ||=
        return parent_ids unless parent_ids

        parent_ids.map { |id| @repository.commit_id_to_display_id(id) }
      end

Robert Speicher's avatar
Robert Speicher committed
285
286
287
288
      def parent_id
        parent_ids.first
      end

289
290
291
292
      def hpd_display_parent_id
        hpd_display_parent_ids.first
      end

293
294
295
296
      def parent_hgid
        parent_hgids.first
      end

297
298
299
300
301
302
303
304
305
306
307
308
      def committed_date
        strong_memoize(:committed_date) do
          init_date_from_gitaly(raw_commit.committer) if raw_commit
        end
      end

      def authored_date
        strong_memoize(:authored_date) do
          init_date_from_gitaly(raw_commit.author) if raw_commit
        end
      end

Robert Speicher's avatar
Robert Speicher committed
309
310
      # Returns a diff object for the changes from this commit's first parent.
      # If there is no parent, then the diff is between this commit and an
311
      # empty repo. See Repository#diff for keys allowed in the +options+
Robert Speicher's avatar
Robert Speicher committed
312
313
      # hash.
      def diff_from_parent(options = {})
314
        @repository.gitaly_commit_client.diff_from_parent(self, options)
Robert Speicher's avatar
Robert Speicher committed
315
316
      end

317
      def deltas
318
        @deltas ||= begin
Jacob Vosmaer's avatar
Jacob Vosmaer committed
319
          deltas = @repository.gitaly_commit_client.commit_deltas(self)
320
321
          deltas.map { |delta| Gitlab::Git::Diff.new(delta) }
        end
322
323
      end

Robert Speicher's avatar
Robert Speicher committed
324
      def has_zero_stats?
325
        stats.total == 0
Robert Speicher's avatar
Robert Speicher committed
326
327
328
329
330
      rescue
        true
      end

      def no_commit_message
331
        "No commit message"
Robert Speicher's avatar
Robert Speicher committed
332
333
334
335
      end

      def to_hash
        serialize_keys.map.with_object({}) do |key, hash|
336
          hash[key] = send(key) # rubocop:disable GitlabSecurity/PublicSend
Robert Speicher's avatar
Robert Speicher committed
337
338
339
340
341
342
343
344
        end
      end

      def date
        committed_date
      end

      def diffs(options = {})
345
        Gitlab::Git::DiffCollection.new(diff_from_parent(options), options)
Robert Speicher's avatar
Robert Speicher committed
346
347
348
      end

      def parents
349
        parent_ids.map { |oid| self.class.find(@repository, oid) }.compact
Robert Speicher's avatar
Robert Speicher committed
350
351
352
      end

      def stats
353
        Gitlab::Git::CommitStats.new(@repository, self)
Robert Speicher's avatar
Robert Speicher committed
354
355
356
357
358
359
360
361
362
      end

      # Get ref names collection
      #
      # Ex.
      #   commit.ref_names(repo)
      #
      def ref_names(repo)
        refs(repo).map do |ref|
363
          ref.sub(%r{^refs/(heads|remotes|tags)/}, "")
Robert Speicher's avatar
Robert Speicher committed
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
        end
      end

      def message
        encode! @message
      end

      def author_name
        encode! @author_name
      end

      def author_email
        encode! @author_email
      end

      def committer_name
        encode! @committer_name
      end

      def committer_email
        encode! @committer_email
      end

387
388
389
390
      def merge_commit?
        parent_ids.size > 1
      end

391
392
393
394
      def gitaly_commit?
        raw_commit.is_a?(Gitaly::GitCommit)
      end

395
      def tree_entry(path)
396
397
        return unless path.present?

398
399
400
401
        commit_tree_entry(path)
      end

      def commit_tree_entry(path)
402
403
404
405
406
407
408
409
410
411
412
413
        # We're only interested in metadata, so limit actual data to 1 byte
        # since Gitaly doesn't support "send no data" option.
        entry = @repository.gitaly_commit_client.tree_entry(id, path, 1)
        return unless entry

        # To be compatible with the rugged format
        entry = entry.to_h
        entry.delete(:data)
        entry[:name] = File.basename(path)
        entry[:type] = entry[:type].downcase

        entry
414
415
      end

416
      def to_gitaly_commit
417
        return raw_commit if gitaly_commit?
418
419
420
421
422
423
424

        message_split = raw_commit.message.split("\n", 2)
        Gitaly::GitCommit.new(
          id: raw_commit.oid,
          subject: message_split[0] ? message_split[0].chomp.b : "",
          body: raw_commit.message.b,
          parent_ids: raw_commit.parent_ids,
425
426
          author: gitaly_commit_author_from_raw(raw_commit.author),
          committer: gitaly_commit_author_from_raw(raw_commit.committer)
427
428
429
        )
      end

Robert Speicher's avatar
Robert Speicher committed
430
431
432
433
434
435
      private

      def init_from_hash(hash)
        raw_commit = hash.symbolize_keys

        serialize_keys.each do |key|
436
          send("#{key}=", raw_commit[key]) # rubocop:disable GitlabSecurity/PublicSend
Robert Speicher's avatar
Robert Speicher committed
437
438
439
        end
      end

440
441
442
443
444
445
      def init_from_gitaly(commit)
        @raw_commit = commit
        @id = commit.id
        # TODO: Once gitaly "takes over" Rugged consider separating the
        # subject from the message to make it clearer when there's one
        # available but not the other.
446
        @message = message_from_gitaly_body
447
448
        @author_name = commit.author.name.dup
        @author_email = commit.author.email.dup
449

450
451
        @committer_name = commit.committer.name.dup
        @committer_email = commit.committer.email.dup
452
        @parent_ids = Array(commit.parent_ids)
453
454
      end

455
456
457
458
459
460
461
462
463
464
      # Gitaly provides a UNIX timestamp in author.date.seconds, and a timezone
      # offset in author.timezone. If the latter isn't present, assume UTC.
      def init_date_from_gitaly(author)
        if author.timezone.present?
          Time.strptime("#{author.date.seconds} #{author.timezone}", '%s %z')
        else
          Time.at(author.date.seconds).utc
        end
      end

Robert Speicher's avatar
Robert Speicher committed
465
466
467
      def serialize_keys
        SERIALIZE_KEYS
      end
468

469
      def gitaly_commit_author_from_raw(author_or_committer)
470
471
472
473
474
475
        Gitaly::CommitAuthor.new(
          name: author_or_committer[:name].b,
          email: author_or_committer[:email].b,
          date: Google::Protobuf::Timestamp.new(seconds: author_or_committer[:time].to_i)
        )
      end
476
477
478
479
480
481
482
483
484

      # Get a collection of Gitlab::Git::Ref objects for this commit.
      #
      # Ex.
      #   commit.ref(repo)
      #
      def refs(repo)
        repo.refs_hash[id]
      end
485
486

      def message_from_gitaly_body
487
        return @raw_commit.subject.dup if @raw_commit.body_size == 0
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
        return @raw_commit.body.dup if full_body_fetched_from_gitaly?

        if @raw_commit.body_size > MAX_COMMIT_MESSAGE_DISPLAY_SIZE
          "#{@raw_commit.subject}\n\n--commit message is too big".strip
        else
          fetch_body_from_gitaly
        end
      end

      def full_body_fetched_from_gitaly?
        @raw_commit.body.bytesize == @raw_commit.body_size
      end

      def fetch_body_from_gitaly
        self.class.get_message(@repository, id)
      end
504
505
506
507
508
509
510
511
512

      def self.valid?(commit_id)
        commit_id.is_a?(String) && !(
          commit_id.start_with?('-') ||
            commit_id.include?(':') ||
            commit_id.include?("\x00") ||
            commit_id.match?(/\s/)
        )
      end
Robert Speicher's avatar
Robert Speicher committed
513
514
515
    end
  end
end
516
517

Gitlab::Git::Commit.singleton_class.prepend Gitlab::Git::RuggedImpl::Commit::ClassMethods