commit.rb 11.7 KB
Newer Older
1
# Gitlab::Git::Commit is a wrapper around Gitaly::GitCommit
Robert Speicher's avatar
Robert Speicher committed
2 3 4
module Gitlab
  module Git
    class Commit
5
      include Gitlab::EncodingHelper
Robert Speicher's avatar
Robert Speicher committed
6

7
      attr_accessor :raw_commit, :head
Robert Speicher's avatar
Robert Speicher committed
8

9
      MAX_COMMIT_MESSAGE_DISPLAY_SIZE = 10.megabytes
10
      MIN_SHA_LENGTH = 7
Robert Speicher's avatar
Robert Speicher committed
11 12 13 14 15 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

      attr_accessor *SERIALIZE_KEYS # rubocop:disable Lint/AmbiguousOperator

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

22
        id && id == other.id
Robert Speicher's avatar
Robert Speicher committed
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
      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)

41
          repo.log(options)
Robert Speicher's avatar
Robert Speicher committed
42 43 44 45 46 47 48 49 50
        end

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

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

Jacob Vosmaer's avatar
Jacob Vosmaer committed
59
          # This saves us an RPC round trip.
60 61
          return nil if commit_id.include?(':')

62 63
          commit = repo.wrapped_gitaly_errors do
            repo.gitaly_commit_client.find_commit(commit_id)
64
          end
Robert Speicher's avatar
Robert Speicher committed
65

66
          decorate(repo, commit) if commit
67
        rescue Gitlab::Git::CommandError, Gitlab::Git::Repository::NoRepository, ArgumentError
Robert Speicher's avatar
Robert Speicher committed
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102
          nil
        end

        # 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)
103 104
          repo.wrapped_gitaly_errors do
            repo.gitaly_commit_client.between(base, head)
105
          end
Robert Speicher's avatar
Robert Speicher committed
106 107
        end

108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
        # 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
127
        def find_all(repo, options = {})
128 129
          repo.wrapped_gitaly_errors do
            Gitlab::GitalyClient::CommitService.new(repo).find_all_commits(options)
130
          end
131 132
        end

133 134
        def decorate(repository, commit, ref = nil)
          Gitlab::Git::Commit.new(repository, commit, ref)
Robert Speicher's avatar
Robert Speicher committed
135 136
        end

137
        def shas_with_signatures(repository, shas)
138
          Gitlab::GitalyClient::CommitService.new(repository).filter_shas_with_signatures(shas)
139
        end
140 141 142 143 144

        # 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)
Jacob Vosmaer's avatar
Jacob Vosmaer committed
145 146
          repo.wrapped_gitaly_errors do
            repo.gitaly_commit_client.list_commits_by_oid(oids)
147 148
          end
        end
149 150

        def extract_signature(repository, commit_id)
151
          repository.gitaly_commit_client.extract_signature(commit_id)
152 153
        end

154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173
        def extract_signature_lazily(repository, commit_id)
          BatchLoader.for({ repository: repository, commit_id: commit_id }).batch do |items, loader|
            items_by_repo = items.group_by { |i| i[:repository] }

            items_by_repo.each do |repo, items|
              commit_ids = items.map { |i| i[:commit_id] }

              signatures = batch_signature_extraction(repository, commit_ids)

              signatures.each do |commit_sha, signature_data|
                loader.call({ repository: repository, commit_id: commit_sha }, signature_data)
              end
            end
          end
        end

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

174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
        def get_message(repository, commit_id)
          BatchLoader.for({ repository: repository, commit_id: commit_id }).batch do |items, loader|
            items_by_repo = items.group_by { |i| i[:repository] }

            items_by_repo.each do |repo, items|
              commit_ids = items.map { |i| i[:commit_id] }

              messages = get_messages(repository, commit_ids)

              messages.each do |commit_sha, message|
                loader.call({ repository: repository, commit_id: commit_sha }, message)
              end
            end
          end
        end

        def get_messages(repository, commit_ids)
191
          repository.gitaly_commit_client.get_commit_messages(commit_ids)
192
        end
Robert Speicher's avatar
Robert Speicher committed
193 194
      end

195
      def initialize(repository, raw_commit, head = nil)
Robert Speicher's avatar
Robert Speicher committed
196 197
        raise "Nil as raw commit passed" unless raw_commit

198 199 200
        @repository = repository
        @head = head

201 202
        case raw_commit
        when Hash
Robert Speicher's avatar
Robert Speicher committed
203
          init_from_hash(raw_commit)
204
        when Gitaly::GitCommit
205
          init_from_gitaly(raw_commit)
Robert Speicher's avatar
Robert Speicher committed
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237
        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

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

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

Robert Speicher's avatar
Robert Speicher committed
251 252 253 254 255 256 257 258 259 260 261 262
      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|
263
          hash[key] = send(key) # rubocop:disable GitlabSecurity/PublicSend
Robert Speicher's avatar
Robert Speicher committed
264 265 266 267 268 269 270 271
        end
      end

      def date
        committed_date
      end

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

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

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

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

314 315 316 317
      def merge_commit?
        parent_ids.size > 1
      end

318
      def tree_entry(path)
319 320
        return unless path.present?

321 322 323 324 325 326 327 328 329 330 331
        # 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

        entry = entry.to_h
        entry.delete(:data)
        entry[:name] = File.basename(path)
        entry[:type] = entry[:type].downcase

        entry
332 333
      end

334 335 336 337 338 339 340 341 342
      def to_gitaly_commit
        return raw_commit if raw_commit.is_a?(Gitaly::GitCommit)

        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,
343 344
          author: gitaly_commit_author_from_raw(raw_commit.author),
          committer: gitaly_commit_author_from_raw(raw_commit.committer)
345 346 347
        )
      end

Robert Speicher's avatar
Robert Speicher committed
348 349 350 351 352 353
      private

      def init_from_hash(hash)
        raw_commit = hash.symbolize_keys

        serialize_keys.each do |key|
354
          send("#{key}=", raw_commit[key]) # rubocop:disable GitlabSecurity/PublicSend
Robert Speicher's avatar
Robert Speicher committed
355 356 357
        end
      end

358 359 360 361 362 363
      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.
364
        @message = message_from_gitaly_body
365
        @authored_date = Time.at(commit.author.date.seconds).utc
366 367
        @author_name = commit.author.name.dup
        @author_email = commit.author.email.dup
368
        @committed_date = Time.at(commit.committer.date.seconds).utc
369 370
        @committer_name = commit.committer.name.dup
        @committer_email = commit.committer.email.dup
371
        @parent_ids = Array(commit.parent_ids)
372 373
      end

Robert Speicher's avatar
Robert Speicher committed
374 375 376
      def serialize_keys
        SERIALIZE_KEYS
      end
377

378
      def gitaly_commit_author_from_raw(author_or_committer)
379 380 381 382 383 384
        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
385 386 387 388 389 390 391 392 393

      # Get a collection of Gitlab::Git::Ref objects for this commit.
      #
      # Ex.
      #   commit.ref(repo)
      #
      def refs(repo)
        repo.refs_hash[id]
      end
394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412

      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
413 414 415
    end
  end
end