Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/git/objects/commit.py: 55%
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 # Options to :manpage:`git-rev-list(1)` that can overwrite files.
90 unsafe_git_rev_options = [
91 "--output",
92 "-o",
93 ]
95 type: Literal["commit"] = "commit"
97 __slots__ = (
98 "tree",
99 "author",
100 "authored_date",
101 "author_tz_offset",
102 "committer",
103 "committed_date",
104 "committer_tz_offset",
105 "message",
106 "parents",
107 "encoding",
108 "gpgsig",
109 )
111 _id_attribute_ = "hexsha"
113 parents: Sequence["Commit"]
115 def __init__(
116 self,
117 repo: "Repo",
118 binsha: bytes,
119 tree: Union[Tree, None] = None,
120 author: Union[Actor, None] = None,
121 authored_date: Union[int, None] = None,
122 author_tz_offset: Union[None, float] = None,
123 committer: Union[Actor, None] = None,
124 committed_date: Union[int, None] = None,
125 committer_tz_offset: Union[None, float] = None,
126 message: Union[str, bytes, None] = None,
127 parents: Union[Sequence["Commit"], None] = None,
128 encoding: Union[str, None] = None,
129 gpgsig: Union[str, None] = None,
130 ) -> None:
131 """Instantiate a new :class:`Commit`. All keyword arguments taking ``None`` as
132 default will be implicitly set on first query.
134 :param binsha:
135 20 byte sha1.
137 :param tree:
138 A :class:`~git.objects.tree.Tree` object.
140 :param author:
141 The author :class:`~git.util.Actor` object.
143 :param authored_date: int_seconds_since_epoch
144 The authored DateTime - use :func:`time.gmtime` to convert it into a
145 different format.
147 :param author_tz_offset: int_seconds_west_of_utc
148 The timezone that the `authored_date` is in.
150 :param committer:
151 The committer string, as an :class:`~git.util.Actor` object.
153 :param committed_date: int_seconds_since_epoch
154 The committed DateTime - use :func:`time.gmtime` to convert it into a
155 different format.
157 :param committer_tz_offset: int_seconds_west_of_utc
158 The timezone that the `committed_date` is in.
160 :param message: string
161 The commit message.
163 :param encoding: string
164 Encoding of the message, defaults to UTF-8.
166 :param parents:
167 List or tuple of :class:`Commit` objects which are our parent(s) in the
168 commit dependency graph.
170 :return:
171 :class:`Commit`
173 :note:
174 Timezone information is in the same format and in the same sign as what
175 :func:`time.altzone` returns. The sign is inverted compared to git's UTC
176 timezone.
177 """
178 super().__init__(repo, binsha)
179 self.binsha = binsha
180 if tree is not None:
181 assert isinstance(tree, Tree), "Tree needs to be a Tree instance, was %s" % type(tree)
182 if tree is not None:
183 self.tree = tree
184 if author is not None:
185 self.author = author
186 if authored_date is not None:
187 self.authored_date = authored_date
188 if author_tz_offset is not None:
189 self.author_tz_offset = author_tz_offset
190 if committer is not None:
191 self.committer = committer
192 if committed_date is not None:
193 self.committed_date = committed_date
194 if committer_tz_offset is not None:
195 self.committer_tz_offset = committer_tz_offset
196 if message is not None:
197 self.message = message
198 if parents is not None:
199 self.parents = parents
200 if encoding is not None:
201 self.encoding = encoding
202 if gpgsig is not None:
203 self.gpgsig = gpgsig
205 @classmethod
206 def _get_intermediate_items(cls, commit: "Commit") -> Tuple["Commit", ...]:
207 return tuple(commit.parents)
209 @classmethod
210 def _calculate_sha_(cls, repo: "Repo", commit: "Commit") -> bytes:
211 """Calculate the sha of a commit.
213 :param repo:
214 :class:`~git.repo.base.Repo` object the commit should be part of.
216 :param commit:
217 :class:`Commit` object for which to generate the sha.
218 """
220 stream = BytesIO()
221 commit._serialize(stream)
222 streamlen = stream.tell()
223 stream.seek(0)
225 istream = repo.odb.store(IStream(cls.type, streamlen, stream))
226 return istream.binsha
228 def replace(self, **kwargs: Any) -> "Commit":
229 """Create new commit object from an existing commit object.
231 Any values provided as keyword arguments will replace the corresponding
232 attribute in the new object.
233 """
235 attrs = {k: getattr(self, k) for k in self.__slots__}
237 for attrname in kwargs:
238 if attrname not in self.__slots__:
239 raise ValueError("invalid attribute name")
241 attrs.update(kwargs)
242 new_commit = self.__class__(self.repo, self.NULL_BIN_SHA, **attrs)
243 new_commit.binsha = self._calculate_sha_(self.repo, new_commit)
245 return new_commit
247 def _set_cache_(self, attr: str) -> None:
248 if attr in Commit.__slots__:
249 # Read the data in a chunk, its faster - then provide a file wrapper.
250 _binsha, _typename, self.size, stream = self.repo.odb.stream(self.binsha)
251 self._deserialize(BytesIO(stream.read()))
252 else:
253 super()._set_cache_(attr)
254 # END handle attrs
256 @property
257 def authored_datetime(self) -> datetime.datetime:
258 return from_timestamp(self.authored_date, self.author_tz_offset)
260 @property
261 def committed_datetime(self) -> datetime.datetime:
262 return from_timestamp(self.committed_date, self.committer_tz_offset)
264 @property
265 def summary(self) -> Union[str, bytes]:
266 """:return: First line of the commit message"""
267 if isinstance(self.message, str):
268 return self.message.split("\n", 1)[0]
269 else:
270 return self.message.split(b"\n", 1)[0]
272 def count(
273 self,
274 paths: Union[PathLike, Sequence[PathLike]] = "",
275 allow_unsafe_options: bool = False,
276 **kwargs: Any,
277 ) -> int:
278 """Count the number of commits reachable from this commit.
280 :param paths:
281 An optional path or a list of paths restricting the return value to commits
282 actually containing the paths.
284 :param allow_unsafe_options:
285 Allow unsafe options, like ``--output``.
287 :param kwargs:
288 Additional options to be passed to :manpage:`git-rev-list(1)`. They must not
289 alter the output style of the command, or parsing will yield incorrect
290 results.
292 :return:
293 An int defining the number of reachable commits
294 """
295 if not allow_unsafe_options:
296 Git.check_unsafe_options(
297 options=Git._option_candidates([], kwargs), unsafe_options=self.unsafe_git_rev_options
298 )
300 # Yes, it makes a difference whether empty paths are given or not in our case as
301 # the empty paths version will ignore merge commits for some reason.
302 if paths:
303 return len(self.repo.git.rev_list(self.hexsha, "--", paths, **kwargs).splitlines())
304 return len(self.repo.git.rev_list(self.hexsha, **kwargs).splitlines())
306 @property
307 def name_rev(self) -> str:
308 """
309 :return:
310 String describing the commits hex sha based on the closest
311 :class:`~git.refs.reference.Reference`.
313 :note:
314 Mostly useful for UI purposes.
315 """
316 return self.repo.git.name_rev(self)
318 @classmethod
319 def iter_items(
320 cls,
321 repo: "Repo",
322 rev: Union[str, "Commit", "SymbolicReference"],
323 paths: Union[PathLike, Sequence[PathLike]] = "",
324 allow_unsafe_options: bool = False,
325 **kwargs: Any,
326 ) -> Iterator["Commit"]:
327 R"""Find all commits matching the given criteria.
329 :param repo:
330 The :class:`~git.repo.base.Repo`.
332 :param rev:
333 Revision specifier. See :manpage:`git-rev-parse(1)` for viable options.
335 :param paths:
336 An optional path or list of paths. If set only :class:`Commit`\s that
337 include the path or paths will be considered.
339 :param kwargs:
340 Optional keyword arguments to :manpage:`git-rev-list(1)` where:
342 * ``max_count`` is the maximum number of commits to fetch.
343 * ``skip`` is the number of commits to skip.
344 * ``since`` selects all commits since some date, e.g. ``"1970-01-01"``.
346 :return:
347 Iterator yielding :class:`Commit` items.
348 """
349 if "pretty" in kwargs:
350 raise ValueError("--pretty cannot be used as parsing expects single sha's only")
351 # END handle pretty
353 if not allow_unsafe_options:
354 Git.check_unsafe_options(
355 options=Git._option_candidates([rev], kwargs), unsafe_options=cls.unsafe_git_rev_options
356 )
358 # Use -- in all cases, to prevent possibility of ambiguous arguments.
359 # See https://github.com/gitpython-developers/GitPython/issues/264.
361 args_list: List[PathLike] = ["--"]
363 if paths:
364 paths_tup: Tuple[PathLike, ...]
365 if isinstance(paths, (str, os.PathLike)):
366 paths_tup = (paths,)
367 else:
368 paths_tup = tuple(paths)
370 args_list.extend(paths_tup)
371 # END if paths
373 proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs)
374 return cls._iter_from_process_or_stream(repo, proc)
376 def iter_parents(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> Iterator["Commit"]:
377 R"""Iterate *all* parents of this commit.
379 :param paths:
380 Optional path or list of paths limiting the :class:`Commit`\s to those that
381 contain at least one of the paths.
383 :param kwargs:
384 All arguments allowed by :manpage:`git-rev-list(1)`.
386 :return:
387 Iterator yielding :class:`Commit` objects which are parents of ``self``
388 """
389 # skip ourselves
390 skip = kwargs.get("skip", 1)
391 if skip == 0: # skip ourselves
392 skip = 1
393 kwargs["skip"] = skip
395 return self.iter_items(self.repo, self, paths, **kwargs)
397 @property
398 def stats(self) -> Stats:
399 """Create a git stat from changes between this commit and its first parent
400 or from all changes done if this is the very first commit.
402 :note:
403 If this commit is at the boundary of a shallow clone, this will
404 raise :exc:`~git.exc.GitCommandError`, since the parent object
405 was never fetched and only exists as a reference on this commit.
407 :return:
408 :class:`Stats`
409 """
411 def process_lines(lines: List[str]) -> str:
412 text = ""
413 for file_info, line in zip(lines, lines[len(lines) // 2 :]):
414 change_type = file_info.split("\t")[0][-1]
415 (insertions, deletions, filename) = line.split("\t")
416 text += "%s\t%s\t%s\t%s\n" % (change_type, insertions, deletions, filename)
417 return text
419 if not self.parents:
420 lines = self.repo.git.diff_tree(
421 self.hexsha, "--", numstat=True, no_renames=True, root=True, raw=True
422 ).splitlines()[1:]
423 text = process_lines(lines)
424 else:
425 lines = self.repo.git.diff(
426 self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True, raw=True
427 ).splitlines()
428 text = process_lines(lines)
429 return Stats._list_from_string(self.repo, text)
431 @property
432 def trailers(self) -> Dict[str, str]:
433 """Deprecated. Get the trailers of the message as a dictionary.
435 :note:
436 This property is deprecated, please use either :attr:`trailers_list` or
437 :attr:`trailers_dict`.
439 :return:
440 Dictionary containing whitespace stripped trailer information.
441 Only contains the latest instance of each trailer key.
442 """
443 warnings.warn(
444 "Commit.trailers is deprecated, use Commit.trailers_list or Commit.trailers_dict instead",
445 DeprecationWarning,
446 stacklevel=2,
447 )
448 return {k: v[0] for k, v in self.trailers_dict.items()}
450 @property
451 def trailers_list(self) -> List[Tuple[str, str]]:
452 """Get the trailers of the message as a list.
454 Git messages can contain trailer information that are similar to :rfc:`822`
455 e-mail headers. See :manpage:`git-interpret-trailers(1)`.
457 This function calls ``git interpret-trailers --parse`` onto the message to
458 extract the trailer information, returns the raw trailer data as a list.
460 Valid message with trailer::
462 Subject line
464 some body information
466 another information
468 key1: value1.1
469 key1: value1.2
470 key2 : value 2 with inner spaces
472 Returned list will look like this::
474 [
475 ("key1", "value1.1"),
476 ("key1", "value1.2"),
477 ("key2", "value 2 with inner spaces"),
478 ]
480 :return:
481 List containing key-value tuples of whitespace stripped trailer information.
482 """
483 trailer = self._interpret_trailers(self.repo, self.message, ["--parse"], encoding=self.encoding).strip()
485 if not trailer:
486 return []
488 trailer_list = []
489 for t in trailer.split("\n"):
490 key, val = t.split(":", 1)
491 trailer_list.append((key.strip(), val.strip()))
493 return trailer_list
495 @classmethod
496 def _interpret_trailers(
497 cls,
498 repo: "Repo",
499 message: Union[str, bytes],
500 trailer_args: Sequence[str],
501 encoding: str = default_encoding,
502 ) -> str:
503 message_bytes = message if isinstance(message, bytes) else message.encode(encoding, errors="strict")
504 cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args]
505 proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload]
506 cmd,
507 as_process=True,
508 istream=PIPE,
509 )
510 try:
511 stdout_bytes, _ = proc.communicate(message_bytes)
512 return stdout_bytes.decode(encoding, errors="strict")
513 finally:
514 finalize_process(proc)
516 @property
517 def trailers_dict(self) -> Dict[str, List[str]]:
518 """Get the trailers of the message as a dictionary.
520 Git messages can contain trailer information that are similar to :rfc:`822`
521 e-mail headers. See :manpage:`git-interpret-trailers(1)`.
523 This function calls ``git interpret-trailers --parse`` onto the message to
524 extract the trailer information. The key value pairs are stripped of leading and
525 trailing whitespaces before they get saved into a dictionary.
527 Valid message with trailer::
529 Subject line
531 some body information
533 another information
535 key1: value1.1
536 key1: value1.2
537 key2 : value 2 with inner spaces
539 Returned dictionary will look like this::
541 {
542 "key1": ["value1.1", "value1.2"],
543 "key2": ["value 2 with inner spaces"],
544 }
547 :return:
548 Dictionary containing whitespace stripped trailer information, mapping
549 trailer keys to a list of their corresponding values.
550 """
551 d = defaultdict(list)
552 for key, val in self.trailers_list:
553 d[key].append(val)
554 return dict(d)
556 @classmethod
557 def _iter_from_process_or_stream(cls, repo: "Repo", proc_or_stream: Union[Popen, IO]) -> Iterator["Commit"]:
558 """Parse out commit information into a list of :class:`Commit` objects.
560 We expect one line per commit, and parse the actual commit information directly
561 from our lighting fast object database.
563 :param proc:
564 :manpage:`git-rev-list(1)` process instance - one sha per line.
566 :return:
567 Iterator supplying :class:`Commit` objects
568 """
570 # def is_proc(inp) -> TypeGuard[Popen]:
571 # return hasattr(proc_or_stream, 'wait') and not hasattr(proc_or_stream, 'readline')
573 # def is_stream(inp) -> TypeGuard[IO]:
574 # return hasattr(proc_or_stream, 'readline')
576 if hasattr(proc_or_stream, "wait"):
577 proc_or_stream = cast(Popen, proc_or_stream)
578 stream = proc_or_stream.stdout
579 if stream is None:
580 raise ValueError("Process has no stdout stream")
581 elif hasattr(proc_or_stream, "readline"):
582 proc_or_stream = cast(IO, proc_or_stream) # type: ignore[redundant-cast]
583 stream = proc_or_stream
584 else:
585 raise TypeError("Expected a process or stream")
587 readline = stream.readline
588 while True:
589 line = readline()
590 if not line:
591 break
592 hexsha = line.strip()
593 if len(hexsha) > 40:
594 # Split additional information, as returned by bisect for instance.
595 hexsha, _ = line.split(None, 1)
596 # END handle extra info
598 assert len(hexsha) == 40, "Invalid line: %s" % hexsha
599 yield cls(repo, hex_to_bin(hexsha))
600 # END for each line in stream
602 # TODO: Review this - it seems process handling got a bit out of control due to
603 # many developers trying to fix the open file handles issue.
604 if hasattr(proc_or_stream, "wait"):
605 proc_or_stream = cast(Popen, proc_or_stream)
606 finalize_process(proc_or_stream)
608 @classmethod
609 def create_from_tree(
610 cls,
611 repo: "Repo",
612 tree: Union[Tree, str],
613 message: str,
614 parent_commits: Union[None, List["Commit"]] = None,
615 head: bool = False,
616 author: Union[None, Actor] = None,
617 committer: Union[None, Actor] = None,
618 author_date: Union[None, str, datetime.datetime] = None,
619 commit_date: Union[None, str, datetime.datetime] = None,
620 trailers: Union[None, Dict[str, str], List[Tuple[str, str]]] = None,
621 ) -> "Commit":
622 """Commit the given tree, creating a :class:`Commit` object.
624 :param repo:
625 :class:`~git.repo.base.Repo` object the commit should be part of.
627 :param tree:
628 :class:`~git.objects.tree.Tree` object or hex or bin sha.
629 The tree of the new commit.
631 :param message:
632 Commit message. It may be an empty string if no message is provided. It will
633 be converted to a string, in any case.
635 :param parent_commits:
636 Optional :class:`Commit` objects to use as parents for the new commit. If
637 empty list, the commit will have no parents at all and become a root commit.
638 If ``None``, the current head commit will be the parent of the new commit
639 object.
641 :param head:
642 If ``True``, the HEAD will be advanced to the new commit automatically.
643 Otherwise the HEAD will remain pointing on the previous commit. This could
644 lead to undesired results when diffing files.
646 :param author:
647 The name of the author, optional.
648 If unset, the repository configuration is used to obtain this value.
650 :param committer:
651 The name of the committer, optional.
652 If unset, the repository configuration is used to obtain this value.
654 :param author_date:
655 The timestamp for the author field.
657 :param commit_date:
658 The timestamp for the committer field.
660 :param trailers:
661 Optional trailer key-value pairs to append to the commit message.
662 Can be a dictionary mapping trailer keys to values, or a list of
663 ``(key, value)`` tuples (useful when the same key appears multiple
664 times, e.g. multiple ``Signed-off-by`` trailers). Trailers are
665 appended using ``git interpret-trailers``.
666 See :manpage:`git-interpret-trailers(1)`.
668 :return:
669 :class:`Commit` object representing the new commit.
671 :note:
672 Additional information about the committer and author are taken from the
673 environment or from the git configuration. See :manpage:`git-commit-tree(1)`
674 for more information.
675 """
676 if parent_commits is None:
677 try:
678 parent_commits = [repo.head.commit]
679 except ValueError:
680 # Empty repositories have no head commit.
681 parent_commits = []
682 # END handle parent commits
683 else:
684 for p in parent_commits:
685 if not isinstance(p, cls):
686 raise ValueError(f"Parent commit '{p!r}' must be of type {cls}")
687 # END check parent commit types
688 # END if parent commits are unset
690 # Retrieve all additional information, create a commit object, and serialize it.
691 # Generally:
692 # * Environment variables override configuration values.
693 # * Sensible defaults are set according to the git documentation.
695 # COMMITTER AND AUTHOR INFO
696 cr = repo.config_reader()
697 env = os.environ
699 committer = committer or Actor.committer(cr)
700 author = author or Actor.author(cr)
702 # PARSE THE DATES
703 unix_time = int(time())
704 is_dst = daylight and localtime().tm_isdst > 0
705 offset = altzone if is_dst else timezone
707 author_date_str = env.get(cls.env_author_date, "")
708 if author_date:
709 author_time, author_offset = parse_date(author_date)
710 elif author_date_str:
711 author_time, author_offset = parse_date(author_date_str)
712 else:
713 author_time, author_offset = unix_time, offset
714 # END set author time
716 committer_date_str = env.get(cls.env_committer_date, "")
717 if commit_date:
718 committer_time, committer_offset = parse_date(commit_date)
719 elif committer_date_str:
720 committer_time, committer_offset = parse_date(committer_date_str)
721 else:
722 committer_time, committer_offset = unix_time, offset
723 # END set committer time
725 # Assume UTF-8 encoding.
726 enc_section, enc_option = cls.conf_encoding.split(".")
727 conf_encoding = cr.get_value(enc_section, enc_option, cls.default_encoding)
728 if not isinstance(conf_encoding, str):
729 raise TypeError("conf_encoding could not be coerced to str")
731 # If the tree is no object, make sure we create one - otherwise the created
732 # commit object is invalid.
733 if isinstance(tree, str):
734 tree = repo.tree(tree)
735 # END tree conversion
737 # APPLY TRAILERS
738 if trailers:
739 trailer_args: List[str] = []
740 if isinstance(trailers, dict):
741 for key, val in trailers.items():
742 trailer_args.append("--trailer")
743 trailer_args.append(f"{key}: {val}")
744 else:
745 for key, val in trailers:
746 trailer_args.append("--trailer")
747 trailer_args.append(f"{key}: {val}")
749 message = cls._interpret_trailers(repo, str(message), trailer_args)
750 # END apply trailers
752 # CREATE NEW COMMIT
753 new_commit = cls(
754 repo,
755 cls.NULL_BIN_SHA,
756 tree,
757 author,
758 author_time,
759 author_offset,
760 committer,
761 committer_time,
762 committer_offset,
763 message,
764 parent_commits,
765 conf_encoding,
766 )
768 new_commit.binsha = cls._calculate_sha_(repo, new_commit)
770 if head:
771 # Need late import here, importing git at the very beginning throws as
772 # well...
773 import git.refs
775 try:
776 repo.head.set_commit(new_commit, logmsg=message)
777 except ValueError:
778 # head is not yet set to the ref our HEAD points to.
779 # Happens on first commit.
780 master = git.refs.Head.create(
781 repo,
782 repo.head.ref,
783 new_commit,
784 logmsg="commit (initial): %s" % message,
785 )
786 repo.head.set_reference(master, logmsg="commit: Switching to %s" % master)
787 # END handle empty repositories
788 # END advance head handling
790 return new_commit
792 # { Serializable Implementation
794 def _serialize(self, stream: BytesIO) -> "Commit":
795 write = stream.write
796 write(("tree %s\n" % self.tree).encode("ascii"))
797 for p in self.parents:
798 write(("parent %s\n" % p).encode("ascii"))
800 a = self.author
801 aname = a.name
802 c = self.committer
803 fmt = "%s %s <%s> %s %s\n"
804 write(
805 (
806 fmt
807 % (
808 "author",
809 aname,
810 a.email,
811 self.authored_date,
812 altz_to_utctz_str(self.author_tz_offset),
813 )
814 ).encode(self.encoding)
815 )
817 # Encode committer.
818 aname = c.name
819 write(
820 (
821 fmt
822 % (
823 "committer",
824 aname,
825 c.email,
826 self.committed_date,
827 altz_to_utctz_str(self.committer_tz_offset),
828 )
829 ).encode(self.encoding)
830 )
832 if self.encoding != self.default_encoding:
833 write(("encoding %s\n" % self.encoding).encode("ascii"))
835 try:
836 if self.__getattribute__("gpgsig"):
837 write(b"gpgsig")
838 for sigline in self.gpgsig.rstrip("\n").split("\n"):
839 write((" " + sigline + "\n").encode("ascii"))
840 except AttributeError:
841 pass
843 write(b"\n")
845 # Write plain bytes, be sure its encoded according to our encoding.
846 if isinstance(self.message, str):
847 write(self.message.encode(self.encoding))
848 else:
849 write(self.message)
850 # END handle encoding
851 return self
853 def _deserialize(self, stream: BytesIO) -> "Commit":
854 readline = stream.readline
855 self.tree = Tree(self.repo, hex_to_bin(readline().split()[1]), Tree.tree_id << 12, "")
857 self.parents = []
858 next_line = None
859 while True:
860 parent_line = readline()
861 if not parent_line.startswith(b"parent"):
862 next_line = parent_line
863 break
864 # END abort reading parents
865 self.parents.append(type(self)(self.repo, hex_to_bin(parent_line.split()[-1].decode("ascii"))))
866 # END for each parent line
867 self.parents = tuple(self.parents)
869 # We don't know actual author encoding before we have parsed it, so keep the
870 # lines around.
871 author_line = next_line
872 committer_line = readline()
874 # We might run into one or more mergetag blocks, skip those for now.
875 next_line = readline()
876 while next_line.startswith(b"mergetag "):
877 next_line = readline()
878 while next_line.startswith(b" "):
879 next_line = readline()
880 # END skip mergetags
882 # Now we can have the encoding line, or an empty line followed by the optional
883 # message.
884 self.encoding = self.default_encoding
885 self.gpgsig = ""
887 # Read headers.
888 enc = next_line
889 buf = enc.strip()
890 while buf:
891 if buf[0:10] == b"encoding ":
892 self.encoding = buf[buf.find(b" ") + 1 :].decode(self.encoding, "ignore")
893 elif buf[0:7] == b"gpgsig ":
894 sig = buf[buf.find(b" ") + 1 :] + b"\n"
895 is_next_header = False
896 while True:
897 sigbuf = readline()
898 if not sigbuf:
899 break
900 if sigbuf[0:1] != b" ":
901 buf = sigbuf.strip()
902 is_next_header = True
903 break
904 sig += sigbuf[1:]
905 # END read all signature
906 self.gpgsig = sig.rstrip(b"\n").decode(self.encoding, "ignore")
907 if is_next_header:
908 continue
909 buf = readline().strip()
911 # Decode the author's name.
912 try:
913 (
914 self.author,
915 self.authored_date,
916 self.author_tz_offset,
917 ) = parse_actor_and_date(author_line.decode(self.encoding, "replace"))
918 except UnicodeDecodeError:
919 _logger.error(
920 "Failed to decode author line '%s' using encoding %s",
921 author_line,
922 self.encoding,
923 exc_info=True,
924 )
926 try:
927 (
928 self.committer,
929 self.committed_date,
930 self.committer_tz_offset,
931 ) = parse_actor_and_date(committer_line.decode(self.encoding, "replace"))
932 except UnicodeDecodeError:
933 _logger.error(
934 "Failed to decode committer line '%s' using encoding %s",
935 committer_line,
936 self.encoding,
937 exc_info=True,
938 )
939 # END handle author's encoding
941 # A stream from our data simply gives us the plain message.
942 # The end of our message stream is marked with a newline that we strip.
943 self.message = stream.read()
944 try:
945 self.message = self.message.decode(self.encoding, "replace")
946 except UnicodeDecodeError:
947 _logger.error(
948 "Failed to decode message '%s' using encoding %s",
949 self.message,
950 self.encoding,
951 exc_info=True,
952 )
953 # END exception handling
955 return self
957 # } END serializable implementation
959 @property
960 def co_authors(self) -> List[Actor]:
961 """Search the commit message for any co-authors of this commit.
963 Details on co-authors:
964 https://github.blog/2018-01-29-commit-together-with-co-authors/
966 :return:
967 List of co-authors for this commit (as :class:`~git.util.Actor` objects).
968 """
969 co_authors = []
971 if self.message:
972 results = re.findall(
973 r"^Co-authored-by: (.*) <(.*?)>$",
974 str(self.message),
975 re.MULTILINE,
976 )
977 for author in results:
978 co_authors.append(Actor(*author))
980 return co_authors