1# Copyright (C) 2008, 2009 Michael Trier (mtrier@gmail.com) and contributors
2#
3# This module is part of GitPython and is released under the
4# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
5
6__all__ = ["Commit"]
7
8from collections import defaultdict
9import datetime
10from io import BytesIO
11import logging
12import os
13import re
14from subprocess import Popen, PIPE
15import sys
16from time import altzone, daylight, localtime, time, timezone
17import warnings
18
19from gitdb import IStream
20
21from git.cmd import Git
22from git.diff import Diffable
23from git.util import Actor, Stats, finalize_process, hex_to_bin
24
25from . import base
26from .tree import Tree
27from .util import (
28 Serializable,
29 TraversableIterableObj,
30 altz_to_utctz_str,
31 from_timestamp,
32 parse_actor_and_date,
33 parse_date,
34)
35
36# typing ------------------------------------------------------------------
37
38from typing import (
39 Any,
40 Dict,
41 IO,
42 Iterator,
43 List,
44 Sequence,
45 Tuple,
46 TYPE_CHECKING,
47 Union,
48 cast,
49)
50
51if sys.version_info >= (3, 8):
52 from typing import Literal
53else:
54 from typing_extensions import Literal
55
56from git.types import PathLike
57
58if TYPE_CHECKING:
59 from git.refs import SymbolicReference
60 from git.repo import Repo
61
62# ------------------------------------------------------------------------
63
64_logger = logging.getLogger(__name__)
65
66
67class Commit(base.Object, TraversableIterableObj, Diffable, Serializable):
68 """Wraps a git commit object.
69
70 See :manpage:`gitglossary(7)` on "commit object":
71 https://git-scm.com/docs/gitglossary#def_commit_object
72
73 :note:
74 This class will act lazily on some of its attributes and will query the value on
75 demand only if it involves calling the git binary.
76 """
77
78 # ENVIRONMENT VARIABLES
79 # Read when creating new commits.
80 env_author_date = "GIT_AUTHOR_DATE"
81 env_committer_date = "GIT_COMMITTER_DATE"
82
83 # CONFIGURATION KEYS
84 conf_encoding = "i18n.commitencoding"
85
86 # INVARIANTS
87 default_encoding = "UTF-8"
88
89 type: Literal["commit"] = "commit"
90
91 __slots__ = (
92 "tree",
93 "author",
94 "authored_date",
95 "author_tz_offset",
96 "committer",
97 "committed_date",
98 "committer_tz_offset",
99 "message",
100 "parents",
101 "encoding",
102 "gpgsig",
103 )
104
105 _id_attribute_ = "hexsha"
106
107 parents: Sequence["Commit"]
108
109 def __init__(
110 self,
111 repo: "Repo",
112 binsha: bytes,
113 tree: Union[Tree, None] = None,
114 author: Union[Actor, None] = None,
115 authored_date: Union[int, None] = None,
116 author_tz_offset: Union[None, float] = None,
117 committer: Union[Actor, None] = None,
118 committed_date: Union[int, None] = None,
119 committer_tz_offset: Union[None, float] = None,
120 message: Union[str, bytes, None] = None,
121 parents: Union[Sequence["Commit"], None] = None,
122 encoding: Union[str, None] = None,
123 gpgsig: Union[str, None] = None,
124 ) -> None:
125 """Instantiate a new :class:`Commit`. All keyword arguments taking ``None`` as
126 default will be implicitly set on first query.
127
128 :param binsha:
129 20 byte sha1.
130
131 :param tree:
132 A :class:`~git.objects.tree.Tree` object.
133
134 :param author:
135 The author :class:`~git.util.Actor` object.
136
137 :param authored_date: int_seconds_since_epoch
138 The authored DateTime - use :func:`time.gmtime` to convert it into a
139 different format.
140
141 :param author_tz_offset: int_seconds_west_of_utc
142 The timezone that the `authored_date` is in.
143
144 :param committer:
145 The committer string, as an :class:`~git.util.Actor` object.
146
147 :param committed_date: int_seconds_since_epoch
148 The committed DateTime - use :func:`time.gmtime` to convert it into a
149 different format.
150
151 :param committer_tz_offset: int_seconds_west_of_utc
152 The timezone that the `committed_date` is in.
153
154 :param message: string
155 The commit message.
156
157 :param encoding: string
158 Encoding of the message, defaults to UTF-8.
159
160 :param parents:
161 List or tuple of :class:`Commit` objects which are our parent(s) in the
162 commit dependency graph.
163
164 :return:
165 :class:`Commit`
166
167 :note:
168 Timezone information is in the same format and in the same sign as what
169 :func:`time.altzone` returns. The sign is inverted compared to git's UTC
170 timezone.
171 """
172 super().__init__(repo, binsha)
173 self.binsha = binsha
174 if tree is not None:
175 assert isinstance(tree, Tree), "Tree needs to be a Tree instance, was %s" % type(tree)
176 if tree is not None:
177 self.tree = tree
178 if author is not None:
179 self.author = author
180 if authored_date is not None:
181 self.authored_date = authored_date
182 if author_tz_offset is not None:
183 self.author_tz_offset = author_tz_offset
184 if committer is not None:
185 self.committer = committer
186 if committed_date is not None:
187 self.committed_date = committed_date
188 if committer_tz_offset is not None:
189 self.committer_tz_offset = committer_tz_offset
190 if message is not None:
191 self.message = message
192 if parents is not None:
193 self.parents = parents
194 if encoding is not None:
195 self.encoding = encoding
196 if gpgsig is not None:
197 self.gpgsig = gpgsig
198
199 @classmethod
200 def _get_intermediate_items(cls, commit: "Commit") -> Tuple["Commit", ...]:
201 return tuple(commit.parents)
202
203 @classmethod
204 def _calculate_sha_(cls, repo: "Repo", commit: "Commit") -> bytes:
205 """Calculate the sha of a commit.
206
207 :param repo:
208 :class:`~git.repo.base.Repo` object the commit should be part of.
209
210 :param commit:
211 :class:`Commit` object for which to generate the sha.
212 """
213
214 stream = BytesIO()
215 commit._serialize(stream)
216 streamlen = stream.tell()
217 stream.seek(0)
218
219 istream = repo.odb.store(IStream(cls.type, streamlen, stream))
220 return istream.binsha
221
222 def replace(self, **kwargs: Any) -> "Commit":
223 """Create new commit object from an existing commit object.
224
225 Any values provided as keyword arguments will replace the corresponding
226 attribute in the new object.
227 """
228
229 attrs = {k: getattr(self, k) for k in self.__slots__}
230
231 for attrname in kwargs:
232 if attrname not in self.__slots__:
233 raise ValueError("invalid attribute name")
234
235 attrs.update(kwargs)
236 new_commit = self.__class__(self.repo, self.NULL_BIN_SHA, **attrs)
237 new_commit.binsha = self._calculate_sha_(self.repo, new_commit)
238
239 return new_commit
240
241 def _set_cache_(self, attr: str) -> None:
242 if attr in Commit.__slots__:
243 # Read the data in a chunk, its faster - then provide a file wrapper.
244 _binsha, _typename, self.size, stream = self.repo.odb.stream(self.binsha)
245 self._deserialize(BytesIO(stream.read()))
246 else:
247 super()._set_cache_(attr)
248 # END handle attrs
249
250 @property
251 def authored_datetime(self) -> datetime.datetime:
252 return from_timestamp(self.authored_date, self.author_tz_offset)
253
254 @property
255 def committed_datetime(self) -> datetime.datetime:
256 return from_timestamp(self.committed_date, self.committer_tz_offset)
257
258 @property
259 def summary(self) -> Union[str, bytes]:
260 """:return: First line of the commit message"""
261 if isinstance(self.message, str):
262 return self.message.split("\n", 1)[0]
263 else:
264 return self.message.split(b"\n", 1)[0]
265
266 def count(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> int:
267 """Count the number of commits reachable from this commit.
268
269 :param paths:
270 An optional path or a list of paths restricting the return value to commits
271 actually containing the paths.
272
273 :param kwargs:
274 Additional options to be passed to :manpage:`git-rev-list(1)`. They must not
275 alter the output style of the command, or parsing will yield incorrect
276 results.
277
278 :return:
279 An int defining the number of reachable commits
280 """
281 # Yes, it makes a difference whether empty paths are given or not in our case as
282 # the empty paths version will ignore merge commits for some reason.
283 if paths:
284 return len(self.repo.git.rev_list(self.hexsha, "--", paths, **kwargs).splitlines())
285 return len(self.repo.git.rev_list(self.hexsha, **kwargs).splitlines())
286
287 @property
288 def name_rev(self) -> str:
289 """
290 :return:
291 String describing the commits hex sha based on the closest
292 `~git.refs.reference.Reference`.
293
294 :note:
295 Mostly useful for UI purposes.
296 """
297 return self.repo.git.name_rev(self)
298
299 @classmethod
300 def iter_items(
301 cls,
302 repo: "Repo",
303 rev: Union[str, "Commit", "SymbolicReference"],
304 paths: Union[PathLike, Sequence[PathLike]] = "",
305 **kwargs: Any,
306 ) -> Iterator["Commit"]:
307 R"""Find all commits matching the given criteria.
308
309 :param repo:
310 The :class:`~git.repo.base.Repo`.
311
312 :param rev:
313 Revision specifier. See :manpage:`git-rev-parse(1)` for viable options.
314
315 :param paths:
316 An optional path or list of paths. If set only :class:`Commit`\s that
317 include the path or paths will be considered.
318
319 :param kwargs:
320 Optional keyword arguments to :manpage:`git-rev-list(1)` where:
321
322 * ``max_count`` is the maximum number of commits to fetch.
323 * ``skip`` is the number of commits to skip.
324 * ``since`` selects all commits since some date, e.g. ``"1970-01-01"``.
325
326 :return:
327 Iterator yielding :class:`Commit` items.
328 """
329 if "pretty" in kwargs:
330 raise ValueError("--pretty cannot be used as parsing expects single sha's only")
331 # END handle pretty
332
333 # Use -- in all cases, to prevent possibility of ambiguous arguments.
334 # See https://github.com/gitpython-developers/GitPython/issues/264.
335
336 args_list: List[PathLike] = ["--"]
337
338 if paths:
339 paths_tup: Tuple[PathLike, ...]
340 if isinstance(paths, (str, os.PathLike)):
341 paths_tup = (paths,)
342 else:
343 paths_tup = tuple(paths)
344
345 args_list.extend(paths_tup)
346 # END if paths
347
348 proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs)
349 return cls._iter_from_process_or_stream(repo, proc)
350
351 def iter_parents(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> Iterator["Commit"]:
352 R"""Iterate _all_ parents of this commit.
353
354 :param paths:
355 Optional path or list of paths limiting the :class:`Commit`\s to those that
356 contain at least one of the paths.
357
358 :param kwargs:
359 All arguments allowed by :manpage:`git-rev-list(1)`.
360
361 :return:
362 Iterator yielding :class:`Commit` objects which are parents of ``self``
363 """
364 # skip ourselves
365 skip = kwargs.get("skip", 1)
366 if skip == 0: # skip ourselves
367 skip = 1
368 kwargs["skip"] = skip
369
370 return self.iter_items(self.repo, self, paths, **kwargs)
371
372 @property
373 def stats(self) -> Stats:
374 """Create a git stat from changes between this commit and its first parent
375 or from all changes done if this is the very first commit.
376
377 :return:
378 :class:`Stats`
379 """
380 if not self.parents:
381 text = self.repo.git.diff_tree(self.hexsha, "--", numstat=True, no_renames=True, root=True)
382 text2 = ""
383 for line in text.splitlines()[1:]:
384 (insertions, deletions, filename) = line.split("\t")
385 text2 += "%s\t%s\t%s\n" % (insertions, deletions, filename)
386 text = text2
387 else:
388 text = self.repo.git.diff(self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True)
389 return Stats._list_from_string(self.repo, text)
390
391 @property
392 def trailers(self) -> Dict[str, str]:
393 """Deprecated. Get the trailers of the message as a dictionary.
394
395 :note:
396 This property is deprecated, please use either :attr:`trailers_list` or
397 :attr:`trailers_dict`.
398
399 :return:
400 Dictionary containing whitespace stripped trailer information.
401 Only contains the latest instance of each trailer key.
402 """
403 warnings.warn(
404 "Commit.trailers is deprecated, use Commit.trailers_list or Commit.trailers_dict instead",
405 DeprecationWarning,
406 stacklevel=2,
407 )
408 return {k: v[0] for k, v in self.trailers_dict.items()}
409
410 @property
411 def trailers_list(self) -> List[Tuple[str, str]]:
412 """Get the trailers of the message as a list.
413
414 Git messages can contain trailer information that are similar to :rfc:`822`
415 e-mail headers. See :manpage:`git-interpret-trailers(1)`.
416
417 This function calls ``git interpret-trailers --parse`` onto the message to
418 extract the trailer information, returns the raw trailer data as a list.
419
420 Valid message with trailer::
421
422 Subject line
423
424 some body information
425
426 another information
427
428 key1: value1.1
429 key1: value1.2
430 key2 : value 2 with inner spaces
431
432 Returned list will look like this::
433
434 [
435 ("key1", "value1.1"),
436 ("key1", "value1.2"),
437 ("key2", "value 2 with inner spaces"),
438 ]
439
440 :return:
441 List containing key-value tuples of whitespace stripped trailer information.
442 """
443 cmd = ["git", "interpret-trailers", "--parse"]
444 proc: Git.AutoInterrupt = self.repo.git.execute( # type: ignore[call-overload]
445 cmd,
446 as_process=True,
447 istream=PIPE,
448 )
449 trailer: str = proc.communicate(str(self.message).encode())[0].decode("utf8")
450 trailer = trailer.strip()
451
452 if not trailer:
453 return []
454
455 trailer_list = []
456 for t in trailer.split("\n"):
457 key, val = t.split(":", 1)
458 trailer_list.append((key.strip(), val.strip()))
459
460 return trailer_list
461
462 @property
463 def trailers_dict(self) -> Dict[str, List[str]]:
464 """Get the trailers of the message as a dictionary.
465
466 Git messages can contain trailer information that are similar to :rfc:`822`
467 e-mail headers. See :manpage:`git-interpret-trailers(1)`.
468
469 This function calls ``git interpret-trailers --parse`` onto the message to
470 extract the trailer information. The key value pairs are stripped of leading and
471 trailing whitespaces before they get saved into a dictionary.
472
473 Valid message with trailer::
474
475 Subject line
476
477 some body information
478
479 another information
480
481 key1: value1.1
482 key1: value1.2
483 key2 : value 2 with inner spaces
484
485 Returned dictionary will look like this::
486
487 {
488 "key1": ["value1.1", "value1.2"],
489 "key2": ["value 2 with inner spaces"],
490 }
491
492
493 :return:
494 Dictionary containing whitespace stripped trailer information, mapping
495 trailer keys to a list of their corresponding values.
496 """
497 d = defaultdict(list)
498 for key, val in self.trailers_list:
499 d[key].append(val)
500 return dict(d)
501
502 @classmethod
503 def _iter_from_process_or_stream(cls, repo: "Repo", proc_or_stream: Union[Popen, IO]) -> Iterator["Commit"]:
504 """Parse out commit information into a list of :class:`Commit` objects.
505
506 We expect one line per commit, and parse the actual commit information directly
507 from our lighting fast object database.
508
509 :param proc:
510 :manpage:`git-rev-list(1)` process instance - one sha per line.
511
512 :return:
513 Iterator supplying :class:`Commit` objects
514 """
515
516 # def is_proc(inp) -> TypeGuard[Popen]:
517 # return hasattr(proc_or_stream, 'wait') and not hasattr(proc_or_stream, 'readline')
518
519 # def is_stream(inp) -> TypeGuard[IO]:
520 # return hasattr(proc_or_stream, 'readline')
521
522 if hasattr(proc_or_stream, "wait"):
523 proc_or_stream = cast(Popen, proc_or_stream)
524 if proc_or_stream.stdout is not None:
525 stream = proc_or_stream.stdout
526 elif hasattr(proc_or_stream, "readline"):
527 proc_or_stream = cast(IO, proc_or_stream) # type: ignore[redundant-cast]
528 stream = proc_or_stream
529
530 readline = stream.readline
531 while True:
532 line = readline()
533 if not line:
534 break
535 hexsha = line.strip()
536 if len(hexsha) > 40:
537 # Split additional information, as returned by bisect for instance.
538 hexsha, _ = line.split(None, 1)
539 # END handle extra info
540
541 assert len(hexsha) == 40, "Invalid line: %s" % hexsha
542 yield cls(repo, hex_to_bin(hexsha))
543 # END for each line in stream
544
545 # TODO: Review this - it seems process handling got a bit out of control due to
546 # many developers trying to fix the open file handles issue.
547 if hasattr(proc_or_stream, "wait"):
548 proc_or_stream = cast(Popen, proc_or_stream)
549 finalize_process(proc_or_stream)
550
551 @classmethod
552 def create_from_tree(
553 cls,
554 repo: "Repo",
555 tree: Union[Tree, str],
556 message: str,
557 parent_commits: Union[None, List["Commit"]] = None,
558 head: bool = False,
559 author: Union[None, Actor] = None,
560 committer: Union[None, Actor] = None,
561 author_date: Union[None, str, datetime.datetime] = None,
562 commit_date: Union[None, str, datetime.datetime] = None,
563 ) -> "Commit":
564 """Commit the given tree, creating a :class:`Commit` object.
565
566 :param repo:
567 :class:`~git.repo.base.Repo` object the commit should be part of.
568
569 :param tree:
570 :class:`~git.objects.tree.Tree` object or hex or bin sha.
571 The tree of the new commit.
572
573 :param message:
574 Commit message. It may be an empty string if no message is provided. It will
575 be converted to a string, in any case.
576
577 :param parent_commits:
578 Optional :class:`Commit` objects to use as parents for the new commit. If
579 empty list, the commit will have no parents at all and become a root commit.
580 If ``None``, the current head commit will be the parent of the new commit
581 object.
582
583 :param head:
584 If ``True``, the HEAD will be advanced to the new commit automatically.
585 Otherwise the HEAD will remain pointing on the previous commit. This could
586 lead to undesired results when diffing files.
587
588 :param author:
589 The name of the author, optional.
590 If unset, the repository configuration is used to obtain this value.
591
592 :param committer:
593 The name of the committer, optional.
594 If unset, the repository configuration is used to obtain this value.
595
596 :param author_date:
597 The timestamp for the author field.
598
599 :param commit_date:
600 The timestamp for the committer field.
601
602 :return:
603 :class:`Commit` object representing the new commit.
604
605 :note:
606 Additional information about the committer and author are taken from the
607 environment or from the git configuration. See :manpage:`git-commit-tree(1)`
608 for more information.
609 """
610 if parent_commits is None:
611 try:
612 parent_commits = [repo.head.commit]
613 except ValueError:
614 # Empty repositories have no head commit.
615 parent_commits = []
616 # END handle parent commits
617 else:
618 for p in parent_commits:
619 if not isinstance(p, cls):
620 raise ValueError(f"Parent commit '{p!r}' must be of type {cls}")
621 # END check parent commit types
622 # END if parent commits are unset
623
624 # Retrieve all additional information, create a commit object, and serialize it.
625 # Generally:
626 # * Environment variables override configuration values.
627 # * Sensible defaults are set according to the git documentation.
628
629 # COMMITTER AND AUTHOR INFO
630 cr = repo.config_reader()
631 env = os.environ
632
633 committer = committer or Actor.committer(cr)
634 author = author or Actor.author(cr)
635
636 # PARSE THE DATES
637 unix_time = int(time())
638 is_dst = daylight and localtime().tm_isdst > 0
639 offset = altzone if is_dst else timezone
640
641 author_date_str = env.get(cls.env_author_date, "")
642 if author_date:
643 author_time, author_offset = parse_date(author_date)
644 elif author_date_str:
645 author_time, author_offset = parse_date(author_date_str)
646 else:
647 author_time, author_offset = unix_time, offset
648 # END set author time
649
650 committer_date_str = env.get(cls.env_committer_date, "")
651 if commit_date:
652 committer_time, committer_offset = parse_date(commit_date)
653 elif committer_date_str:
654 committer_time, committer_offset = parse_date(committer_date_str)
655 else:
656 committer_time, committer_offset = unix_time, offset
657 # END set committer time
658
659 # Assume UTF-8 encoding.
660 enc_section, enc_option = cls.conf_encoding.split(".")
661 conf_encoding = cr.get_value(enc_section, enc_option, cls.default_encoding)
662 if not isinstance(conf_encoding, str):
663 raise TypeError("conf_encoding could not be coerced to str")
664
665 # If the tree is no object, make sure we create one - otherwise the created
666 # commit object is invalid.
667 if isinstance(tree, str):
668 tree = repo.tree(tree)
669 # END tree conversion
670
671 # CREATE NEW COMMIT
672 new_commit = cls(
673 repo,
674 cls.NULL_BIN_SHA,
675 tree,
676 author,
677 author_time,
678 author_offset,
679 committer,
680 committer_time,
681 committer_offset,
682 message,
683 parent_commits,
684 conf_encoding,
685 )
686
687 new_commit.binsha = cls._calculate_sha_(repo, new_commit)
688
689 if head:
690 # Need late import here, importing git at the very beginning throws as
691 # well...
692 import git.refs
693
694 try:
695 repo.head.set_commit(new_commit, logmsg=message)
696 except ValueError:
697 # head is not yet set to the ref our HEAD points to.
698 # Happens on first commit.
699 master = git.refs.Head.create(
700 repo,
701 repo.head.ref,
702 new_commit,
703 logmsg="commit (initial): %s" % message,
704 )
705 repo.head.set_reference(master, logmsg="commit: Switching to %s" % master)
706 # END handle empty repositories
707 # END advance head handling
708
709 return new_commit
710
711 # { Serializable Implementation
712
713 def _serialize(self, stream: BytesIO) -> "Commit":
714 write = stream.write
715 write(("tree %s\n" % self.tree).encode("ascii"))
716 for p in self.parents:
717 write(("parent %s\n" % p).encode("ascii"))
718
719 a = self.author
720 aname = a.name
721 c = self.committer
722 fmt = "%s %s <%s> %s %s\n"
723 write(
724 (
725 fmt
726 % (
727 "author",
728 aname,
729 a.email,
730 self.authored_date,
731 altz_to_utctz_str(self.author_tz_offset),
732 )
733 ).encode(self.encoding)
734 )
735
736 # Encode committer.
737 aname = c.name
738 write(
739 (
740 fmt
741 % (
742 "committer",
743 aname,
744 c.email,
745 self.committed_date,
746 altz_to_utctz_str(self.committer_tz_offset),
747 )
748 ).encode(self.encoding)
749 )
750
751 if self.encoding != self.default_encoding:
752 write(("encoding %s\n" % self.encoding).encode("ascii"))
753
754 try:
755 if self.__getattribute__("gpgsig"):
756 write(b"gpgsig")
757 for sigline in self.gpgsig.rstrip("\n").split("\n"):
758 write((" " + sigline + "\n").encode("ascii"))
759 except AttributeError:
760 pass
761
762 write(b"\n")
763
764 # Write plain bytes, be sure its encoded according to our encoding.
765 if isinstance(self.message, str):
766 write(self.message.encode(self.encoding))
767 else:
768 write(self.message)
769 # END handle encoding
770 return self
771
772 def _deserialize(self, stream: BytesIO) -> "Commit":
773 readline = stream.readline
774 self.tree = Tree(self.repo, hex_to_bin(readline().split()[1]), Tree.tree_id << 12, "")
775
776 self.parents = []
777 next_line = None
778 while True:
779 parent_line = readline()
780 if not parent_line.startswith(b"parent"):
781 next_line = parent_line
782 break
783 # END abort reading parents
784 self.parents.append(type(self)(self.repo, hex_to_bin(parent_line.split()[-1].decode("ascii"))))
785 # END for each parent line
786 self.parents = tuple(self.parents)
787
788 # We don't know actual author encoding before we have parsed it, so keep the
789 # lines around.
790 author_line = next_line
791 committer_line = readline()
792
793 # We might run into one or more mergetag blocks, skip those for now.
794 next_line = readline()
795 while next_line.startswith(b"mergetag "):
796 next_line = readline()
797 while next_line.startswith(b" "):
798 next_line = readline()
799 # END skip mergetags
800
801 # Now we can have the encoding line, or an empty line followed by the optional
802 # message.
803 self.encoding = self.default_encoding
804 self.gpgsig = ""
805
806 # Read headers.
807 enc = next_line
808 buf = enc.strip()
809 while buf:
810 if buf[0:10] == b"encoding ":
811 self.encoding = buf[buf.find(b" ") + 1 :].decode(self.encoding, "ignore")
812 elif buf[0:7] == b"gpgsig ":
813 sig = buf[buf.find(b" ") + 1 :] + b"\n"
814 is_next_header = False
815 while True:
816 sigbuf = readline()
817 if not sigbuf:
818 break
819 if sigbuf[0:1] != b" ":
820 buf = sigbuf.strip()
821 is_next_header = True
822 break
823 sig += sigbuf[1:]
824 # END read all signature
825 self.gpgsig = sig.rstrip(b"\n").decode(self.encoding, "ignore")
826 if is_next_header:
827 continue
828 buf = readline().strip()
829
830 # Decode the author's name.
831 try:
832 (
833 self.author,
834 self.authored_date,
835 self.author_tz_offset,
836 ) = parse_actor_and_date(author_line.decode(self.encoding, "replace"))
837 except UnicodeDecodeError:
838 _logger.error(
839 "Failed to decode author line '%s' using encoding %s",
840 author_line,
841 self.encoding,
842 exc_info=True,
843 )
844
845 try:
846 (
847 self.committer,
848 self.committed_date,
849 self.committer_tz_offset,
850 ) = parse_actor_and_date(committer_line.decode(self.encoding, "replace"))
851 except UnicodeDecodeError:
852 _logger.error(
853 "Failed to decode committer line '%s' using encoding %s",
854 committer_line,
855 self.encoding,
856 exc_info=True,
857 )
858 # END handle author's encoding
859
860 # A stream from our data simply gives us the plain message.
861 # The end of our message stream is marked with a newline that we strip.
862 self.message = stream.read()
863 try:
864 self.message = self.message.decode(self.encoding, "replace")
865 except UnicodeDecodeError:
866 _logger.error(
867 "Failed to decode message '%s' using encoding %s",
868 self.message,
869 self.encoding,
870 exc_info=True,
871 )
872 # END exception handling
873
874 return self
875
876 # } END serializable implementation
877
878 @property
879 def co_authors(self) -> List[Actor]:
880 """Search the commit message for any co-authors of this commit.
881
882 Details on co-authors:
883 https://github.blog/2018-01-29-commit-together-with-co-authors/
884
885 :return:
886 List of co-authors for this commit (as :class:`~git.util.Actor` objects).
887 """
888 co_authors = []
889
890 if self.message:
891 results = re.findall(
892 r"^Co-authored-by: (.*) <(.*?)>$",
893 self.message,
894 re.MULTILINE,
895 )
896 for author in results:
897 co_authors.append(Actor(*author))
898
899 return co_authors