Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/git/objects/commit.py: 56%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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/
6__all__ = ["Commit"]
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
19from gitdb import IStream
21from git.cmd import Git
22from git.diff import Diffable
23from git.util import Actor, Stats, finalize_process, hex_to_bin
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)
36# typing ------------------------------------------------------------------
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)
51if sys.version_info >= (3, 8):
52 from typing import Literal
53else:
54 from typing_extensions import Literal
56from git.types import PathLike
58if TYPE_CHECKING:
59 from git.refs import SymbolicReference
60 from git.repo import Repo
62# ------------------------------------------------------------------------
64_logger = logging.getLogger(__name__)
67class Commit(base.Object, TraversableIterableObj, Diffable, Serializable):
68 """Wraps a git commit object.
70 See :manpage:`gitglossary(7)` on "commit object":
71 https://git-scm.com/docs/gitglossary#def_commit_object
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 """
78 # ENVIRONMENT VARIABLES
79 # Read when creating new commits.
80 env_author_date = "GIT_AUTHOR_DATE"
81 env_committer_date = "GIT_COMMITTER_DATE"
83 # CONFIGURATION KEYS
84 conf_encoding = "i18n.commitencoding"
86 # INVARIANTS
87 default_encoding = "UTF-8"
89 type: Literal["commit"] = "commit"
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 )
105 _id_attribute_ = "hexsha"
107 parents: Sequence["Commit"]
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.
128 :param binsha:
129 20 byte sha1.
131 :param tree:
132 A :class:`~git.objects.tree.Tree` object.
134 :param author:
135 The author :class:`~git.util.Actor` object.
137 :param authored_date: int_seconds_since_epoch
138 The authored DateTime - use :func:`time.gmtime` to convert it into a
139 different format.
141 :param author_tz_offset: int_seconds_west_of_utc
142 The timezone that the `authored_date` is in.
144 :param committer:
145 The committer string, as an :class:`~git.util.Actor` object.
147 :param committed_date: int_seconds_since_epoch
148 The committed DateTime - use :func:`time.gmtime` to convert it into a
149 different format.
151 :param committer_tz_offset: int_seconds_west_of_utc
152 The timezone that the `committed_date` is in.
154 :param message: string
155 The commit message.
157 :param encoding: string
158 Encoding of the message, defaults to UTF-8.
160 :param parents:
161 List or tuple of :class:`Commit` objects which are our parent(s) in the
162 commit dependency graph.
164 :return:
165 :class:`Commit`
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
199 @classmethod
200 def _get_intermediate_items(cls, commit: "Commit") -> Tuple["Commit", ...]:
201 return tuple(commit.parents)
203 @classmethod
204 def _calculate_sha_(cls, repo: "Repo", commit: "Commit") -> bytes:
205 """Calculate the sha of a commit.
207 :param repo:
208 :class:`~git.repo.base.Repo` object the commit should be part of.
210 :param commit:
211 :class:`Commit` object for which to generate the sha.
212 """
214 stream = BytesIO()
215 commit._serialize(stream)
216 streamlen = stream.tell()
217 stream.seek(0)
219 istream = repo.odb.store(IStream(cls.type, streamlen, stream))
220 return istream.binsha
222 def replace(self, **kwargs: Any) -> "Commit":
223 """Create new commit object from an existing commit object.
225 Any values provided as keyword arguments will replace the corresponding
226 attribute in the new object.
227 """
229 attrs = {k: getattr(self, k) for k in self.__slots__}
231 for attrname in kwargs:
232 if attrname not in self.__slots__:
233 raise ValueError("invalid attribute name")
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)
239 return new_commit
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
250 @property
251 def authored_datetime(self) -> datetime.datetime:
252 return from_timestamp(self.authored_date, self.author_tz_offset)
254 @property
255 def committed_datetime(self) -> datetime.datetime:
256 return from_timestamp(self.committed_date, self.committer_tz_offset)
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]
266 def count(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> int:
267 """Count the number of commits reachable from this commit.
269 :param paths:
270 An optional path or a list of paths restricting the return value to commits
271 actually containing the paths.
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.
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())
287 @property
288 def name_rev(self) -> str:
289 """
290 :return:
291 String describing the commits hex sha based on the closest
292 :class:`~git.refs.reference.Reference`.
294 :note:
295 Mostly useful for UI purposes.
296 """
297 return self.repo.git.name_rev(self)
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.
309 :param repo:
310 The :class:`~git.repo.base.Repo`.
312 :param rev:
313 Revision specifier. See :manpage:`git-rev-parse(1)` for viable options.
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.
319 :param kwargs:
320 Optional keyword arguments to :manpage:`git-rev-list(1)` where:
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"``.
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
333 # Use -- in all cases, to prevent possibility of ambiguous arguments.
334 # See https://github.com/gitpython-developers/GitPython/issues/264.
336 args_list: List[PathLike] = ["--"]
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)
345 args_list.extend(paths_tup)
346 # END if paths
348 proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs)
349 return cls._iter_from_process_or_stream(repo, proc)
351 def iter_parents(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> Iterator["Commit"]:
352 R"""Iterate *all* parents of this commit.
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.
358 :param kwargs:
359 All arguments allowed by :manpage:`git-rev-list(1)`.
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
370 return self.iter_items(self.repo, self, paths, **kwargs)
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.
377 :return:
378 :class:`Stats`
379 """
381 def process_lines(lines: List[str]) -> str:
382 text = ""
383 for file_info, line in zip(lines, lines[len(lines) // 2 :]):
384 change_type = file_info.split("\t")[0][-1]
385 (insertions, deletions, filename) = line.split("\t")
386 text += "%s\t%s\t%s\t%s\n" % (change_type, insertions, deletions, filename)
387 return text
389 if not self.parents:
390 lines = self.repo.git.diff_tree(
391 self.hexsha, "--", numstat=True, no_renames=True, root=True, raw=True
392 ).splitlines()[1:]
393 text = process_lines(lines)
394 else:
395 lines = self.repo.git.diff(
396 self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True, raw=True
397 ).splitlines()
398 text = process_lines(lines)
399 return Stats._list_from_string(self.repo, text)
401 @property
402 def trailers(self) -> Dict[str, str]:
403 """Deprecated. Get the trailers of the message as a dictionary.
405 :note:
406 This property is deprecated, please use either :attr:`trailers_list` or
407 :attr:`trailers_dict`.
409 :return:
410 Dictionary containing whitespace stripped trailer information.
411 Only contains the latest instance of each trailer key.
412 """
413 warnings.warn(
414 "Commit.trailers is deprecated, use Commit.trailers_list or Commit.trailers_dict instead",
415 DeprecationWarning,
416 stacklevel=2,
417 )
418 return {k: v[0] for k, v in self.trailers_dict.items()}
420 @property
421 def trailers_list(self) -> List[Tuple[str, str]]:
422 """Get the trailers of the message as a list.
424 Git messages can contain trailer information that are similar to :rfc:`822`
425 e-mail headers. See :manpage:`git-interpret-trailers(1)`.
427 This function calls ``git interpret-trailers --parse`` onto the message to
428 extract the trailer information, returns the raw trailer data as a list.
430 Valid message with trailer::
432 Subject line
434 some body information
436 another information
438 key1: value1.1
439 key1: value1.2
440 key2 : value 2 with inner spaces
442 Returned list will look like this::
444 [
445 ("key1", "value1.1"),
446 ("key1", "value1.2"),
447 ("key2", "value 2 with inner spaces"),
448 ]
450 :return:
451 List containing key-value tuples of whitespace stripped trailer information.
452 """
453 trailer = self._interpret_trailers(self.repo, self.message, ["--parse"], encoding=self.encoding).strip()
455 if not trailer:
456 return []
458 trailer_list = []
459 for t in trailer.split("\n"):
460 key, val = t.split(":", 1)
461 trailer_list.append((key.strip(), val.strip()))
463 return trailer_list
465 @classmethod
466 def _interpret_trailers(
467 cls,
468 repo: "Repo",
469 message: Union[str, bytes],
470 trailer_args: Sequence[str],
471 encoding: str = default_encoding,
472 ) -> str:
473 message_bytes = message if isinstance(message, bytes) else message.encode(encoding, errors="strict")
474 cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args]
475 proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload]
476 cmd,
477 as_process=True,
478 istream=PIPE,
479 )
480 try:
481 stdout_bytes, _ = proc.communicate(message_bytes)
482 return stdout_bytes.decode(encoding, errors="strict")
483 finally:
484 finalize_process(proc)
486 @property
487 def trailers_dict(self) -> Dict[str, List[str]]:
488 """Get the trailers of the message as a dictionary.
490 Git messages can contain trailer information that are similar to :rfc:`822`
491 e-mail headers. See :manpage:`git-interpret-trailers(1)`.
493 This function calls ``git interpret-trailers --parse`` onto the message to
494 extract the trailer information. The key value pairs are stripped of leading and
495 trailing whitespaces before they get saved into a dictionary.
497 Valid message with trailer::
499 Subject line
501 some body information
503 another information
505 key1: value1.1
506 key1: value1.2
507 key2 : value 2 with inner spaces
509 Returned dictionary will look like this::
511 {
512 "key1": ["value1.1", "value1.2"],
513 "key2": ["value 2 with inner spaces"],
514 }
517 :return:
518 Dictionary containing whitespace stripped trailer information, mapping
519 trailer keys to a list of their corresponding values.
520 """
521 d = defaultdict(list)
522 for key, val in self.trailers_list:
523 d[key].append(val)
524 return dict(d)
526 @classmethod
527 def _iter_from_process_or_stream(cls, repo: "Repo", proc_or_stream: Union[Popen, IO]) -> Iterator["Commit"]:
528 """Parse out commit information into a list of :class:`Commit` objects.
530 We expect one line per commit, and parse the actual commit information directly
531 from our lighting fast object database.
533 :param proc:
534 :manpage:`git-rev-list(1)` process instance - one sha per line.
536 :return:
537 Iterator supplying :class:`Commit` objects
538 """
540 # def is_proc(inp) -> TypeGuard[Popen]:
541 # return hasattr(proc_or_stream, 'wait') and not hasattr(proc_or_stream, 'readline')
543 # def is_stream(inp) -> TypeGuard[IO]:
544 # return hasattr(proc_or_stream, 'readline')
546 if hasattr(proc_or_stream, "wait"):
547 proc_or_stream = cast(Popen, proc_or_stream)
548 if proc_or_stream.stdout is not None:
549 stream = proc_or_stream.stdout
550 elif hasattr(proc_or_stream, "readline"):
551 proc_or_stream = cast(IO, proc_or_stream) # type: ignore[redundant-cast]
552 stream = proc_or_stream
554 readline = stream.readline
555 while True:
556 line = readline()
557 if not line:
558 break
559 hexsha = line.strip()
560 if len(hexsha) > 40:
561 # Split additional information, as returned by bisect for instance.
562 hexsha, _ = line.split(None, 1)
563 # END handle extra info
565 assert len(hexsha) == 40, "Invalid line: %s" % hexsha
566 yield cls(repo, hex_to_bin(hexsha))
567 # END for each line in stream
569 # TODO: Review this - it seems process handling got a bit out of control due to
570 # many developers trying to fix the open file handles issue.
571 if hasattr(proc_or_stream, "wait"):
572 proc_or_stream = cast(Popen, proc_or_stream)
573 finalize_process(proc_or_stream)
575 @classmethod
576 def create_from_tree(
577 cls,
578 repo: "Repo",
579 tree: Union[Tree, str],
580 message: str,
581 parent_commits: Union[None, List["Commit"]] = None,
582 head: bool = False,
583 author: Union[None, Actor] = None,
584 committer: Union[None, Actor] = None,
585 author_date: Union[None, str, datetime.datetime] = None,
586 commit_date: Union[None, str, datetime.datetime] = None,
587 trailers: Union[None, Dict[str, str], List[Tuple[str, str]]] = None,
588 ) -> "Commit":
589 """Commit the given tree, creating a :class:`Commit` object.
591 :param repo:
592 :class:`~git.repo.base.Repo` object the commit should be part of.
594 :param tree:
595 :class:`~git.objects.tree.Tree` object or hex or bin sha.
596 The tree of the new commit.
598 :param message:
599 Commit message. It may be an empty string if no message is provided. It will
600 be converted to a string, in any case.
602 :param parent_commits:
603 Optional :class:`Commit` objects to use as parents for the new commit. If
604 empty list, the commit will have no parents at all and become a root commit.
605 If ``None``, the current head commit will be the parent of the new commit
606 object.
608 :param head:
609 If ``True``, the HEAD will be advanced to the new commit automatically.
610 Otherwise the HEAD will remain pointing on the previous commit. This could
611 lead to undesired results when diffing files.
613 :param author:
614 The name of the author, optional.
615 If unset, the repository configuration is used to obtain this value.
617 :param committer:
618 The name of the committer, optional.
619 If unset, the repository configuration is used to obtain this value.
621 :param author_date:
622 The timestamp for the author field.
624 :param commit_date:
625 The timestamp for the committer field.
627 :param trailers:
628 Optional trailer key-value pairs to append to the commit message.
629 Can be a dictionary mapping trailer keys to values, or a list of
630 ``(key, value)`` tuples (useful when the same key appears multiple
631 times, e.g. multiple ``Signed-off-by`` trailers). Trailers are
632 appended using ``git interpret-trailers``.
633 See :manpage:`git-interpret-trailers(1)`.
635 :return:
636 :class:`Commit` object representing the new commit.
638 :note:
639 Additional information about the committer and author are taken from the
640 environment or from the git configuration. See :manpage:`git-commit-tree(1)`
641 for more information.
642 """
643 if parent_commits is None:
644 try:
645 parent_commits = [repo.head.commit]
646 except ValueError:
647 # Empty repositories have no head commit.
648 parent_commits = []
649 # END handle parent commits
650 else:
651 for p in parent_commits:
652 if not isinstance(p, cls):
653 raise ValueError(f"Parent commit '{p!r}' must be of type {cls}")
654 # END check parent commit types
655 # END if parent commits are unset
657 # Retrieve all additional information, create a commit object, and serialize it.
658 # Generally:
659 # * Environment variables override configuration values.
660 # * Sensible defaults are set according to the git documentation.
662 # COMMITTER AND AUTHOR INFO
663 cr = repo.config_reader()
664 env = os.environ
666 committer = committer or Actor.committer(cr)
667 author = author or Actor.author(cr)
669 # PARSE THE DATES
670 unix_time = int(time())
671 is_dst = daylight and localtime().tm_isdst > 0
672 offset = altzone if is_dst else timezone
674 author_date_str = env.get(cls.env_author_date, "")
675 if author_date:
676 author_time, author_offset = parse_date(author_date)
677 elif author_date_str:
678 author_time, author_offset = parse_date(author_date_str)
679 else:
680 author_time, author_offset = unix_time, offset
681 # END set author time
683 committer_date_str = env.get(cls.env_committer_date, "")
684 if commit_date:
685 committer_time, committer_offset = parse_date(commit_date)
686 elif committer_date_str:
687 committer_time, committer_offset = parse_date(committer_date_str)
688 else:
689 committer_time, committer_offset = unix_time, offset
690 # END set committer time
692 # Assume UTF-8 encoding.
693 enc_section, enc_option = cls.conf_encoding.split(".")
694 conf_encoding = cr.get_value(enc_section, enc_option, cls.default_encoding)
695 if not isinstance(conf_encoding, str):
696 raise TypeError("conf_encoding could not be coerced to str")
698 # If the tree is no object, make sure we create one - otherwise the created
699 # commit object is invalid.
700 if isinstance(tree, str):
701 tree = repo.tree(tree)
702 # END tree conversion
704 # APPLY TRAILERS
705 if trailers:
706 trailer_args: List[str] = []
707 if isinstance(trailers, dict):
708 for key, val in trailers.items():
709 trailer_args.append("--trailer")
710 trailer_args.append(f"{key}: {val}")
711 else:
712 for key, val in trailers:
713 trailer_args.append("--trailer")
714 trailer_args.append(f"{key}: {val}")
716 message = cls._interpret_trailers(repo, str(message), trailer_args)
717 # END apply trailers
719 # CREATE NEW COMMIT
720 new_commit = cls(
721 repo,
722 cls.NULL_BIN_SHA,
723 tree,
724 author,
725 author_time,
726 author_offset,
727 committer,
728 committer_time,
729 committer_offset,
730 message,
731 parent_commits,
732 conf_encoding,
733 )
735 new_commit.binsha = cls._calculate_sha_(repo, new_commit)
737 if head:
738 # Need late import here, importing git at the very beginning throws as
739 # well...
740 import git.refs
742 try:
743 repo.head.set_commit(new_commit, logmsg=message)
744 except ValueError:
745 # head is not yet set to the ref our HEAD points to.
746 # Happens on first commit.
747 master = git.refs.Head.create(
748 repo,
749 repo.head.ref,
750 new_commit,
751 logmsg="commit (initial): %s" % message,
752 )
753 repo.head.set_reference(master, logmsg="commit: Switching to %s" % master)
754 # END handle empty repositories
755 # END advance head handling
757 return new_commit
759 # { Serializable Implementation
761 def _serialize(self, stream: BytesIO) -> "Commit":
762 write = stream.write
763 write(("tree %s\n" % self.tree).encode("ascii"))
764 for p in self.parents:
765 write(("parent %s\n" % p).encode("ascii"))
767 a = self.author
768 aname = a.name
769 c = self.committer
770 fmt = "%s %s <%s> %s %s\n"
771 write(
772 (
773 fmt
774 % (
775 "author",
776 aname,
777 a.email,
778 self.authored_date,
779 altz_to_utctz_str(self.author_tz_offset),
780 )
781 ).encode(self.encoding)
782 )
784 # Encode committer.
785 aname = c.name
786 write(
787 (
788 fmt
789 % (
790 "committer",
791 aname,
792 c.email,
793 self.committed_date,
794 altz_to_utctz_str(self.committer_tz_offset),
795 )
796 ).encode(self.encoding)
797 )
799 if self.encoding != self.default_encoding:
800 write(("encoding %s\n" % self.encoding).encode("ascii"))
802 try:
803 if self.__getattribute__("gpgsig"):
804 write(b"gpgsig")
805 for sigline in self.gpgsig.rstrip("\n").split("\n"):
806 write((" " + sigline + "\n").encode("ascii"))
807 except AttributeError:
808 pass
810 write(b"\n")
812 # Write plain bytes, be sure its encoded according to our encoding.
813 if isinstance(self.message, str):
814 write(self.message.encode(self.encoding))
815 else:
816 write(self.message)
817 # END handle encoding
818 return self
820 def _deserialize(self, stream: BytesIO) -> "Commit":
821 readline = stream.readline
822 self.tree = Tree(self.repo, hex_to_bin(readline().split()[1]), Tree.tree_id << 12, "")
824 self.parents = []
825 next_line = None
826 while True:
827 parent_line = readline()
828 if not parent_line.startswith(b"parent"):
829 next_line = parent_line
830 break
831 # END abort reading parents
832 self.parents.append(type(self)(self.repo, hex_to_bin(parent_line.split()[-1].decode("ascii"))))
833 # END for each parent line
834 self.parents = tuple(self.parents)
836 # We don't know actual author encoding before we have parsed it, so keep the
837 # lines around.
838 author_line = next_line
839 committer_line = readline()
841 # We might run into one or more mergetag blocks, skip those for now.
842 next_line = readline()
843 while next_line.startswith(b"mergetag "):
844 next_line = readline()
845 while next_line.startswith(b" "):
846 next_line = readline()
847 # END skip mergetags
849 # Now we can have the encoding line, or an empty line followed by the optional
850 # message.
851 self.encoding = self.default_encoding
852 self.gpgsig = ""
854 # Read headers.
855 enc = next_line
856 buf = enc.strip()
857 while buf:
858 if buf[0:10] == b"encoding ":
859 self.encoding = buf[buf.find(b" ") + 1 :].decode(self.encoding, "ignore")
860 elif buf[0:7] == b"gpgsig ":
861 sig = buf[buf.find(b" ") + 1 :] + b"\n"
862 is_next_header = False
863 while True:
864 sigbuf = readline()
865 if not sigbuf:
866 break
867 if sigbuf[0:1] != b" ":
868 buf = sigbuf.strip()
869 is_next_header = True
870 break
871 sig += sigbuf[1:]
872 # END read all signature
873 self.gpgsig = sig.rstrip(b"\n").decode(self.encoding, "ignore")
874 if is_next_header:
875 continue
876 buf = readline().strip()
878 # Decode the author's name.
879 try:
880 (
881 self.author,
882 self.authored_date,
883 self.author_tz_offset,
884 ) = parse_actor_and_date(author_line.decode(self.encoding, "replace"))
885 except UnicodeDecodeError:
886 _logger.error(
887 "Failed to decode author line '%s' using encoding %s",
888 author_line,
889 self.encoding,
890 exc_info=True,
891 )
893 try:
894 (
895 self.committer,
896 self.committed_date,
897 self.committer_tz_offset,
898 ) = parse_actor_and_date(committer_line.decode(self.encoding, "replace"))
899 except UnicodeDecodeError:
900 _logger.error(
901 "Failed to decode committer line '%s' using encoding %s",
902 committer_line,
903 self.encoding,
904 exc_info=True,
905 )
906 # END handle author's encoding
908 # A stream from our data simply gives us the plain message.
909 # The end of our message stream is marked with a newline that we strip.
910 self.message = stream.read()
911 try:
912 self.message = self.message.decode(self.encoding, "replace")
913 except UnicodeDecodeError:
914 _logger.error(
915 "Failed to decode message '%s' using encoding %s",
916 self.message,
917 self.encoding,
918 exc_info=True,
919 )
920 # END exception handling
922 return self
924 # } END serializable implementation
926 @property
927 def co_authors(self) -> List[Actor]:
928 """Search the commit message for any co-authors of this commit.
930 Details on co-authors:
931 https://github.blog/2018-01-29-commit-together-with-co-authors/
933 :return:
934 List of co-authors for this commit (as :class:`~git.util.Actor` objects).
935 """
936 co_authors = []
938 if self.message:
939 results = re.findall(
940 r"^Co-authored-by: (.*) <(.*?)>$",
941 str(self.message),
942 re.MULTILINE,
943 )
944 for author in results:
945 co_authors.append(Actor(*author))
947 return co_authors