Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/git/objects/commit.py: 58%

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

312 statements  

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/ 

5 

6__all__ = ["Commit"] 

7 

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 

18 

19from gitdb import IStream 

20 

21from git.cmd import Git 

22from git.diff import Diffable 

23from git.util import Actor, Stats, finalize_process, hex_to_bin 

24 

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) 

35 

36# typing ------------------------------------------------------------------ 

37 

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) 

50 

51if sys.version_info >= (3, 8): 

52 from typing import Literal 

53else: 

54 from typing_extensions import Literal 

55 

56from git.types import PathLike 

57 

58if TYPE_CHECKING: 

59 from git.refs import SymbolicReference 

60 from git.repo import Repo 

61 

62# ------------------------------------------------------------------------ 

63 

64_logger = logging.getLogger(__name__) 

65 

66 

67class Commit(base.Object, TraversableIterableObj, Diffable, Serializable): 

68 """Wraps a git commit object. 

69 

70 See :manpage:`gitglossary(7)` on "commit object": 

71 https://git-scm.com/docs/gitglossary#def_commit_object 

72 

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 """ 

77 

78 # ENVIRONMENT VARIABLES 

79 # Read when creating new commits. 

80 env_author_date = "GIT_AUTHOR_DATE" 

81 env_committer_date = "GIT_COMMITTER_DATE" 

82 

83 # CONFIGURATION KEYS 

84 conf_encoding = "i18n.commitencoding" 

85 

86 # INVARIANTS 

87 default_encoding = "UTF-8" 

88 

89 type: Literal["commit"] = "commit" 

90 

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 ) 

104 

105 _id_attribute_ = "hexsha" 

106 

107 parents: Sequence["Commit"] 

108 

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. 

127 

128 :param binsha: 

129 20 byte sha1. 

130 

131 :param tree: 

132 A :class:`~git.objects.tree.Tree` object. 

133 

134 :param author: 

135 The author :class:`~git.util.Actor` object. 

136 

137 :param authored_date: int_seconds_since_epoch 

138 The authored DateTime - use :func:`time.gmtime` to convert it into a 

139 different format. 

140 

141 :param author_tz_offset: int_seconds_west_of_utc 

142 The timezone that the `authored_date` is in. 

143 

144 :param committer: 

145 The committer string, as an :class:`~git.util.Actor` object. 

146 

147 :param committed_date: int_seconds_since_epoch 

148 The committed DateTime - use :func:`time.gmtime` to convert it into a 

149 different format. 

150 

151 :param committer_tz_offset: int_seconds_west_of_utc 

152 The timezone that the `committed_date` is in. 

153 

154 :param message: string 

155 The commit message. 

156 

157 :param encoding: string 

158 Encoding of the message, defaults to UTF-8. 

159 

160 :param parents: 

161 List or tuple of :class:`Commit` objects which are our parent(s) in the 

162 commit dependency graph. 

163 

164 :return: 

165 :class:`Commit` 

166 

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 

198 

199 @classmethod 

200 def _get_intermediate_items(cls, commit: "Commit") -> Tuple["Commit", ...]: 

201 return tuple(commit.parents) 

202 

203 @classmethod 

204 def _calculate_sha_(cls, repo: "Repo", commit: "Commit") -> bytes: 

205 """Calculate the sha of a commit. 

206 

207 :param repo: 

208 :class:`~git.repo.base.Repo` object the commit should be part of. 

209 

210 :param commit: 

211 :class:`Commit` object for which to generate the sha. 

212 """ 

213 

214 stream = BytesIO() 

215 commit._serialize(stream) 

216 streamlen = stream.tell() 

217 stream.seek(0) 

218 

219 istream = repo.odb.store(IStream(cls.type, streamlen, stream)) 

220 return istream.binsha 

221 

222 def replace(self, **kwargs: Any) -> "Commit": 

223 """Create new commit object from an existing commit object. 

224 

225 Any values provided as keyword arguments will replace the corresponding 

226 attribute in the new object. 

227 """ 

228 

229 attrs = {k: getattr(self, k) for k in self.__slots__} 

230 

231 for attrname in kwargs: 

232 if attrname not in self.__slots__: 

233 raise ValueError("invalid attribute name") 

234 

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) 

238 

239 return new_commit 

240 

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 

249 

250 @property 

251 def authored_datetime(self) -> datetime.datetime: 

252 return from_timestamp(self.authored_date, self.author_tz_offset) 

253 

254 @property 

255 def committed_datetime(self) -> datetime.datetime: 

256 return from_timestamp(self.committed_date, self.committer_tz_offset) 

257 

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] 

265 

266 def count(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> int: 

267 """Count the number of commits reachable from this commit. 

268 

269 :param paths: 

270 An optional path or a list of paths restricting the return value to commits 

271 actually containing the paths. 

272 

273 :param kwargs: 

274 Additional options to be passed to :manpage:`git-rev-list(1)`. They must not 

275 alter the output style of the command, or parsing will yield incorrect 

276 results. 

277 

278 :return: 

279 An int defining the number of reachable commits 

280 """ 

281 # Yes, it makes a difference whether empty paths are given or not in our case as 

282 # the empty paths version will ignore merge commits for some reason. 

283 if paths: 

284 return len(self.repo.git.rev_list(self.hexsha, "--", paths, **kwargs).splitlines()) 

285 return len(self.repo.git.rev_list(self.hexsha, **kwargs).splitlines()) 

286 

287 @property 

288 def name_rev(self) -> str: 

289 """ 

290 :return: 

291 String describing the commits hex sha based on the closest 

292 `~git.refs.reference.Reference`. 

293 

294 :note: 

295 Mostly useful for UI purposes. 

296 """ 

297 return self.repo.git.name_rev(self) 

298 

299 @classmethod 

300 def iter_items( 

301 cls, 

302 repo: "Repo", 

303 rev: Union[str, "Commit", "SymbolicReference"], 

304 paths: Union[PathLike, Sequence[PathLike]] = "", 

305 **kwargs: Any, 

306 ) -> Iterator["Commit"]: 

307 R"""Find all commits matching the given criteria. 

308 

309 :param repo: 

310 The :class:`~git.repo.base.Repo`. 

311 

312 :param rev: 

313 Revision specifier. See :manpage:`git-rev-parse(1)` for viable options. 

314 

315 :param paths: 

316 An optional path or list of paths. If set only :class:`Commit`\s that 

317 include the path or paths will be considered. 

318 

319 :param kwargs: 

320 Optional keyword arguments to :manpage:`git-rev-list(1)` where: 

321 

322 * ``max_count`` is the maximum number of commits to fetch. 

323 * ``skip`` is the number of commits to skip. 

324 * ``since`` selects all commits since some date, e.g. ``"1970-01-01"``. 

325 

326 :return: 

327 Iterator yielding :class:`Commit` items. 

328 """ 

329 if "pretty" in kwargs: 

330 raise ValueError("--pretty cannot be used as parsing expects single sha's only") 

331 # END handle pretty 

332 

333 # Use -- in all cases, to prevent possibility of ambiguous arguments. 

334 # See https://github.com/gitpython-developers/GitPython/issues/264. 

335 

336 args_list: List[PathLike] = ["--"] 

337 

338 if paths: 

339 paths_tup: Tuple[PathLike, ...] 

340 if isinstance(paths, (str, os.PathLike)): 

341 paths_tup = (paths,) 

342 else: 

343 paths_tup = tuple(paths) 

344 

345 args_list.extend(paths_tup) 

346 # END if paths 

347 

348 proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs) 

349 return cls._iter_from_process_or_stream(repo, proc) 

350 

351 def iter_parents(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> Iterator["Commit"]: 

352 R"""Iterate _all_ parents of this commit. 

353 

354 :param paths: 

355 Optional path or list of paths limiting the :class:`Commit`\s to those that 

356 contain at least one of the paths. 

357 

358 :param kwargs: 

359 All arguments allowed by :manpage:`git-rev-list(1)`. 

360 

361 :return: 

362 Iterator yielding :class:`Commit` objects which are parents of ``self`` 

363 """ 

364 # skip ourselves 

365 skip = kwargs.get("skip", 1) 

366 if skip == 0: # skip ourselves 

367 skip = 1 

368 kwargs["skip"] = skip 

369 

370 return self.iter_items(self.repo, self, paths, **kwargs) 

371 

372 @property 

373 def stats(self) -> Stats: 

374 """Create a git stat from changes between this commit and its first parent 

375 or from all changes done if this is the very first commit. 

376 

377 :return: 

378 :class:`Stats` 

379 """ 

380 if not self.parents: 

381 text = self.repo.git.diff_tree(self.hexsha, "--", numstat=True, no_renames=True, root=True) 

382 text2 = "" 

383 for line in text.splitlines()[1:]: 

384 (insertions, deletions, filename) = line.split("\t") 

385 text2 += "%s\t%s\t%s\n" % (insertions, deletions, filename) 

386 text = text2 

387 else: 

388 text = self.repo.git.diff(self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True) 

389 return Stats._list_from_string(self.repo, text) 

390 

391 @property 

392 def trailers(self) -> Dict[str, str]: 

393 """Deprecated. Get the trailers of the message as a dictionary. 

394 

395 :note: 

396 This property is deprecated, please use either :attr:`trailers_list` or 

397 :attr:`trailers_dict`. 

398 

399 :return: 

400 Dictionary containing whitespace stripped trailer information. 

401 Only contains the latest instance of each trailer key. 

402 """ 

403 warnings.warn( 

404 "Commit.trailers is deprecated, use Commit.trailers_list or Commit.trailers_dict instead", 

405 DeprecationWarning, 

406 stacklevel=2, 

407 ) 

408 return {k: v[0] for k, v in self.trailers_dict.items()} 

409 

410 @property 

411 def trailers_list(self) -> List[Tuple[str, str]]: 

412 """Get the trailers of the message as a list. 

413 

414 Git messages can contain trailer information that are similar to :rfc:`822` 

415 e-mail headers. See :manpage:`git-interpret-trailers(1)`. 

416 

417 This function calls ``git interpret-trailers --parse`` onto the message to 

418 extract the trailer information, returns the raw trailer data as a list. 

419 

420 Valid message with trailer:: 

421 

422 Subject line 

423 

424 some body information 

425 

426 another information 

427 

428 key1: value1.1 

429 key1: value1.2 

430 key2 : value 2 with inner spaces 

431 

432 Returned list will look like this:: 

433 

434 [ 

435 ("key1", "value1.1"), 

436 ("key1", "value1.2"), 

437 ("key2", "value 2 with inner spaces"), 

438 ] 

439 

440 :return: 

441 List containing key-value tuples of whitespace stripped trailer information. 

442 """ 

443 cmd = ["git", "interpret-trailers", "--parse"] 

444 proc: Git.AutoInterrupt = self.repo.git.execute( # type: ignore[call-overload] 

445 cmd, 

446 as_process=True, 

447 istream=PIPE, 

448 ) 

449 trailer: str = proc.communicate(str(self.message).encode())[0].decode("utf8") 

450 trailer = trailer.strip() 

451 

452 if not trailer: 

453 return [] 

454 

455 trailer_list = [] 

456 for t in trailer.split("\n"): 

457 key, val = t.split(":", 1) 

458 trailer_list.append((key.strip(), val.strip())) 

459 

460 return trailer_list 

461 

462 @property 

463 def trailers_dict(self) -> Dict[str, List[str]]: 

464 """Get the trailers of the message as a dictionary. 

465 

466 Git messages can contain trailer information that are similar to :rfc:`822` 

467 e-mail headers. See :manpage:`git-interpret-trailers(1)`. 

468 

469 This function calls ``git interpret-trailers --parse`` onto the message to 

470 extract the trailer information. The key value pairs are stripped of leading and 

471 trailing whitespaces before they get saved into a dictionary. 

472 

473 Valid message with trailer:: 

474 

475 Subject line 

476 

477 some body information 

478 

479 another information 

480 

481 key1: value1.1 

482 key1: value1.2 

483 key2 : value 2 with inner spaces 

484 

485 Returned dictionary will look like this:: 

486 

487 { 

488 "key1": ["value1.1", "value1.2"], 

489 "key2": ["value 2 with inner spaces"], 

490 } 

491 

492 

493 :return: 

494 Dictionary containing whitespace stripped trailer information, mapping 

495 trailer keys to a list of their corresponding values. 

496 """ 

497 d = defaultdict(list) 

498 for key, val in self.trailers_list: 

499 d[key].append(val) 

500 return dict(d) 

501 

502 @classmethod 

503 def _iter_from_process_or_stream(cls, repo: "Repo", proc_or_stream: Union[Popen, IO]) -> Iterator["Commit"]: 

504 """Parse out commit information into a list of :class:`Commit` objects. 

505 

506 We expect one line per commit, and parse the actual commit information directly 

507 from our lighting fast object database. 

508 

509 :param proc: 

510 :manpage:`git-rev-list(1)` process instance - one sha per line. 

511 

512 :return: 

513 Iterator supplying :class:`Commit` objects 

514 """ 

515 

516 # def is_proc(inp) -> TypeGuard[Popen]: 

517 # return hasattr(proc_or_stream, 'wait') and not hasattr(proc_or_stream, 'readline') 

518 

519 # def is_stream(inp) -> TypeGuard[IO]: 

520 # return hasattr(proc_or_stream, 'readline') 

521 

522 if hasattr(proc_or_stream, "wait"): 

523 proc_or_stream = cast(Popen, proc_or_stream) 

524 if proc_or_stream.stdout is not None: 

525 stream = proc_or_stream.stdout 

526 elif hasattr(proc_or_stream, "readline"): 

527 proc_or_stream = cast(IO, proc_or_stream) # type: ignore[redundant-cast] 

528 stream = proc_or_stream 

529 

530 readline = stream.readline 

531 while True: 

532 line = readline() 

533 if not line: 

534 break 

535 hexsha = line.strip() 

536 if len(hexsha) > 40: 

537 # Split additional information, as returned by bisect for instance. 

538 hexsha, _ = line.split(None, 1) 

539 # END handle extra info 

540 

541 assert len(hexsha) == 40, "Invalid line: %s" % hexsha 

542 yield cls(repo, hex_to_bin(hexsha)) 

543 # END for each line in stream 

544 

545 # TODO: Review this - it seems process handling got a bit out of control due to 

546 # many developers trying to fix the open file handles issue. 

547 if hasattr(proc_or_stream, "wait"): 

548 proc_or_stream = cast(Popen, proc_or_stream) 

549 finalize_process(proc_or_stream) 

550 

551 @classmethod 

552 def create_from_tree( 

553 cls, 

554 repo: "Repo", 

555 tree: Union[Tree, str], 

556 message: str, 

557 parent_commits: Union[None, List["Commit"]] = None, 

558 head: bool = False, 

559 author: Union[None, Actor] = None, 

560 committer: Union[None, Actor] = None, 

561 author_date: Union[None, str, datetime.datetime] = None, 

562 commit_date: Union[None, str, datetime.datetime] = None, 

563 ) -> "Commit": 

564 """Commit the given tree, creating a :class:`Commit` object. 

565 

566 :param repo: 

567 :class:`~git.repo.base.Repo` object the commit should be part of. 

568 

569 :param tree: 

570 :class:`~git.objects.tree.Tree` object or hex or bin sha. 

571 The tree of the new commit. 

572 

573 :param message: 

574 Commit message. It may be an empty string if no message is provided. It will 

575 be converted to a string, in any case. 

576 

577 :param parent_commits: 

578 Optional :class:`Commit` objects to use as parents for the new commit. If 

579 empty list, the commit will have no parents at all and become a root commit. 

580 If ``None``, the current head commit will be the parent of the new commit 

581 object. 

582 

583 :param head: 

584 If ``True``, the HEAD will be advanced to the new commit automatically. 

585 Otherwise the HEAD will remain pointing on the previous commit. This could 

586 lead to undesired results when diffing files. 

587 

588 :param author: 

589 The name of the author, optional. 

590 If unset, the repository configuration is used to obtain this value. 

591 

592 :param committer: 

593 The name of the committer, optional. 

594 If unset, the repository configuration is used to obtain this value. 

595 

596 :param author_date: 

597 The timestamp for the author field. 

598 

599 :param commit_date: 

600 The timestamp for the committer field. 

601 

602 :return: 

603 :class:`Commit` object representing the new commit. 

604 

605 :note: 

606 Additional information about the committer and author are taken from the 

607 environment or from the git configuration. See :manpage:`git-commit-tree(1)` 

608 for more information. 

609 """ 

610 if parent_commits is None: 

611 try: 

612 parent_commits = [repo.head.commit] 

613 except ValueError: 

614 # Empty repositories have no head commit. 

615 parent_commits = [] 

616 # END handle parent commits 

617 else: 

618 for p in parent_commits: 

619 if not isinstance(p, cls): 

620 raise ValueError(f"Parent commit '{p!r}' must be of type {cls}") 

621 # END check parent commit types 

622 # END if parent commits are unset 

623 

624 # Retrieve all additional information, create a commit object, and serialize it. 

625 # Generally: 

626 # * Environment variables override configuration values. 

627 # * Sensible defaults are set according to the git documentation. 

628 

629 # COMMITTER AND AUTHOR INFO 

630 cr = repo.config_reader() 

631 env = os.environ 

632 

633 committer = committer or Actor.committer(cr) 

634 author = author or Actor.author(cr) 

635 

636 # PARSE THE DATES 

637 unix_time = int(time()) 

638 is_dst = daylight and localtime().tm_isdst > 0 

639 offset = altzone if is_dst else timezone 

640 

641 author_date_str = env.get(cls.env_author_date, "") 

642 if author_date: 

643 author_time, author_offset = parse_date(author_date) 

644 elif author_date_str: 

645 author_time, author_offset = parse_date(author_date_str) 

646 else: 

647 author_time, author_offset = unix_time, offset 

648 # END set author time 

649 

650 committer_date_str = env.get(cls.env_committer_date, "") 

651 if commit_date: 

652 committer_time, committer_offset = parse_date(commit_date) 

653 elif committer_date_str: 

654 committer_time, committer_offset = parse_date(committer_date_str) 

655 else: 

656 committer_time, committer_offset = unix_time, offset 

657 # END set committer time 

658 

659 # Assume UTF-8 encoding. 

660 enc_section, enc_option = cls.conf_encoding.split(".") 

661 conf_encoding = cr.get_value(enc_section, enc_option, cls.default_encoding) 

662 if not isinstance(conf_encoding, str): 

663 raise TypeError("conf_encoding could not be coerced to str") 

664 

665 # If the tree is no object, make sure we create one - otherwise the created 

666 # commit object is invalid. 

667 if isinstance(tree, str): 

668 tree = repo.tree(tree) 

669 # END tree conversion 

670 

671 # CREATE NEW COMMIT 

672 new_commit = cls( 

673 repo, 

674 cls.NULL_BIN_SHA, 

675 tree, 

676 author, 

677 author_time, 

678 author_offset, 

679 committer, 

680 committer_time, 

681 committer_offset, 

682 message, 

683 parent_commits, 

684 conf_encoding, 

685 ) 

686 

687 new_commit.binsha = cls._calculate_sha_(repo, new_commit) 

688 

689 if head: 

690 # Need late import here, importing git at the very beginning throws as 

691 # well... 

692 import git.refs 

693 

694 try: 

695 repo.head.set_commit(new_commit, logmsg=message) 

696 except ValueError: 

697 # head is not yet set to the ref our HEAD points to. 

698 # Happens on first commit. 

699 master = git.refs.Head.create( 

700 repo, 

701 repo.head.ref, 

702 new_commit, 

703 logmsg="commit (initial): %s" % message, 

704 ) 

705 repo.head.set_reference(master, logmsg="commit: Switching to %s" % master) 

706 # END handle empty repositories 

707 # END advance head handling 

708 

709 return new_commit 

710 

711 # { Serializable Implementation 

712 

713 def _serialize(self, stream: BytesIO) -> "Commit": 

714 write = stream.write 

715 write(("tree %s\n" % self.tree).encode("ascii")) 

716 for p in self.parents: 

717 write(("parent %s\n" % p).encode("ascii")) 

718 

719 a = self.author 

720 aname = a.name 

721 c = self.committer 

722 fmt = "%s %s <%s> %s %s\n" 

723 write( 

724 ( 

725 fmt 

726 % ( 

727 "author", 

728 aname, 

729 a.email, 

730 self.authored_date, 

731 altz_to_utctz_str(self.author_tz_offset), 

732 ) 

733 ).encode(self.encoding) 

734 ) 

735 

736 # Encode committer. 

737 aname = c.name 

738 write( 

739 ( 

740 fmt 

741 % ( 

742 "committer", 

743 aname, 

744 c.email, 

745 self.committed_date, 

746 altz_to_utctz_str(self.committer_tz_offset), 

747 ) 

748 ).encode(self.encoding) 

749 ) 

750 

751 if self.encoding != self.default_encoding: 

752 write(("encoding %s\n" % self.encoding).encode("ascii")) 

753 

754 try: 

755 if self.__getattribute__("gpgsig"): 

756 write(b"gpgsig") 

757 for sigline in self.gpgsig.rstrip("\n").split("\n"): 

758 write((" " + sigline + "\n").encode("ascii")) 

759 except AttributeError: 

760 pass 

761 

762 write(b"\n") 

763 

764 # Write plain bytes, be sure its encoded according to our encoding. 

765 if isinstance(self.message, str): 

766 write(self.message.encode(self.encoding)) 

767 else: 

768 write(self.message) 

769 # END handle encoding 

770 return self 

771 

772 def _deserialize(self, stream: BytesIO) -> "Commit": 

773 readline = stream.readline 

774 self.tree = Tree(self.repo, hex_to_bin(readline().split()[1]), Tree.tree_id << 12, "") 

775 

776 self.parents = [] 

777 next_line = None 

778 while True: 

779 parent_line = readline() 

780 if not parent_line.startswith(b"parent"): 

781 next_line = parent_line 

782 break 

783 # END abort reading parents 

784 self.parents.append(type(self)(self.repo, hex_to_bin(parent_line.split()[-1].decode("ascii")))) 

785 # END for each parent line 

786 self.parents = tuple(self.parents) 

787 

788 # We don't know actual author encoding before we have parsed it, so keep the 

789 # lines around. 

790 author_line = next_line 

791 committer_line = readline() 

792 

793 # We might run into one or more mergetag blocks, skip those for now. 

794 next_line = readline() 

795 while next_line.startswith(b"mergetag "): 

796 next_line = readline() 

797 while next_line.startswith(b" "): 

798 next_line = readline() 

799 # END skip mergetags 

800 

801 # Now we can have the encoding line, or an empty line followed by the optional 

802 # message. 

803 self.encoding = self.default_encoding 

804 self.gpgsig = "" 

805 

806 # Read headers. 

807 enc = next_line 

808 buf = enc.strip() 

809 while buf: 

810 if buf[0:10] == b"encoding ": 

811 self.encoding = buf[buf.find(b" ") + 1 :].decode(self.encoding, "ignore") 

812 elif buf[0:7] == b"gpgsig ": 

813 sig = buf[buf.find(b" ") + 1 :] + b"\n" 

814 is_next_header = False 

815 while True: 

816 sigbuf = readline() 

817 if not sigbuf: 

818 break 

819 if sigbuf[0:1] != b" ": 

820 buf = sigbuf.strip() 

821 is_next_header = True 

822 break 

823 sig += sigbuf[1:] 

824 # END read all signature 

825 self.gpgsig = sig.rstrip(b"\n").decode(self.encoding, "ignore") 

826 if is_next_header: 

827 continue 

828 buf = readline().strip() 

829 

830 # Decode the author's name. 

831 try: 

832 ( 

833 self.author, 

834 self.authored_date, 

835 self.author_tz_offset, 

836 ) = parse_actor_and_date(author_line.decode(self.encoding, "replace")) 

837 except UnicodeDecodeError: 

838 _logger.error( 

839 "Failed to decode author line '%s' using encoding %s", 

840 author_line, 

841 self.encoding, 

842 exc_info=True, 

843 ) 

844 

845 try: 

846 ( 

847 self.committer, 

848 self.committed_date, 

849 self.committer_tz_offset, 

850 ) = parse_actor_and_date(committer_line.decode(self.encoding, "replace")) 

851 except UnicodeDecodeError: 

852 _logger.error( 

853 "Failed to decode committer line '%s' using encoding %s", 

854 committer_line, 

855 self.encoding, 

856 exc_info=True, 

857 ) 

858 # END handle author's encoding 

859 

860 # A stream from our data simply gives us the plain message. 

861 # The end of our message stream is marked with a newline that we strip. 

862 self.message = stream.read() 

863 try: 

864 self.message = self.message.decode(self.encoding, "replace") 

865 except UnicodeDecodeError: 

866 _logger.error( 

867 "Failed to decode message '%s' using encoding %s", 

868 self.message, 

869 self.encoding, 

870 exc_info=True, 

871 ) 

872 # END exception handling 

873 

874 return self 

875 

876 # } END serializable implementation 

877 

878 @property 

879 def co_authors(self) -> List[Actor]: 

880 """Search the commit message for any co-authors of this commit. 

881 

882 Details on co-authors: 

883 https://github.blog/2018-01-29-commit-together-with-co-authors/ 

884 

885 :return: 

886 List of co-authors for this commit (as :class:`~git.util.Actor` objects). 

887 """ 

888 co_authors = [] 

889 

890 if self.message: 

891 results = re.findall( 

892 r"^Co-authored-by: (.*) <(.*?)>$", 

893 self.message, 

894 re.MULTILINE, 

895 ) 

896 for author in results: 

897 co_authors.append(Actor(*author)) 

898 

899 return co_authors