Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/git/objects/commit.py: 53%
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
13from subprocess import Popen, PIPE
14from time import altzone, daylight, localtime, time, timezone
15import warnings
17from gitdb import IStream
19from git.cmd import Git
20from git.diff import Diffable
21from git.util import Actor, Stats, finalize_process, hex_to_bin
23from . import base
24from .tree import Tree
25from .util import (
26 Serializable,
27 TraversableIterableObj,
28 altz_to_utctz_str,
29 from_timestamp,
30 parse_actor_and_date,
31 parse_date,
32)
34# typing ------------------------------------------------------------------
36from typing import (
37 Any,
38 Dict,
39 IO,
40 Iterator,
41 List,
42 Literal,
43 Sequence,
44 Tuple,
45 TYPE_CHECKING,
46 Union,
47 cast,
48)
50from git.types import PathLike
52if TYPE_CHECKING:
53 from git.refs import SymbolicReference
54 from git.repo import Repo
56# ------------------------------------------------------------------------
58_logger = logging.getLogger(__name__)
61class Commit(base.Object, TraversableIterableObj, Diffable, Serializable):
62 """Wraps a git commit object.
64 See :manpage:`gitglossary(7)` on "commit object":
65 https://git-scm.com/docs/gitglossary#def_commit_object
67 :note:
68 This class will act lazily on some of its attributes and will query the value on
69 demand only if it involves calling the git binary.
70 """
72 # ENVIRONMENT VARIABLES
73 # Read when creating new commits.
74 env_author_date = "GIT_AUTHOR_DATE"
75 env_committer_date = "GIT_COMMITTER_DATE"
77 # CONFIGURATION KEYS
78 conf_encoding = "i18n.commitencoding"
80 # INVARIANTS
81 default_encoding = "UTF-8"
83 # Options to :manpage:`git-rev-list(1)` that can overwrite files.
84 unsafe_git_rev_options = [
85 "--output",
86 "-o",
87 ]
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(
267 self,
268 paths: Union[PathLike, Sequence[PathLike]] = "",
269 allow_unsafe_options: bool = False,
270 **kwargs: Any,
271 ) -> int:
272 """Count the number of commits reachable from this commit.
274 :param paths:
275 An optional path or a list of paths restricting the return value to commits
276 actually containing the paths.
278 :param allow_unsafe_options:
279 Allow unsafe options, like ``--output``.
281 :param kwargs:
282 Additional options to be passed to :manpage:`git-rev-list(1)`. They must not
283 alter the output style of the command, or parsing will yield incorrect
284 results.
286 :return:
287 An int defining the number of reachable commits
288 """
289 if not allow_unsafe_options:
290 Git.check_unsafe_options(
291 options=Git._option_candidates([], kwargs), unsafe_options=self.unsafe_git_rev_options
292 )
294 # Yes, it makes a difference whether empty paths are given or not in our case as
295 # the empty paths version will ignore merge commits for some reason.
296 if paths:
297 return len(self.repo.git.rev_list(self.hexsha, "--", paths, **kwargs).splitlines())
298 return len(self.repo.git.rev_list(self.hexsha, **kwargs).splitlines())
300 @property
301 def name_rev(self) -> str:
302 """
303 :return:
304 String describing the commits hex sha based on the closest
305 :class:`~git.refs.reference.Reference`.
307 :note:
308 Mostly useful for UI purposes.
309 """
310 return self.repo.git.name_rev(self)
312 @classmethod
313 def iter_items(
314 cls,
315 repo: "Repo",
316 rev: Union[str, "Commit", "SymbolicReference"],
317 paths: Union[PathLike, Sequence[PathLike]] = "",
318 allow_unsafe_options: bool = False,
319 **kwargs: Any,
320 ) -> Iterator["Commit"]:
321 R"""Find all commits matching the given criteria.
323 :param repo:
324 The :class:`~git.repo.base.Repo`.
326 :param rev:
327 Revision specifier. See :manpage:`git-rev-parse(1)` for viable options.
329 :param paths:
330 An optional path or list of paths. If set only :class:`Commit`\s that
331 include the path or paths will be considered.
333 :param kwargs:
334 Optional keyword arguments to :manpage:`git-rev-list(1)` where:
336 * ``max_count`` is the maximum number of commits to fetch.
337 * ``skip`` is the number of commits to skip.
338 * ``since`` selects all commits since some date, e.g. ``"1970-01-01"``.
340 :return:
341 Iterator yielding :class:`Commit` items.
342 """
343 if "pretty" in kwargs:
344 raise ValueError("--pretty cannot be used as parsing expects single sha's only")
345 # END handle pretty
347 if not allow_unsafe_options:
348 Git.check_unsafe_options(
349 options=Git._option_candidates([rev], kwargs), unsafe_options=cls.unsafe_git_rev_options
350 )
352 # Use -- in all cases, to prevent possibility of ambiguous arguments.
353 # See https://github.com/gitpython-developers/GitPython/issues/264.
355 args_list: List[PathLike] = ["--"]
357 if paths:
358 paths_tup: Tuple[PathLike, ...]
359 if isinstance(paths, (str, os.PathLike)):
360 paths_tup = (paths,)
361 else:
362 paths_tup = tuple(paths)
364 args_list.extend(paths_tup)
365 # END if paths
367 proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs)
368 return cls._iter_from_process_or_stream(repo, proc)
370 def iter_parents(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> Iterator["Commit"]:
371 R"""Iterate *all* parents of this commit.
373 :param paths:
374 Optional path or list of paths limiting the :class:`Commit`\s to those that
375 contain at least one of the paths.
377 :param kwargs:
378 All arguments allowed by :manpage:`git-rev-list(1)`.
380 :return:
381 Iterator yielding :class:`Commit` objects which are parents of ``self``
382 """
383 # skip ourselves
384 skip = kwargs.get("skip", 1)
385 if skip == 0: # skip ourselves
386 skip = 1
387 kwargs["skip"] = skip
389 return self.iter_items(self.repo, self, paths, **kwargs)
391 @property
392 def stats(self) -> Stats:
393 """Create a git stat from changes between this commit and its first parent
394 or from all changes done if this is the very first commit.
396 :note:
397 If this commit is at the boundary of a shallow clone, this will
398 raise :exc:`~git.exc.GitCommandError`, since the parent object
399 was never fetched and only exists as a reference on this commit.
401 :return:
402 :class:`Stats`
403 """
405 def process_lines(lines: List[str]) -> str:
406 text = ""
407 for file_info, line in zip(lines, lines[len(lines) // 2 :]):
408 change_type = file_info.split("\t")[0][-1]
409 (insertions, deletions, filename) = line.split("\t")
410 text += "%s\t%s\t%s\t%s\n" % (change_type, insertions, deletions, filename)
411 return text
413 if not self.parents:
414 lines = self.repo.git.diff_tree(
415 self.hexsha, "--", numstat=True, no_renames=True, root=True, raw=True
416 ).splitlines()[1:]
417 text = process_lines(lines)
418 else:
419 lines = self.repo.git.diff(
420 self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True, raw=True
421 ).splitlines()
422 text = process_lines(lines)
423 return Stats._list_from_string(self.repo, text)
425 @property
426 def trailers(self) -> Dict[str, str]:
427 """Deprecated. Get the trailers of the message as a dictionary.
429 :note:
430 This property is deprecated, please use either :attr:`trailers_list` or
431 :attr:`trailers_dict`.
433 :return:
434 Dictionary containing whitespace stripped trailer information.
435 Only contains the latest instance of each trailer key.
436 """
437 warnings.warn(
438 "Commit.trailers is deprecated, use Commit.trailers_list or Commit.trailers_dict instead",
439 DeprecationWarning,
440 stacklevel=2,
441 )
442 return {k: v[0] for k, v in self.trailers_dict.items()}
444 @property
445 def trailers_list(self) -> List[Tuple[str, str]]:
446 """Get the trailers of the message as a list.
448 Git messages can contain trailer information that are similar to :rfc:`822`
449 e-mail headers. See :manpage:`git-interpret-trailers(1)`.
451 This function calls ``git interpret-trailers --parse`` onto the message to
452 extract the trailer information, returns the raw trailer data as a list.
454 Valid message with trailer::
456 Subject line
458 some body information
460 another information
462 key1: value1.1
463 key1: value1.2
464 key2 : value 2 with inner spaces
466 Returned list will look like this::
468 [
469 ("key1", "value1.1"),
470 ("key1", "value1.2"),
471 ("key2", "value 2 with inner spaces"),
472 ]
474 :return:
475 List containing key-value tuples of whitespace stripped trailer information.
476 """
477 trailer = self._interpret_trailers(self.repo, self.message, ["--parse"], encoding=self.encoding).strip()
479 if not trailer:
480 return []
482 trailer_list = []
483 for t in trailer.split("\n"):
484 key, val = t.split(":", 1)
485 trailer_list.append((key.strip(), val.strip()))
487 return trailer_list
489 @classmethod
490 def _interpret_trailers(
491 cls,
492 repo: "Repo",
493 message: Union[str, bytes],
494 trailer_args: Sequence[str],
495 encoding: str = default_encoding,
496 ) -> str:
497 message_bytes = message if isinstance(message, bytes) else message.encode(encoding, errors="strict")
498 cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args]
499 proc: Git.AutoInterrupt = repo.git.execute(
500 cmd,
501 as_process=True,
502 istream=PIPE,
503 )
504 try:
505 stdout_bytes, _ = proc.communicate(message_bytes)
506 return stdout_bytes.decode(encoding, errors="strict")
507 finally:
508 finalize_process(proc)
510 @property
511 def trailers_dict(self) -> Dict[str, List[str]]:
512 """Get the trailers of the message as a dictionary.
514 Git messages can contain trailer information that are similar to :rfc:`822`
515 e-mail headers. See :manpage:`git-interpret-trailers(1)`.
517 This function calls ``git interpret-trailers --parse`` onto the message to
518 extract the trailer information. The key value pairs are stripped of leading and
519 trailing whitespaces before they get saved into a dictionary.
521 Valid message with trailer::
523 Subject line
525 some body information
527 another information
529 key1: value1.1
530 key1: value1.2
531 key2 : value 2 with inner spaces
533 Returned dictionary will look like this::
535 {
536 "key1": ["value1.1", "value1.2"],
537 "key2": ["value 2 with inner spaces"],
538 }
541 :return:
542 Dictionary containing whitespace stripped trailer information, mapping
543 trailer keys to a list of their corresponding values.
544 """
545 d = defaultdict(list)
546 for key, val in self.trailers_list:
547 d[key].append(val)
548 return dict(d)
550 @classmethod
551 def _iter_from_process_or_stream(cls, repo: "Repo", proc_or_stream: Union[Popen, IO]) -> Iterator["Commit"]:
552 """Parse out commit information into a list of :class:`Commit` objects.
554 We expect one line per commit, and parse the actual commit information directly
555 from our lighting fast object database.
557 :param proc:
558 :manpage:`git-rev-list(1)` process instance - one sha per line.
560 :return:
561 Iterator supplying :class:`Commit` objects
562 """
564 # def is_proc(inp) -> TypeGuard[Popen]:
565 # return hasattr(proc_or_stream, 'wait') and not hasattr(proc_or_stream, 'readline')
567 # def is_stream(inp) -> TypeGuard[IO]:
568 # return hasattr(proc_or_stream, 'readline')
570 if hasattr(proc_or_stream, "wait"):
571 proc_or_stream = cast(Popen, proc_or_stream)
572 stream = proc_or_stream.stdout
573 if stream is None:
574 raise ValueError("Process has no stdout stream")
575 elif hasattr(proc_or_stream, "readline"):
576 proc_or_stream = cast(IO, proc_or_stream) # type: ignore[redundant-cast]
577 stream = proc_or_stream
578 else:
579 raise TypeError("Expected a process or stream")
581 readline = stream.readline
582 while True:
583 line = readline()
584 if not line:
585 break
586 hexsha = line.strip()
587 if len(hexsha) > 40:
588 # Split additional information, as returned by bisect for instance.
589 hexsha, _ = line.split(None, 1)
590 # END handle extra info
592 assert len(hexsha) == 40, "Invalid line: %s" % hexsha
593 yield cls(repo, hex_to_bin(hexsha))
594 # END for each line in stream
596 # TODO: Review this - it seems process handling got a bit out of control due to
597 # many developers trying to fix the open file handles issue.
598 if hasattr(proc_or_stream, "wait"):
599 proc_or_stream = cast(Popen, proc_or_stream)
600 finalize_process(proc_or_stream)
602 @classmethod
603 def create_from_tree(
604 cls,
605 repo: "Repo",
606 tree: Union[Tree, str],
607 message: str,
608 parent_commits: Union[None, List["Commit"]] = None,
609 head: bool = False,
610 author: Union[None, Actor] = None,
611 committer: Union[None, Actor] = None,
612 author_date: Union[None, str, datetime.datetime] = None,
613 commit_date: Union[None, str, datetime.datetime] = None,
614 trailers: Union[None, Dict[str, str], List[Tuple[str, str]]] = None,
615 ) -> "Commit":
616 """Commit the given tree, creating a :class:`Commit` object.
618 :param repo:
619 :class:`~git.repo.base.Repo` object the commit should be part of.
621 :param tree:
622 :class:`~git.objects.tree.Tree` object or hex or bin sha.
623 The tree of the new commit.
625 :param message:
626 Commit message. It may be an empty string if no message is provided. It will
627 be converted to a string, in any case.
629 :param parent_commits:
630 Optional :class:`Commit` objects to use as parents for the new commit. If
631 empty list, the commit will have no parents at all and become a root commit.
632 If ``None``, the current head commit will be the parent of the new commit
633 object.
635 :param head:
636 If ``True``, the HEAD will be advanced to the new commit automatically.
637 Otherwise the HEAD will remain pointing on the previous commit. This could
638 lead to undesired results when diffing files.
640 :param author:
641 The name of the author, optional.
642 If unset, the repository configuration is used to obtain this value.
644 :param committer:
645 The name of the committer, optional.
646 If unset, the repository configuration is used to obtain this value.
648 :param author_date:
649 The timestamp for the author field.
651 :param commit_date:
652 The timestamp for the committer field.
654 :param trailers:
655 Optional trailer key-value pairs to append to the commit message.
656 Can be a dictionary mapping trailer keys to values, or a list of
657 ``(key, value)`` tuples (useful when the same key appears multiple
658 times, e.g. multiple ``Signed-off-by`` trailers). Trailers are
659 appended using ``git interpret-trailers``.
660 See :manpage:`git-interpret-trailers(1)`.
662 :return:
663 :class:`Commit` object representing the new commit.
665 :note:
666 Additional information about the committer and author are taken from the
667 environment or from the git configuration. See :manpage:`git-commit-tree(1)`
668 for more information.
669 """
670 if parent_commits is None:
671 try:
672 parent_commits = [repo.head.commit]
673 except ValueError:
674 # Empty repositories have no head commit.
675 parent_commits = []
676 # END handle parent commits
677 else:
678 for p in parent_commits:
679 if not isinstance(p, cls):
680 raise ValueError(f"Parent commit '{p!r}' must be of type {cls}")
681 # END check parent commit types
682 # END if parent commits are unset
684 # Retrieve all additional information, create a commit object, and serialize it.
685 # Generally:
686 # * Environment variables override configuration values.
687 # * Sensible defaults are set according to the git documentation.
689 # COMMITTER AND AUTHOR INFO
690 cr = repo.config_reader()
691 env = os.environ
693 committer = committer or Actor.committer(cr)
694 author = author or Actor.author(cr)
696 # PARSE THE DATES
697 unix_time = int(time())
698 is_dst = daylight and localtime().tm_isdst > 0
699 offset = altzone if is_dst else timezone
701 author_date_str = env.get(cls.env_author_date, "")
702 if author_date:
703 author_time, author_offset = parse_date(author_date)
704 elif author_date_str:
705 author_time, author_offset = parse_date(author_date_str)
706 else:
707 author_time, author_offset = unix_time, offset
708 # END set author time
710 committer_date_str = env.get(cls.env_committer_date, "")
711 if commit_date:
712 committer_time, committer_offset = parse_date(commit_date)
713 elif committer_date_str:
714 committer_time, committer_offset = parse_date(committer_date_str)
715 else:
716 committer_time, committer_offset = unix_time, offset
717 # END set committer time
719 # Assume UTF-8 encoding.
720 enc_section, enc_option = cls.conf_encoding.split(".")
721 conf_encoding = cr.get_value(enc_section, enc_option, cls.default_encoding)
722 if not isinstance(conf_encoding, str):
723 raise TypeError("conf_encoding could not be coerced to str")
725 # If the tree is no object, make sure we create one - otherwise the created
726 # commit object is invalid.
727 if isinstance(tree, str):
728 tree = repo.tree(tree)
729 # END tree conversion
731 # APPLY TRAILERS
732 if trailers:
733 trailer_args: List[str] = []
734 if isinstance(trailers, dict):
735 for key, val in trailers.items():
736 trailer_args.append("--trailer")
737 trailer_args.append(f"{key}: {val}")
738 else:
739 for key, val in trailers:
740 trailer_args.append("--trailer")
741 trailer_args.append(f"{key}: {val}")
743 message = cls._interpret_trailers(repo, str(message), trailer_args)
744 # END apply trailers
746 # CREATE NEW COMMIT
747 new_commit = cls(
748 repo,
749 cls.NULL_BIN_SHA,
750 tree,
751 author,
752 author_time,
753 author_offset,
754 committer,
755 committer_time,
756 committer_offset,
757 message,
758 parent_commits,
759 conf_encoding,
760 )
762 new_commit.binsha = cls._calculate_sha_(repo, new_commit)
764 if head:
765 # Need late import here, importing git at the very beginning throws as
766 # well...
767 import git.refs
769 try:
770 repo.head.set_commit(new_commit, logmsg=message)
771 except ValueError:
772 # head is not yet set to the ref our HEAD points to.
773 # Happens on first commit.
774 master = git.refs.Head.create(
775 repo,
776 repo.head.ref,
777 new_commit,
778 logmsg="commit (initial): %s" % message,
779 )
780 repo.head.set_reference(master, logmsg="commit: Switching to %s" % master)
781 # END handle empty repositories
782 # END advance head handling
784 return new_commit
786 # { Serializable Implementation
788 def _serialize(self, stream: BytesIO) -> "Commit":
789 write = stream.write
790 write(("tree %s\n" % self.tree).encode("ascii"))
791 for p in self.parents:
792 write(("parent %s\n" % p).encode("ascii"))
794 a = self.author
795 aname = a.name
796 c = self.committer
797 fmt = "%s %s <%s> %s %s\n"
798 write(
799 (
800 fmt
801 % (
802 "author",
803 aname,
804 a.email,
805 self.authored_date,
806 altz_to_utctz_str(self.author_tz_offset),
807 )
808 ).encode(self.encoding)
809 )
811 # Encode committer.
812 aname = c.name
813 write(
814 (
815 fmt
816 % (
817 "committer",
818 aname,
819 c.email,
820 self.committed_date,
821 altz_to_utctz_str(self.committer_tz_offset),
822 )
823 ).encode(self.encoding)
824 )
826 if self.encoding != self.default_encoding:
827 write(("encoding %s\n" % self.encoding).encode("ascii"))
829 try:
830 if self.__getattribute__("gpgsig"):
831 write(b"gpgsig")
832 for sigline in self.gpgsig.rstrip("\n").split("\n"):
833 write((" " + sigline + "\n").encode("ascii"))
834 except AttributeError:
835 pass
837 write(b"\n")
839 # Write plain bytes, be sure its encoded according to our encoding.
840 if isinstance(self.message, str):
841 write(self.message.encode(self.encoding))
842 else:
843 write(self.message)
844 # END handle encoding
845 return self
847 def _deserialize(self, stream: BytesIO) -> "Commit":
848 readline = stream.readline
849 self.tree = Tree(self.repo, hex_to_bin(readline().split()[1]), Tree.tree_id << 12, "")
851 self.parents = []
852 next_line = None
853 while True:
854 parent_line = readline()
855 if not parent_line.startswith(b"parent"):
856 next_line = parent_line
857 break
858 # END abort reading parents
859 self.parents.append(type(self)(self.repo, hex_to_bin(parent_line.split()[-1].decode("ascii"))))
860 # END for each parent line
861 self.parents = tuple(self.parents)
863 # We don't know actual author encoding before we have parsed it, so keep the
864 # lines around.
865 author_line = next_line
866 committer_line = readline()
868 # We might run into one or more mergetag blocks, skip those for now.
869 next_line = readline()
870 while next_line.startswith(b"mergetag "):
871 next_line = readline()
872 while next_line.startswith(b" "):
873 next_line = readline()
874 # END skip mergetags
876 # Now we can have the encoding line, or an empty line followed by the optional
877 # message.
878 self.encoding = self.default_encoding
879 self.gpgsig = ""
881 # Read headers.
882 enc = next_line
883 buf = enc.strip()
884 while buf:
885 if buf[0:10] == b"encoding ":
886 self.encoding = buf[buf.find(b" ") + 1 :].decode(self.encoding, "ignore")
887 elif buf[0:7] == b"gpgsig ":
888 sig_lines = [buf[buf.find(b" ") + 1 :] + b"\n"]
889 is_next_header = False
890 while True:
891 sigbuf = readline()
892 if not sigbuf:
893 break
894 if sigbuf[0:1] != b" ":
895 buf = sigbuf.strip()
896 is_next_header = True
897 break
898 sig_lines.append(sigbuf[1:])
899 # END read all signature
900 self.gpgsig = b"".join(sig_lines).rstrip(b"\n").decode(self.encoding, "ignore")
901 if is_next_header:
902 continue
903 buf = readline().strip()
905 # Decode the author's name.
906 try:
907 (
908 self.author,
909 self.authored_date,
910 self.author_tz_offset,
911 ) = parse_actor_and_date(author_line.decode(self.encoding, "replace"))
912 except UnicodeDecodeError:
913 _logger.error(
914 "Failed to decode author line '%s' using encoding %s",
915 author_line,
916 self.encoding,
917 exc_info=True,
918 )
920 try:
921 (
922 self.committer,
923 self.committed_date,
924 self.committer_tz_offset,
925 ) = parse_actor_and_date(committer_line.decode(self.encoding, "replace"))
926 except UnicodeDecodeError:
927 _logger.error(
928 "Failed to decode committer line '%s' using encoding %s",
929 committer_line,
930 self.encoding,
931 exc_info=True,
932 )
933 # END handle author's encoding
935 # A stream from our data simply gives us the plain message.
936 # The end of our message stream is marked with a newline that we strip.
937 self.message = stream.read()
938 try:
939 self.message = self.message.decode(self.encoding, "replace")
940 except UnicodeDecodeError:
941 _logger.error(
942 "Failed to decode message '%s' using encoding %s",
943 self.message,
944 self.encoding,
945 exc_info=True,
946 )
947 # END exception handling
949 return self
951 # } END serializable implementation
953 @property
954 def co_authors(self) -> List[Actor]:
955 """Search the commit message for any co-authors of this commit.
957 Details on co-authors:
958 https://github.blog/2018-01-29-commit-together-with-co-authors/
960 :return:
961 List of co-authors for this commit (as :class:`~git.util.Actor` objects).
962 """
963 co_authors = []
965 if self.message:
966 # Scan line by line instead of matching `(.*) <(.*?)>` across the whole
967 # message. On a single trailer line that repeats " <" without ever closing
968 # a ">", greedy backtracking over each " <" made the regex run in O(n^2)
969 # time, so a large (fully attacker-controlled) commit message could stall
970 # any caller of this property. A trailer is "Co-authored-by: <name> <email>"
971 # with the email in the final angle brackets, so the name ends at the last
972 # " <" and the line ends at ">".
973 prefix = "Co-authored-by: "
974 for line in str(self.message).split("\n"):
975 if not line.startswith(prefix) or not line.endswith(">"):
976 continue
977 identity = line[len(prefix) :]
978 separator = identity.rfind(" <")
979 if separator == -1:
980 continue
981 co_authors.append(Actor(identity[:separator], identity[separator + 2 : -1]))
983 return co_authors