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 :raise ValueError:
666 If the name or email of the author or committer contains ``<``, ``>`` or a
667 line feed, as these would change the identity headers of the commit.
669 :note:
670 Additional information about the committer and author are taken from the
671 environment or from the git configuration. See :manpage:`git-commit-tree(1)`
672 for more information.
673 """
674 if parent_commits is None:
675 try:
676 parent_commits = [repo.head.commit]
677 except ValueError:
678 # Empty repositories have no head commit.
679 parent_commits = []
680 # END handle parent commits
681 else:
682 for p in parent_commits:
683 if not isinstance(p, cls):
684 raise ValueError(f"Parent commit '{p!r}' must be of type {cls}")
685 # END check parent commit types
686 # END if parent commits are unset
688 # Retrieve all additional information, create a commit object, and serialize it.
689 # Generally:
690 # * Environment variables override configuration values.
691 # * Sensible defaults are set according to the git documentation.
693 # COMMITTER AND AUTHOR INFO
694 cr = repo.config_reader()
695 env = os.environ
697 committer = committer or Actor.committer(cr)
698 author = author or Actor.author(cr)
700 # PARSE THE DATES
701 unix_time = int(time())
702 is_dst = daylight and localtime().tm_isdst > 0
703 offset = altzone if is_dst else timezone
705 author_date_str = env.get(cls.env_author_date, "")
706 if author_date:
707 author_time, author_offset = parse_date(author_date)
708 elif author_date_str:
709 author_time, author_offset = parse_date(author_date_str)
710 else:
711 author_time, author_offset = unix_time, offset
712 # END set author time
714 committer_date_str = env.get(cls.env_committer_date, "")
715 if commit_date:
716 committer_time, committer_offset = parse_date(commit_date)
717 elif committer_date_str:
718 committer_time, committer_offset = parse_date(committer_date_str)
719 else:
720 committer_time, committer_offset = unix_time, offset
721 # END set committer time
723 # Assume UTF-8 encoding.
724 enc_section, enc_option = cls.conf_encoding.split(".")
725 conf_encoding = cr.get_value(enc_section, enc_option, cls.default_encoding)
726 if not isinstance(conf_encoding, str):
727 raise TypeError("conf_encoding could not be coerced to str")
729 # If the tree is no object, make sure we create one - otherwise the created
730 # commit object is invalid.
731 if isinstance(tree, str):
732 tree = repo.tree(tree)
733 # END tree conversion
735 # APPLY TRAILERS
736 if trailers:
737 trailer_args: List[str] = []
738 if isinstance(trailers, dict):
739 for key, val in trailers.items():
740 trailer_args.append("--trailer")
741 trailer_args.append(f"{key}: {val}")
742 else:
743 for key, val in trailers:
744 trailer_args.append("--trailer")
745 trailer_args.append(f"{key}: {val}")
747 message = cls._interpret_trailers(repo, str(message), trailer_args)
748 # END apply trailers
750 # CREATE NEW COMMIT
751 new_commit = cls(
752 repo,
753 cls.NULL_BIN_SHA,
754 tree,
755 author,
756 author_time,
757 author_offset,
758 committer,
759 committer_time,
760 committer_offset,
761 message,
762 parent_commits,
763 conf_encoding,
764 )
766 new_commit.binsha = cls._calculate_sha_(repo, new_commit)
768 if head:
769 # Need late import here, importing git at the very beginning throws as
770 # well...
771 import git.refs
773 try:
774 repo.head.set_commit(new_commit, logmsg=message)
775 except ValueError:
776 # head is not yet set to the ref our HEAD points to.
777 # Happens on first commit.
778 master = git.refs.Head.create(
779 repo,
780 repo.head.ref,
781 new_commit,
782 logmsg="commit (initial): %s" % message,
783 )
784 repo.head.set_reference(master, logmsg="commit: Switching to %s" % master)
785 # END handle empty repositories
786 # END advance head handling
788 return new_commit
790 # { Serializable Implementation
792 def _serialize(self, stream: BytesIO) -> "Commit":
793 # An identity is written as "name <email> date" on a single header line, so a
794 # line feed or an angle bracket inside a name or email moves those boundaries:
795 # it can add header lines, end the headers early, or present another email.
796 # Git drops these three characters when it writes an identity; refuse them
797 # here before anything is written.
798 for actor in (self.author, self.committer):
799 for value in (actor.name, actor.email):
800 if value and any(char in value for char in "<>\n"):
801 raise ValueError("Commit identity %r must not contain '<', '>' or a line feed" % value)
803 write = stream.write
804 write(("tree %s\n" % self.tree).encode("ascii"))
805 for p in self.parents:
806 write(("parent %s\n" % p).encode("ascii"))
808 a = self.author
809 aname = a.name
810 c = self.committer
811 fmt = "%s %s <%s> %s %s\n"
812 write(
813 (
814 fmt
815 % (
816 "author",
817 aname,
818 a.email,
819 self.authored_date,
820 altz_to_utctz_str(self.author_tz_offset),
821 )
822 ).encode(self.encoding)
823 )
825 # Encode committer.
826 aname = c.name
827 write(
828 (
829 fmt
830 % (
831 "committer",
832 aname,
833 c.email,
834 self.committed_date,
835 altz_to_utctz_str(self.committer_tz_offset),
836 )
837 ).encode(self.encoding)
838 )
840 if self.encoding != self.default_encoding:
841 write(("encoding %s\n" % self.encoding).encode("ascii"))
843 try:
844 if self.__getattribute__("gpgsig"):
845 write(b"gpgsig")
846 for sigline in self.gpgsig.rstrip("\n").split("\n"):
847 write((" " + sigline + "\n").encode("ascii"))
848 except AttributeError:
849 pass
851 write(b"\n")
853 # Write plain bytes, be sure its encoded according to our encoding.
854 if isinstance(self.message, str):
855 write(self.message.encode(self.encoding))
856 else:
857 write(self.message)
858 # END handle encoding
859 return self
861 def _deserialize(self, stream: BytesIO) -> "Commit":
862 readline = stream.readline
863 self.tree = Tree(self.repo, hex_to_bin(readline().split()[1]), Tree.tree_id << 12, "")
865 self.parents = []
866 next_line = None
867 while True:
868 parent_line = readline()
869 if not parent_line.startswith(b"parent"):
870 next_line = parent_line
871 break
872 # END abort reading parents
873 self.parents.append(type(self)(self.repo, hex_to_bin(parent_line.split()[-1].decode("ascii"))))
874 # END for each parent line
875 self.parents = tuple(self.parents)
877 # We don't know actual author encoding before we have parsed it, so keep the
878 # lines around.
879 author_line = next_line
880 committer_line = readline()
882 # We might run into one or more mergetag blocks, skip those for now.
883 next_line = readline()
884 while next_line.startswith(b"mergetag "):
885 next_line = readline()
886 while next_line.startswith(b" "):
887 next_line = readline()
888 # END skip mergetags
890 # Now we can have the encoding line, or an empty line followed by the optional
891 # message.
892 self.encoding = self.default_encoding
893 self.gpgsig = ""
895 # Read headers.
896 enc = next_line
897 buf = enc.strip()
898 while buf:
899 if buf[0:9] == b"encoding ":
900 raw_encoding = buf[9:]
901 try:
902 # Codec names are ASCII, independent of the content encoding.
903 encoding = raw_encoding.decode("ascii")
904 # Probe one byte: decoding b"" skips the codec lookup entirely.
905 b" ".decode(encoding, "replace")
906 except (LookupError, ValueError):
907 # Reject malformed names and non-text codecs without changing
908 # the current encoding (the default unless a valid header was read).
909 _logger.error("Ignoring invalid commit encoding %r", raw_encoding)
910 else:
911 self.encoding = encoding
912 elif buf[0:7] == b"gpgsig ":
913 sig_lines = [buf[buf.find(b" ") + 1 :] + b"\n"]
914 is_next_header = False
915 while True:
916 sigbuf = readline()
917 if not sigbuf:
918 break
919 if sigbuf[0:1] != b" ":
920 buf = sigbuf.strip()
921 is_next_header = True
922 break
923 sig_lines.append(sigbuf[1:])
924 # END read all signature
925 self.gpgsig = b"".join(sig_lines).rstrip(b"\n").decode(self.encoding, "ignore")
926 if is_next_header:
927 continue
928 buf = readline().strip()
930 # Decode the author's name.
931 try:
932 (
933 self.author,
934 self.authored_date,
935 self.author_tz_offset,
936 ) = parse_actor_and_date(author_line.decode(self.encoding, "replace"))
937 except UnicodeDecodeError:
938 _logger.error(
939 "Failed to decode author line '%s' using encoding %s",
940 author_line,
941 self.encoding,
942 exc_info=True,
943 )
945 try:
946 (
947 self.committer,
948 self.committed_date,
949 self.committer_tz_offset,
950 ) = parse_actor_and_date(committer_line.decode(self.encoding, "replace"))
951 except UnicodeDecodeError:
952 _logger.error(
953 "Failed to decode committer line '%s' using encoding %s",
954 committer_line,
955 self.encoding,
956 exc_info=True,
957 )
958 # END handle author's encoding
960 # A stream from our data simply gives us the plain message.
961 # The end of our message stream is marked with a newline that we strip.
962 self.message = stream.read()
963 try:
964 self.message = self.message.decode(self.encoding, "replace")
965 except UnicodeDecodeError:
966 _logger.error(
967 "Failed to decode message '%s' using encoding %s",
968 self.message,
969 self.encoding,
970 exc_info=True,
971 )
972 # END exception handling
974 return self
976 # } END serializable implementation
978 @property
979 def co_authors(self) -> List[Actor]:
980 """Search the commit message for any co-authors of this commit.
982 Details on co-authors:
983 https://github.blog/2018-01-29-commit-together-with-co-authors/
985 :return:
986 List of co-authors for this commit (as :class:`~git.util.Actor` objects).
987 """
988 co_authors = []
990 if self.message:
991 # Scan line by line instead of matching `(.*) <(.*?)>` across the whole
992 # message. On a single trailer line that repeats " <" without ever closing
993 # a ">", greedy backtracking over each " <" made the regex run in O(n^2)
994 # time, so a large (fully attacker-controlled) commit message could stall
995 # any caller of this property. A trailer is "Co-authored-by: <name> <email>"
996 # with the email in the final angle brackets, so the name ends at the last
997 # " <" and the line ends at ">".
998 prefix = "Co-authored-by: "
999 for line in str(self.message).split("\n"):
1000 if not line.startswith(prefix) or not line.endswith(">"):
1001 continue
1002 identity = line[len(prefix) :]
1003 separator = identity.rfind(" <")
1004 if separator == -1:
1005 continue
1006 co_authors.append(Actor(identity[:separator], identity[separator + 2 : -1]))
1008 return co_authors