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(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> int:
273 """Count the number of commits reachable from this commit.
275 :param paths:
276 An optional path or a list of paths restricting the return value to commits
277 actually containing the paths.
279 :param kwargs:
280 Additional options to be passed to :manpage:`git-rev-list(1)`. They must not
281 alter the output style of the command, or parsing will yield incorrect
282 results.
284 :return:
285 An int defining the number of reachable commits
286 """
287 # Yes, it makes a difference whether empty paths are given or not in our case as
288 # the empty paths version will ignore merge commits for some reason.
289 if paths:
290 return len(self.repo.git.rev_list(self.hexsha, "--", paths, **kwargs).splitlines())
291 return len(self.repo.git.rev_list(self.hexsha, **kwargs).splitlines())
293 @property
294 def name_rev(self) -> str:
295 """
296 :return:
297 String describing the commits hex sha based on the closest
298 :class:`~git.refs.reference.Reference`.
300 :note:
301 Mostly useful for UI purposes.
302 """
303 return self.repo.git.name_rev(self)
305 @classmethod
306 def iter_items(
307 cls,
308 repo: "Repo",
309 rev: Union[str, "Commit", "SymbolicReference"],
310 paths: Union[PathLike, Sequence[PathLike]] = "",
311 allow_unsafe_options: bool = False,
312 **kwargs: Any,
313 ) -> Iterator["Commit"]:
314 R"""Find all commits matching the given criteria.
316 :param repo:
317 The :class:`~git.repo.base.Repo`.
319 :param rev:
320 Revision specifier. See :manpage:`git-rev-parse(1)` for viable options.
322 :param paths:
323 An optional path or list of paths. If set only :class:`Commit`\s that
324 include the path or paths will be considered.
326 :param kwargs:
327 Optional keyword arguments to :manpage:`git-rev-list(1)` where:
329 * ``max_count`` is the maximum number of commits to fetch.
330 * ``skip`` is the number of commits to skip.
331 * ``since`` selects all commits since some date, e.g. ``"1970-01-01"``.
333 :return:
334 Iterator yielding :class:`Commit` items.
335 """
336 if "pretty" in kwargs:
337 raise ValueError("--pretty cannot be used as parsing expects single sha's only")
338 # END handle pretty
340 if not allow_unsafe_options:
341 Git.check_unsafe_options(
342 options=Git._option_candidates([rev], kwargs), unsafe_options=cls.unsafe_git_rev_options
343 )
345 # Use -- in all cases, to prevent possibility of ambiguous arguments.
346 # See https://github.com/gitpython-developers/GitPython/issues/264.
348 args_list: List[PathLike] = ["--"]
350 if paths:
351 paths_tup: Tuple[PathLike, ...]
352 if isinstance(paths, (str, os.PathLike)):
353 paths_tup = (paths,)
354 else:
355 paths_tup = tuple(paths)
357 args_list.extend(paths_tup)
358 # END if paths
360 proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs)
361 return cls._iter_from_process_or_stream(repo, proc)
363 def iter_parents(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> Iterator["Commit"]:
364 R"""Iterate *all* parents of this commit.
366 :param paths:
367 Optional path or list of paths limiting the :class:`Commit`\s to those that
368 contain at least one of the paths.
370 :param kwargs:
371 All arguments allowed by :manpage:`git-rev-list(1)`.
373 :return:
374 Iterator yielding :class:`Commit` objects which are parents of ``self``
375 """
376 # skip ourselves
377 skip = kwargs.get("skip", 1)
378 if skip == 0: # skip ourselves
379 skip = 1
380 kwargs["skip"] = skip
382 return self.iter_items(self.repo, self, paths, **kwargs)
384 @property
385 def stats(self) -> Stats:
386 """Create a git stat from changes between this commit and its first parent
387 or from all changes done if this is the very first commit.
389 :note:
390 If this commit is at the boundary of a shallow clone, this will
391 raise :exc:`~git.exc.GitCommandError`, since the parent object
392 was never fetched and only exists as a reference on this commit.
394 :return:
395 :class:`Stats`
396 """
398 def process_lines(lines: List[str]) -> str:
399 text = ""
400 for file_info, line in zip(lines, lines[len(lines) // 2 :]):
401 change_type = file_info.split("\t")[0][-1]
402 (insertions, deletions, filename) = line.split("\t")
403 text += "%s\t%s\t%s\t%s\n" % (change_type, insertions, deletions, filename)
404 return text
406 if not self.parents:
407 lines = self.repo.git.diff_tree(
408 self.hexsha, "--", numstat=True, no_renames=True, root=True, raw=True
409 ).splitlines()[1:]
410 text = process_lines(lines)
411 else:
412 lines = self.repo.git.diff(
413 self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True, raw=True
414 ).splitlines()
415 text = process_lines(lines)
416 return Stats._list_from_string(self.repo, text)
418 @property
419 def trailers(self) -> Dict[str, str]:
420 """Deprecated. Get the trailers of the message as a dictionary.
422 :note:
423 This property is deprecated, please use either :attr:`trailers_list` or
424 :attr:`trailers_dict`.
426 :return:
427 Dictionary containing whitespace stripped trailer information.
428 Only contains the latest instance of each trailer key.
429 """
430 warnings.warn(
431 "Commit.trailers is deprecated, use Commit.trailers_list or Commit.trailers_dict instead",
432 DeprecationWarning,
433 stacklevel=2,
434 )
435 return {k: v[0] for k, v in self.trailers_dict.items()}
437 @property
438 def trailers_list(self) -> List[Tuple[str, str]]:
439 """Get the trailers of the message as a list.
441 Git messages can contain trailer information that are similar to :rfc:`822`
442 e-mail headers. See :manpage:`git-interpret-trailers(1)`.
444 This function calls ``git interpret-trailers --parse`` onto the message to
445 extract the trailer information, returns the raw trailer data as a list.
447 Valid message with trailer::
449 Subject line
451 some body information
453 another information
455 key1: value1.1
456 key1: value1.2
457 key2 : value 2 with inner spaces
459 Returned list will look like this::
461 [
462 ("key1", "value1.1"),
463 ("key1", "value1.2"),
464 ("key2", "value 2 with inner spaces"),
465 ]
467 :return:
468 List containing key-value tuples of whitespace stripped trailer information.
469 """
470 trailer = self._interpret_trailers(self.repo, self.message, ["--parse"], encoding=self.encoding).strip()
472 if not trailer:
473 return []
475 trailer_list = []
476 for t in trailer.split("\n"):
477 key, val = t.split(":", 1)
478 trailer_list.append((key.strip(), val.strip()))
480 return trailer_list
482 @classmethod
483 def _interpret_trailers(
484 cls,
485 repo: "Repo",
486 message: Union[str, bytes],
487 trailer_args: Sequence[str],
488 encoding: str = default_encoding,
489 ) -> str:
490 message_bytes = message if isinstance(message, bytes) else message.encode(encoding, errors="strict")
491 cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args]
492 proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload]
493 cmd,
494 as_process=True,
495 istream=PIPE,
496 )
497 try:
498 stdout_bytes, _ = proc.communicate(message_bytes)
499 return stdout_bytes.decode(encoding, errors="strict")
500 finally:
501 finalize_process(proc)
503 @property
504 def trailers_dict(self) -> Dict[str, List[str]]:
505 """Get the trailers of the message as a dictionary.
507 Git messages can contain trailer information that are similar to :rfc:`822`
508 e-mail headers. See :manpage:`git-interpret-trailers(1)`.
510 This function calls ``git interpret-trailers --parse`` onto the message to
511 extract the trailer information. The key value pairs are stripped of leading and
512 trailing whitespaces before they get saved into a dictionary.
514 Valid message with trailer::
516 Subject line
518 some body information
520 another information
522 key1: value1.1
523 key1: value1.2
524 key2 : value 2 with inner spaces
526 Returned dictionary will look like this::
528 {
529 "key1": ["value1.1", "value1.2"],
530 "key2": ["value 2 with inner spaces"],
531 }
534 :return:
535 Dictionary containing whitespace stripped trailer information, mapping
536 trailer keys to a list of their corresponding values.
537 """
538 d = defaultdict(list)
539 for key, val in self.trailers_list:
540 d[key].append(val)
541 return dict(d)
543 @classmethod
544 def _iter_from_process_or_stream(cls, repo: "Repo", proc_or_stream: Union[Popen, IO]) -> Iterator["Commit"]:
545 """Parse out commit information into a list of :class:`Commit` objects.
547 We expect one line per commit, and parse the actual commit information directly
548 from our lighting fast object database.
550 :param proc:
551 :manpage:`git-rev-list(1)` process instance - one sha per line.
553 :return:
554 Iterator supplying :class:`Commit` objects
555 """
557 # def is_proc(inp) -> TypeGuard[Popen]:
558 # return hasattr(proc_or_stream, 'wait') and not hasattr(proc_or_stream, 'readline')
560 # def is_stream(inp) -> TypeGuard[IO]:
561 # return hasattr(proc_or_stream, 'readline')
563 if hasattr(proc_or_stream, "wait"):
564 proc_or_stream = cast(Popen, proc_or_stream)
565 if proc_or_stream.stdout is not None:
566 stream = proc_or_stream.stdout
567 elif hasattr(proc_or_stream, "readline"):
568 proc_or_stream = cast(IO, proc_or_stream) # type: ignore[redundant-cast]
569 stream = proc_or_stream
571 readline = stream.readline
572 while True:
573 line = readline()
574 if not line:
575 break
576 hexsha = line.strip()
577 if len(hexsha) > 40:
578 # Split additional information, as returned by bisect for instance.
579 hexsha, _ = line.split(None, 1)
580 # END handle extra info
582 assert len(hexsha) == 40, "Invalid line: %s" % hexsha
583 yield cls(repo, hex_to_bin(hexsha))
584 # END for each line in stream
586 # TODO: Review this - it seems process handling got a bit out of control due to
587 # many developers trying to fix the open file handles issue.
588 if hasattr(proc_or_stream, "wait"):
589 proc_or_stream = cast(Popen, proc_or_stream)
590 finalize_process(proc_or_stream)
592 @classmethod
593 def create_from_tree(
594 cls,
595 repo: "Repo",
596 tree: Union[Tree, str],
597 message: str,
598 parent_commits: Union[None, List["Commit"]] = None,
599 head: bool = False,
600 author: Union[None, Actor] = None,
601 committer: Union[None, Actor] = None,
602 author_date: Union[None, str, datetime.datetime] = None,
603 commit_date: Union[None, str, datetime.datetime] = None,
604 trailers: Union[None, Dict[str, str], List[Tuple[str, str]]] = None,
605 ) -> "Commit":
606 """Commit the given tree, creating a :class:`Commit` object.
608 :param repo:
609 :class:`~git.repo.base.Repo` object the commit should be part of.
611 :param tree:
612 :class:`~git.objects.tree.Tree` object or hex or bin sha.
613 The tree of the new commit.
615 :param message:
616 Commit message. It may be an empty string if no message is provided. It will
617 be converted to a string, in any case.
619 :param parent_commits:
620 Optional :class:`Commit` objects to use as parents for the new commit. If
621 empty list, the commit will have no parents at all and become a root commit.
622 If ``None``, the current head commit will be the parent of the new commit
623 object.
625 :param head:
626 If ``True``, the HEAD will be advanced to the new commit automatically.
627 Otherwise the HEAD will remain pointing on the previous commit. This could
628 lead to undesired results when diffing files.
630 :param author:
631 The name of the author, optional.
632 If unset, the repository configuration is used to obtain this value.
634 :param committer:
635 The name of the committer, optional.
636 If unset, the repository configuration is used to obtain this value.
638 :param author_date:
639 The timestamp for the author field.
641 :param commit_date:
642 The timestamp for the committer field.
644 :param trailers:
645 Optional trailer key-value pairs to append to the commit message.
646 Can be a dictionary mapping trailer keys to values, or a list of
647 ``(key, value)`` tuples (useful when the same key appears multiple
648 times, e.g. multiple ``Signed-off-by`` trailers). Trailers are
649 appended using ``git interpret-trailers``.
650 See :manpage:`git-interpret-trailers(1)`.
652 :return:
653 :class:`Commit` object representing the new commit.
655 :note:
656 Additional information about the committer and author are taken from the
657 environment or from the git configuration. See :manpage:`git-commit-tree(1)`
658 for more information.
659 """
660 if parent_commits is None:
661 try:
662 parent_commits = [repo.head.commit]
663 except ValueError:
664 # Empty repositories have no head commit.
665 parent_commits = []
666 # END handle parent commits
667 else:
668 for p in parent_commits:
669 if not isinstance(p, cls):
670 raise ValueError(f"Parent commit '{p!r}' must be of type {cls}")
671 # END check parent commit types
672 # END if parent commits are unset
674 # Retrieve all additional information, create a commit object, and serialize it.
675 # Generally:
676 # * Environment variables override configuration values.
677 # * Sensible defaults are set according to the git documentation.
679 # COMMITTER AND AUTHOR INFO
680 cr = repo.config_reader()
681 env = os.environ
683 committer = committer or Actor.committer(cr)
684 author = author or Actor.author(cr)
686 # PARSE THE DATES
687 unix_time = int(time())
688 is_dst = daylight and localtime().tm_isdst > 0
689 offset = altzone if is_dst else timezone
691 author_date_str = env.get(cls.env_author_date, "")
692 if author_date:
693 author_time, author_offset = parse_date(author_date)
694 elif author_date_str:
695 author_time, author_offset = parse_date(author_date_str)
696 else:
697 author_time, author_offset = unix_time, offset
698 # END set author time
700 committer_date_str = env.get(cls.env_committer_date, "")
701 if commit_date:
702 committer_time, committer_offset = parse_date(commit_date)
703 elif committer_date_str:
704 committer_time, committer_offset = parse_date(committer_date_str)
705 else:
706 committer_time, committer_offset = unix_time, offset
707 # END set committer time
709 # Assume UTF-8 encoding.
710 enc_section, enc_option = cls.conf_encoding.split(".")
711 conf_encoding = cr.get_value(enc_section, enc_option, cls.default_encoding)
712 if not isinstance(conf_encoding, str):
713 raise TypeError("conf_encoding could not be coerced to str")
715 # If the tree is no object, make sure we create one - otherwise the created
716 # commit object is invalid.
717 if isinstance(tree, str):
718 tree = repo.tree(tree)
719 # END tree conversion
721 # APPLY TRAILERS
722 if trailers:
723 trailer_args: List[str] = []
724 if isinstance(trailers, dict):
725 for key, val in trailers.items():
726 trailer_args.append("--trailer")
727 trailer_args.append(f"{key}: {val}")
728 else:
729 for key, val in trailers:
730 trailer_args.append("--trailer")
731 trailer_args.append(f"{key}: {val}")
733 message = cls._interpret_trailers(repo, str(message), trailer_args)
734 # END apply trailers
736 # CREATE NEW COMMIT
737 new_commit = cls(
738 repo,
739 cls.NULL_BIN_SHA,
740 tree,
741 author,
742 author_time,
743 author_offset,
744 committer,
745 committer_time,
746 committer_offset,
747 message,
748 parent_commits,
749 conf_encoding,
750 )
752 new_commit.binsha = cls._calculate_sha_(repo, new_commit)
754 if head:
755 # Need late import here, importing git at the very beginning throws as
756 # well...
757 import git.refs
759 try:
760 repo.head.set_commit(new_commit, logmsg=message)
761 except ValueError:
762 # head is not yet set to the ref our HEAD points to.
763 # Happens on first commit.
764 master = git.refs.Head.create(
765 repo,
766 repo.head.ref,
767 new_commit,
768 logmsg="commit (initial): %s" % message,
769 )
770 repo.head.set_reference(master, logmsg="commit: Switching to %s" % master)
771 # END handle empty repositories
772 # END advance head handling
774 return new_commit
776 # { Serializable Implementation
778 def _serialize(self, stream: BytesIO) -> "Commit":
779 write = stream.write
780 write(("tree %s\n" % self.tree).encode("ascii"))
781 for p in self.parents:
782 write(("parent %s\n" % p).encode("ascii"))
784 a = self.author
785 aname = a.name
786 c = self.committer
787 fmt = "%s %s <%s> %s %s\n"
788 write(
789 (
790 fmt
791 % (
792 "author",
793 aname,
794 a.email,
795 self.authored_date,
796 altz_to_utctz_str(self.author_tz_offset),
797 )
798 ).encode(self.encoding)
799 )
801 # Encode committer.
802 aname = c.name
803 write(
804 (
805 fmt
806 % (
807 "committer",
808 aname,
809 c.email,
810 self.committed_date,
811 altz_to_utctz_str(self.committer_tz_offset),
812 )
813 ).encode(self.encoding)
814 )
816 if self.encoding != self.default_encoding:
817 write(("encoding %s\n" % self.encoding).encode("ascii"))
819 try:
820 if self.__getattribute__("gpgsig"):
821 write(b"gpgsig")
822 for sigline in self.gpgsig.rstrip("\n").split("\n"):
823 write((" " + sigline + "\n").encode("ascii"))
824 except AttributeError:
825 pass
827 write(b"\n")
829 # Write plain bytes, be sure its encoded according to our encoding.
830 if isinstance(self.message, str):
831 write(self.message.encode(self.encoding))
832 else:
833 write(self.message)
834 # END handle encoding
835 return self
837 def _deserialize(self, stream: BytesIO) -> "Commit":
838 readline = stream.readline
839 self.tree = Tree(self.repo, hex_to_bin(readline().split()[1]), Tree.tree_id << 12, "")
841 self.parents = []
842 next_line = None
843 while True:
844 parent_line = readline()
845 if not parent_line.startswith(b"parent"):
846 next_line = parent_line
847 break
848 # END abort reading parents
849 self.parents.append(type(self)(self.repo, hex_to_bin(parent_line.split()[-1].decode("ascii"))))
850 # END for each parent line
851 self.parents = tuple(self.parents)
853 # We don't know actual author encoding before we have parsed it, so keep the
854 # lines around.
855 author_line = next_line
856 committer_line = readline()
858 # We might run into one or more mergetag blocks, skip those for now.
859 next_line = readline()
860 while next_line.startswith(b"mergetag "):
861 next_line = readline()
862 while next_line.startswith(b" "):
863 next_line = readline()
864 # END skip mergetags
866 # Now we can have the encoding line, or an empty line followed by the optional
867 # message.
868 self.encoding = self.default_encoding
869 self.gpgsig = ""
871 # Read headers.
872 enc = next_line
873 buf = enc.strip()
874 while buf:
875 if buf[0:10] == b"encoding ":
876 self.encoding = buf[buf.find(b" ") + 1 :].decode(self.encoding, "ignore")
877 elif buf[0:7] == b"gpgsig ":
878 sig = buf[buf.find(b" ") + 1 :] + b"\n"
879 is_next_header = False
880 while True:
881 sigbuf = readline()
882 if not sigbuf:
883 break
884 if sigbuf[0:1] != b" ":
885 buf = sigbuf.strip()
886 is_next_header = True
887 break
888 sig += sigbuf[1:]
889 # END read all signature
890 self.gpgsig = sig.rstrip(b"\n").decode(self.encoding, "ignore")
891 if is_next_header:
892 continue
893 buf = readline().strip()
895 # Decode the author's name.
896 try:
897 (
898 self.author,
899 self.authored_date,
900 self.author_tz_offset,
901 ) = parse_actor_and_date(author_line.decode(self.encoding, "replace"))
902 except UnicodeDecodeError:
903 _logger.error(
904 "Failed to decode author line '%s' using encoding %s",
905 author_line,
906 self.encoding,
907 exc_info=True,
908 )
910 try:
911 (
912 self.committer,
913 self.committed_date,
914 self.committer_tz_offset,
915 ) = parse_actor_and_date(committer_line.decode(self.encoding, "replace"))
916 except UnicodeDecodeError:
917 _logger.error(
918 "Failed to decode committer line '%s' using encoding %s",
919 committer_line,
920 self.encoding,
921 exc_info=True,
922 )
923 # END handle author's encoding
925 # A stream from our data simply gives us the plain message.
926 # The end of our message stream is marked with a newline that we strip.
927 self.message = stream.read()
928 try:
929 self.message = self.message.decode(self.encoding, "replace")
930 except UnicodeDecodeError:
931 _logger.error(
932 "Failed to decode message '%s' using encoding %s",
933 self.message,
934 self.encoding,
935 exc_info=True,
936 )
937 # END exception handling
939 return self
941 # } END serializable implementation
943 @property
944 def co_authors(self) -> List[Actor]:
945 """Search the commit message for any co-authors of this commit.
947 Details on co-authors:
948 https://github.blog/2018-01-29-commit-together-with-co-authors/
950 :return:
951 List of co-authors for this commit (as :class:`~git.util.Actor` objects).
952 """
953 co_authors = []
955 if self.message:
956 results = re.findall(
957 r"^Co-authored-by: (.*) <(.*?)>$",
958 str(self.message),
959 re.MULTILINE,
960 )
961 for author in results:
962 co_authors.append(Actor(*author))
964 return co_authors