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

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

336 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 :class:`~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 

381 def process_lines(lines: List[str]) -> str: 

382 text = "" 

383 for file_info, line in zip(lines, lines[len(lines) // 2 :]): 

384 change_type = file_info.split("\t")[0][-1] 

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

386 text += "%s\t%s\t%s\t%s\n" % (change_type, insertions, deletions, filename) 

387 return text 

388 

389 if not self.parents: 

390 lines = self.repo.git.diff_tree( 

391 self.hexsha, "--", numstat=True, no_renames=True, root=True, raw=True 

392 ).splitlines()[1:] 

393 text = process_lines(lines) 

394 else: 

395 lines = self.repo.git.diff( 

396 self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True, raw=True 

397 ).splitlines() 

398 text = process_lines(lines) 

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

400 

401 @property 

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

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

404 

405 :note: 

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

407 :attr:`trailers_dict`. 

408 

409 :return: 

410 Dictionary containing whitespace stripped trailer information. 

411 Only contains the latest instance of each trailer key. 

412 """ 

413 warnings.warn( 

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

415 DeprecationWarning, 

416 stacklevel=2, 

417 ) 

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

419 

420 @property 

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

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

423 

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

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

426 

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

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

429 

430 Valid message with trailer:: 

431 

432 Subject line 

433 

434 some body information 

435 

436 another information 

437 

438 key1: value1.1 

439 key1: value1.2 

440 key2 : value 2 with inner spaces 

441 

442 Returned list will look like this:: 

443 

444 [ 

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

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

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

448 ] 

449 

450 :return: 

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

452 """ 

453 trailer = self._interpret_trailers(self.repo, self.message, ["--parse"], encoding=self.encoding).strip() 

454 

455 if not trailer: 

456 return [] 

457 

458 trailer_list = [] 

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

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

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

462 

463 return trailer_list 

464 

465 @classmethod 

466 def _interpret_trailers( 

467 cls, 

468 repo: "Repo", 

469 message: Union[str, bytes], 

470 trailer_args: Sequence[str], 

471 encoding: str = default_encoding, 

472 ) -> str: 

473 message_bytes = message if isinstance(message, bytes) else message.encode(encoding, errors="strict") 

474 cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args] 

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

476 cmd, 

477 as_process=True, 

478 istream=PIPE, 

479 ) 

480 try: 

481 stdout_bytes, _ = proc.communicate(message_bytes) 

482 return stdout_bytes.decode(encoding, errors="strict") 

483 finally: 

484 finalize_process(proc) 

485 

486 @property 

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

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

489 

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

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

492 

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

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

495 trailing whitespaces before they get saved into a dictionary. 

496 

497 Valid message with trailer:: 

498 

499 Subject line 

500 

501 some body information 

502 

503 another information 

504 

505 key1: value1.1 

506 key1: value1.2 

507 key2 : value 2 with inner spaces 

508 

509 Returned dictionary will look like this:: 

510 

511 { 

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

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

514 } 

515 

516 

517 :return: 

518 Dictionary containing whitespace stripped trailer information, mapping 

519 trailer keys to a list of their corresponding values. 

520 """ 

521 d = defaultdict(list) 

522 for key, val in self.trailers_list: 

523 d[key].append(val) 

524 return dict(d) 

525 

526 @classmethod 

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

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

529 

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

531 from our lighting fast object database. 

532 

533 :param proc: 

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

535 

536 :return: 

537 Iterator supplying :class:`Commit` objects 

538 """ 

539 

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

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

542 

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

544 # return hasattr(proc_or_stream, 'readline') 

545 

546 if hasattr(proc_or_stream, "wait"): 

547 proc_or_stream = cast(Popen, proc_or_stream) 

548 if proc_or_stream.stdout is not None: 

549 stream = proc_or_stream.stdout 

550 elif hasattr(proc_or_stream, "readline"): 

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

552 stream = proc_or_stream 

553 

554 readline = stream.readline 

555 while True: 

556 line = readline() 

557 if not line: 

558 break 

559 hexsha = line.strip() 

560 if len(hexsha) > 40: 

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

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

563 # END handle extra info 

564 

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

566 yield cls(repo, hex_to_bin(hexsha)) 

567 # END for each line in stream 

568 

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

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

571 if hasattr(proc_or_stream, "wait"): 

572 proc_or_stream = cast(Popen, proc_or_stream) 

573 finalize_process(proc_or_stream) 

574 

575 @classmethod 

576 def create_from_tree( 

577 cls, 

578 repo: "Repo", 

579 tree: Union[Tree, str], 

580 message: str, 

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

582 head: bool = False, 

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

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

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

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

587 trailers: Union[None, Dict[str, str], List[Tuple[str, str]]] = None, 

588 ) -> "Commit": 

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

590 

591 :param repo: 

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

593 

594 :param tree: 

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

596 The tree of the new commit. 

597 

598 :param message: 

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

600 be converted to a string, in any case. 

601 

602 :param parent_commits: 

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

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

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

606 object. 

607 

608 :param head: 

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

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

611 lead to undesired results when diffing files. 

612 

613 :param author: 

614 The name of the author, optional. 

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

616 

617 :param committer: 

618 The name of the committer, optional. 

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

620 

621 :param author_date: 

622 The timestamp for the author field. 

623 

624 :param commit_date: 

625 The timestamp for the committer field. 

626 

627 :param trailers: 

628 Optional trailer key-value pairs to append to the commit message. 

629 Can be a dictionary mapping trailer keys to values, or a list of 

630 ``(key, value)`` tuples (useful when the same key appears multiple 

631 times, e.g. multiple ``Signed-off-by`` trailers). Trailers are 

632 appended using ``git interpret-trailers``. 

633 See :manpage:`git-interpret-trailers(1)`. 

634 

635 :return: 

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

637 

638 :note: 

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

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

641 for more information. 

642 """ 

643 if parent_commits is None: 

644 try: 

645 parent_commits = [repo.head.commit] 

646 except ValueError: 

647 # Empty repositories have no head commit. 

648 parent_commits = [] 

649 # END handle parent commits 

650 else: 

651 for p in parent_commits: 

652 if not isinstance(p, cls): 

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

654 # END check parent commit types 

655 # END if parent commits are unset 

656 

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

658 # Generally: 

659 # * Environment variables override configuration values. 

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

661 

662 # COMMITTER AND AUTHOR INFO 

663 cr = repo.config_reader() 

664 env = os.environ 

665 

666 committer = committer or Actor.committer(cr) 

667 author = author or Actor.author(cr) 

668 

669 # PARSE THE DATES 

670 unix_time = int(time()) 

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

672 offset = altzone if is_dst else timezone 

673 

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

675 if author_date: 

676 author_time, author_offset = parse_date(author_date) 

677 elif author_date_str: 

678 author_time, author_offset = parse_date(author_date_str) 

679 else: 

680 author_time, author_offset = unix_time, offset 

681 # END set author time 

682 

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

684 if commit_date: 

685 committer_time, committer_offset = parse_date(commit_date) 

686 elif committer_date_str: 

687 committer_time, committer_offset = parse_date(committer_date_str) 

688 else: 

689 committer_time, committer_offset = unix_time, offset 

690 # END set committer time 

691 

692 # Assume UTF-8 encoding. 

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

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

695 if not isinstance(conf_encoding, str): 

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

697 

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

699 # commit object is invalid. 

700 if isinstance(tree, str): 

701 tree = repo.tree(tree) 

702 # END tree conversion 

703 

704 # APPLY TRAILERS 

705 if trailers: 

706 trailer_args: List[str] = [] 

707 if isinstance(trailers, dict): 

708 for key, val in trailers.items(): 

709 trailer_args.append("--trailer") 

710 trailer_args.append(f"{key}: {val}") 

711 else: 

712 for key, val in trailers: 

713 trailer_args.append("--trailer") 

714 trailer_args.append(f"{key}: {val}") 

715 

716 message = cls._interpret_trailers(repo, str(message), trailer_args) 

717 # END apply trailers 

718 

719 # CREATE NEW COMMIT 

720 new_commit = cls( 

721 repo, 

722 cls.NULL_BIN_SHA, 

723 tree, 

724 author, 

725 author_time, 

726 author_offset, 

727 committer, 

728 committer_time, 

729 committer_offset, 

730 message, 

731 parent_commits, 

732 conf_encoding, 

733 ) 

734 

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

736 

737 if head: 

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

739 # well... 

740 import git.refs 

741 

742 try: 

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

744 except ValueError: 

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

746 # Happens on first commit. 

747 master = git.refs.Head.create( 

748 repo, 

749 repo.head.ref, 

750 new_commit, 

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

752 ) 

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

754 # END handle empty repositories 

755 # END advance head handling 

756 

757 return new_commit 

758 

759 # { Serializable Implementation 

760 

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

762 write = stream.write 

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

764 for p in self.parents: 

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

766 

767 a = self.author 

768 aname = a.name 

769 c = self.committer 

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

771 write( 

772 ( 

773 fmt 

774 % ( 

775 "author", 

776 aname, 

777 a.email, 

778 self.authored_date, 

779 altz_to_utctz_str(self.author_tz_offset), 

780 ) 

781 ).encode(self.encoding) 

782 ) 

783 

784 # Encode committer. 

785 aname = c.name 

786 write( 

787 ( 

788 fmt 

789 % ( 

790 "committer", 

791 aname, 

792 c.email, 

793 self.committed_date, 

794 altz_to_utctz_str(self.committer_tz_offset), 

795 ) 

796 ).encode(self.encoding) 

797 ) 

798 

799 if self.encoding != self.default_encoding: 

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

801 

802 try: 

803 if self.__getattribute__("gpgsig"): 

804 write(b"gpgsig") 

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

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

807 except AttributeError: 

808 pass 

809 

810 write(b"\n") 

811 

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

813 if isinstance(self.message, str): 

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

815 else: 

816 write(self.message) 

817 # END handle encoding 

818 return self 

819 

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

821 readline = stream.readline 

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

823 

824 self.parents = [] 

825 next_line = None 

826 while True: 

827 parent_line = readline() 

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

829 next_line = parent_line 

830 break 

831 # END abort reading parents 

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

833 # END for each parent line 

834 self.parents = tuple(self.parents) 

835 

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

837 # lines around. 

838 author_line = next_line 

839 committer_line = readline() 

840 

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

842 next_line = readline() 

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

844 next_line = readline() 

845 while next_line.startswith(b" "): 

846 next_line = readline() 

847 # END skip mergetags 

848 

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

850 # message. 

851 self.encoding = self.default_encoding 

852 self.gpgsig = "" 

853 

854 # Read headers. 

855 enc = next_line 

856 buf = enc.strip() 

857 while buf: 

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

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

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

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

862 is_next_header = False 

863 while True: 

864 sigbuf = readline() 

865 if not sigbuf: 

866 break 

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

868 buf = sigbuf.strip() 

869 is_next_header = True 

870 break 

871 sig += sigbuf[1:] 

872 # END read all signature 

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

874 if is_next_header: 

875 continue 

876 buf = readline().strip() 

877 

878 # Decode the author's name. 

879 try: 

880 ( 

881 self.author, 

882 self.authored_date, 

883 self.author_tz_offset, 

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

885 except UnicodeDecodeError: 

886 _logger.error( 

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

888 author_line, 

889 self.encoding, 

890 exc_info=True, 

891 ) 

892 

893 try: 

894 ( 

895 self.committer, 

896 self.committed_date, 

897 self.committer_tz_offset, 

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

899 except UnicodeDecodeError: 

900 _logger.error( 

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

902 committer_line, 

903 self.encoding, 

904 exc_info=True, 

905 ) 

906 # END handle author's encoding 

907 

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

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

910 self.message = stream.read() 

911 try: 

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

913 except UnicodeDecodeError: 

914 _logger.error( 

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

916 self.message, 

917 self.encoding, 

918 exc_info=True, 

919 ) 

920 # END exception handling 

921 

922 return self 

923 

924 # } END serializable implementation 

925 

926 @property 

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

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

929 

930 Details on co-authors: 

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

932 

933 :return: 

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

935 """ 

936 co_authors = [] 

937 

938 if self.message: 

939 results = re.findall( 

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

941 str(self.message), 

942 re.MULTILINE, 

943 ) 

944 for author in results: 

945 co_authors.append(Actor(*author)) 

946 

947 return co_authors