commit.rb 11.9 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
Robert Speicher's avatar
Robert Speicher committed
10

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

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

      attr_accessor *SERIALIZE_KEYS # rubocop:disable Lint/AmbiguousOperator

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

26
        id && id == other.id
Robert Speicher's avatar
Robert Speicher committed
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
      end

      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)

45
          repo.log(options)
Robert Speicher's avatar
Robert Speicher committed
46 47 48 49 50 51 52 53 54
        end

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

          # Some weird thing?
61
          return unless commit_id.is_a?(String)
62

63
          # This saves us an RPC round trip.
64
          return if commit_id.include?(':')
65

66
          commit = find_commit(repo, commit_id)
Robert Speicher's avatar
Robert Speicher committed
67

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

73 74 75 76 77 78
        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
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110
        # 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')
        #
        def last_for_path(repo, ref, path = nil)
          where(
            repo: repo,
            ref: ref,
            path: path,
            limit: 1
          ).first
        end

        # Get commits between two revspecs
        # See also #repository.commits_between
        #
        # Ex.
        #   Commit.between(repo, '29eda46b', 'master')
        #
        def between(repo, base, head)
111
          wrapped_gitaly_errors do
112
            repo.gitaly_commit_client.between(base, head)
113
          end
Robert Speicher's avatar
Robert Speicher committed
114 115
        end

116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
        # 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
        #        are documented here:
        #        http://www.rubydoc.info/github/libgit2/rugged/Rugged#SORT_NONE-constant)
Robert Speicher's avatar
Robert Speicher committed
135
        def find_all(repo, options = {})
136
          wrapped_gitaly_errors do
137
            Gitlab::GitalyClient::CommitService.new(repo).find_all_commits(options)
138
          end
139 140
        end

141 142
        def decorate(repository, commit, ref = nil)
          Gitlab::Git::Commit.new(repository, commit, ref)
Robert Speicher's avatar
Robert Speicher committed
143 144
        end

145
        def shas_with_signatures(repository, shas)
146
          Gitlab::GitalyClient::CommitService.new(repository).filter_shas_with_signatures(shas)
147
        end
148 149 150 151 152

        # 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)
153
          wrapped_gitaly_errors do
Jacob Vosmaer's avatar
Jacob Vosmaer committed
154
            repo.gitaly_commit_client.list_commits_by_oid(oids)
155 156
          end
        end
157 158

        def extract_signature(repository, commit_id)
159
          repository.gitaly_commit_client.extract_signature(commit_id)
160 161
        end

162
        def extract_signature_lazily(repository, commit_id)
163 164 165
          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)
166 167 168 169 170 171 172 173
            end
          end
        end

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

174
        def get_message(repository, commit_id)
175 176 177
          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)
178 179 180 181 182
            end
          end
        end

        def get_messages(repository, commit_ids)
183
          repository.gitaly_commit_client.get_commit_messages(commit_ids)
184
        end
Robert Speicher's avatar
Robert Speicher committed
185 186
      end

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

190 191
        @repository = repository
        @head = head
192
        @lazy_load_parents = lazy_load_parents
193

194 195 196 197
        init_commit(raw_commit)
      end

      def init_commit(raw_commit)
198 199
        case raw_commit
        when Hash
Robert Speicher's avatar
Robert Speicher committed
200
          init_from_hash(raw_commit)
201
        when Gitaly::GitCommit
202
          init_from_gitaly(raw_commit)
Robert Speicher's avatar
Robert Speicher committed
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228
        else
          raise "Invalid raw commit type: #{raw_commit.class}"
        end
      end

      def sha
        id
      end

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

      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

229 230 231 232 233 234
      def parent_ids
        return @parent_ids unless @lazy_load_parents

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

Robert Speicher's avatar
Robert Speicher committed
235 236 237 238 239 240
      def parent_id
        parent_ids.first
      end

      # 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
241
      # empty repo. See Repository#diff for keys allowed in the +options+
Robert Speicher's avatar
Robert Speicher committed
242 243
      # hash.
      def diff_from_parent(options = {})
244
        @repository.gitaly_commit_client.diff_from_parent(self, options)
245 246
      end

247
      def deltas
248
        @deltas ||= begin
Jacob Vosmaer's avatar
Jacob Vosmaer committed
249
          deltas = @repository.gitaly_commit_client.commit_deltas(self)
250 251
          deltas.map { |delta| Gitlab::Git::Diff.new(delta) }
        end
252 253
      end

Robert Speicher's avatar
Robert Speicher committed
254 255 256 257 258 259 260 261 262 263 264 265
      def has_zero_stats?
        stats.total.zero?
      rescue
        true
      end

      def no_commit_message
        "--no commit message"
      end

      def to_hash
        serialize_keys.map.with_object({}) do |key, hash|
266
          hash[key] = send(key) # rubocop:disable GitlabSecurity/PublicSend
Robert Speicher's avatar
Robert Speicher committed
267 268 269 270 271 272 273 274
        end
      end

      def date
        committed_date
      end

      def diffs(options = {})
275
        Gitlab::Git::DiffCollection.new(diff_from_parent(options), options)
Robert Speicher's avatar
Robert Speicher committed
276 277 278
      end

      def parents
279
        parent_ids.map { |oid| self.class.find(@repository, oid) }.compact
Robert Speicher's avatar
Robert Speicher committed
280 281 282
      end

      def stats
283
        Gitlab::Git::CommitStats.new(@repository, self)
Robert Speicher's avatar
Robert Speicher committed
284 285 286 287 288 289 290 291 292
      end

      # Get ref names collection
      #
      # Ex.
      #   commit.ref_names(repo)
      #
      def ref_names(repo)
        refs(repo).map do |ref|
293
          ref.sub(%r{^refs/(heads|remotes|tags)/}, "")
Robert Speicher's avatar
Robert Speicher committed
294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316
        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

317 318 319 320
      def merge_commit?
        parent_ids.size > 1
      end

321 322 323 324
      def gitaly_commit?
        raw_commit.is_a?(Gitaly::GitCommit)
      end

325
      def tree_entry(path)
326 327
        return unless path.present?

328 329 330 331
        commit_tree_entry(path)
      end

      def commit_tree_entry(path)
332 333 334 335 336
        # 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

337
        # To be compatible with the rugged format
338 339 340 341 342 343
        entry = entry.to_h
        entry.delete(:data)
        entry[:name] = File.basename(path)
        entry[:type] = entry[:type].downcase

        entry
344 345
      end

346
      def to_gitaly_commit
347
        return raw_commit if gitaly_commit?
348 349 350 351 352 353 354

        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,
355 356
          author: gitaly_commit_author_from_raw(raw_commit.author),
          committer: gitaly_commit_author_from_raw(raw_commit.committer)
357 358 359
        )
      end

Robert Speicher's avatar
Robert Speicher committed
360 361 362 363 364 365
      private

      def init_from_hash(hash)
        raw_commit = hash.symbolize_keys

        serialize_keys.each do |key|
366
          send("#{key}=", raw_commit[key]) # rubocop:disable GitlabSecurity/PublicSend
Robert Speicher's avatar
Robert Speicher committed
367 368 369
        end
      end

370 371 372 373 374 375
      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.
376
        @message = message_from_gitaly_body
377
        @authored_date = Time.at(commit.author.date.seconds).utc
378 379
        @author_name = commit.author.name.dup
        @author_email = commit.author.email.dup
380
        @committed_date = Time.at(commit.committer.date.seconds).utc
381 382
        @committer_name = commit.committer.name.dup
        @committer_email = commit.committer.email.dup
383
        @parent_ids = Array(commit.parent_ids)
384 385
      end

Robert Speicher's avatar
Robert Speicher committed
386 387 388
      def serialize_keys
        SERIALIZE_KEYS
      end
389

390
      def gitaly_commit_author_from_raw(author_or_committer)
391 392 393 394 395 396
        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
397 398 399 400 401 402 403 404 405

      # Get a collection of Gitlab::Git::Ref objects for this commit.
      #
      # Ex.
      #   commit.ref(repo)
      #
      def refs(repo)
        repo.refs_hash[id]
      end
406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424

      def message_from_gitaly_body
        return @raw_commit.subject.dup if @raw_commit.body_size.zero?
        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
Robert Speicher's avatar
Robert Speicher committed
425 426 427
    end
  end
end
428 429

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