1module Gitlab
2  module Git
3    class Commit
4      include Gitlab::EncodingHelper
5
6      attr_accessor :raw_commit, :head
7
8      MAX_COMMIT_MESSAGE_DISPLAY_SIZE = 10.megabytes
9      MIN_SHA_LENGTH = 7
10      SERIALIZE_KEYS = %i[
11        id message parent_ids
12        authored_date author_name author_email
13        committed_date committer_name committer_email trailers
14      ].freeze
15
16      attr_accessor *SERIALIZE_KEYS # rubocop:disable Lint/AmbiguousOperator
17
18      def ==(other)
19        return false unless other.is_a?(Gitlab::Git::Commit)
20
21        id && id == other.id
22      end
23
24      class << self
25        # Get single commit
26        #
27        # Ex.
28        #   Commit.find(repo, '29eda46b')
29        #
30        #   Commit.find(repo, 'master')
31        #
32        def find(repo, commit_id = "HEAD")
33          # Already a commit?
34          return commit_id if commit_id.is_a?(Gitlab::Git::Commit)
35
36          # A rugged reference?
37          commit_id = Gitlab::Git::Ref.dereference_object(commit_id)
38          return decorate(repo, commit_id) if commit_id.is_a?(Rugged::Commit)
39
40          # Some weird thing?
41          return nil unless commit_id.is_a?(String)
42
43          # This saves us an RPC round trip.
44          return nil if commit_id.include?(':')
45
46          commit = rugged_find(repo, commit_id)
47
48          decorate(repo, commit) if commit
49        rescue Rugged::ReferenceError, Rugged::InvalidError, Rugged::ObjectError,
50               Gitlab::Git::CommandError, Gitlab::Git::Repository::NoRepository,
51               Rugged::OdbError, Rugged::TreeError, ArgumentError
52          nil
53        end
54
55        def rugged_find(repo, commit_id)
56          obj = repo.rev_parse_target(commit_id)
57
58          obj.is_a?(Rugged::Commit) ? obj : nil
59        end
60
61        def decorate(repository, commit, ref = nil)
62          Gitlab::Git::Commit.new(repository, commit, ref)
63        end
64
65        def shas_with_signatures(repository, shas)
66          shas.select do |sha|
67            begin
68              Rugged::Commit.extract_signature(repository.rugged, sha)
69            rescue Rugged::OdbError
70              false
71            end
72          end
73        end
74      end
75
76      def initialize(repository, raw_commit, head = nil)
77        raise "Nil as raw commit passed" unless raw_commit
78
79        @repository = repository
80        @head = head
81
82        case raw_commit
83        when Hash
84          init_from_hash(raw_commit)
85        when Rugged::Commit
86          init_from_rugged(raw_commit)
87        when Gitaly::GitCommit
88          init_from_gitaly(raw_commit)
89        else
90          raise "Invalid raw commit type: #{raw_commit.class}"
91        end
92      end
93
94      def sha
95        id
96      end
97
98      def short_id(length = 10)
99        id.to_s[0..length]
100      end
101
102      def safe_message
103        @safe_message ||= message
104      end
105
106      def no_commit_message
107        "--no commit message"
108      end
109
110      def to_hash
111        serialize_keys.map.with_object({}) do |key, hash|
112          hash[key] = send(key)
113        end
114      end
115
116      def date
117        committed_date
118      end
119
120      def parents
121        parent_ids.map { |oid| self.class.find(@repository, oid) }.compact
122      end
123
124      def message
125        encode! @message
126      end
127
128      def author_name
129        encode! @author_name
130      end
131
132      def author_email
133        encode! @author_email
134      end
135
136      def committer_name
137        encode! @committer_name
138      end
139
140      def committer_email
141        encode! @committer_email
142      end
143
144      def rugged_commit
145        @rugged_commit ||= if raw_commit.is_a?(Rugged::Commit)
146                             raw_commit
147                           else
148                             @repository.rev_parse_target(id)
149                           end
150      end
151
152      def merge_commit?
153        parent_ids.size > 1
154      end
155
156      def to_gitaly_commit
157        return raw_commit if raw_commit.is_a?(Gitaly::GitCommit)
158
159        message_split = raw_commit.message.split("\n", 2)
160        Gitaly::GitCommit.new(
161          id: raw_commit.oid,
162          subject: message_split[0] ? message_split[0].chomp.b : "",
163          body: raw_commit.message.b,
164          parent_ids: raw_commit.parent_ids,
165          author: gitaly_commit_author_from_rugged(raw_commit.author),
166          committer: gitaly_commit_author_from_rugged(raw_commit.committer),
167          trailers: gitaly_trailers_from_rugged(raw_commit)
168        )
169      end
170
171      private
172
173      def init_from_hash(hash)
174        raw_commit = hash.symbolize_keys
175
176        serialize_keys.each do |key|
177          send("#{key}=", raw_commit[key])
178        end
179      end
180
181      def init_from_rugged(commit)
182        author = commit.author
183        committer = commit.committer
184
185        @raw_commit = commit
186        @id = commit.oid
187        @message = commit.message
188        @authored_date = author[:time]
189        @committed_date = committer[:time]
190        @author_name = author[:name]
191        @author_email = author[:email]
192        @committer_name = committer[:name]
193        @committer_email = committer[:email]
194        @parent_ids = commit.parents.map(&:oid)
195        @trailers = Hash[commit.trailers]
196      end
197
198      def init_from_gitaly(commit)
199        @raw_commit = commit
200        @id = commit.id
201        # TODO: Once gitaly "takes over" Rugged consider separating the
202        # subject from the message to make it clearer when there's one
203        # available but not the other.
204        @message = message_from_gitaly_body
205        @authored_date = init_date_from_gitaly(commit.author)
206        @author_name = commit.author.name.dup
207        @author_email = commit.author.email.dup
208        @committed_date = init_date_from_gitaly(commit.committer)
209        @committer_name = commit.committer.name.dup
210        @committer_email = commit.committer.email.dup
211        @parent_ids = Array(commit.parent_ids)
212        @trailers = Hash[commit.trailers.map { |t| [t.key, t.value] }]
213      end
214
215      # Gitaly provides a UNIX timestamp in author.date.seconds, and a timezone
216      # offset in author.timezone. If the latter isn't present, assume UTC.
217      def init_date_from_gitaly(author)
218        if author.timezone.present?
219          Time.strptime("#{author.date.seconds} #{author.timezone}", '%s %z')
220        else
221          Time.at(author.date.seconds).utc
222        end
223      end
224
225      def serialize_keys
226        SERIALIZE_KEYS
227      end
228
229      def gitaly_commit_author_from_rugged(author_or_committer)
230        Gitaly::CommitAuthor.new(
231          name: author_or_committer[:name].b,
232          email: author_or_committer[:email].b,
233          date: Google::Protobuf::Timestamp.new(seconds: author_or_committer[:time].to_i)
234        )
235      end
236
237      def gitaly_trailers_from_rugged(rugged_commit)
238        rugged_commit.trailers.map do |(key, value)|
239          Gitaly::CommitTrailer.new(key: key, value: value)
240        end
241      end
242
243      def message_from_gitaly_body
244        return @raw_commit.subject.dup if @raw_commit.body_size.zero?
245
246        @raw_commit.body.dup
247      end
248    end
249  end
250end
251