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

354 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 

13from subprocess import Popen, PIPE 

14from time import altzone, daylight, localtime, time, timezone 

15import warnings 

16 

17from gitdb import IStream 

18 

19from git.cmd import Git 

20from git.diff import Diffable 

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

22 

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) 

33 

34# typing ------------------------------------------------------------------ 

35 

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) 

49 

50from git.types import PathLike 

51 

52if TYPE_CHECKING: 

53 from git.refs import SymbolicReference 

54 from git.repo import Repo 

55 

56# ------------------------------------------------------------------------ 

57 

58_logger = logging.getLogger(__name__) 

59 

60 

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

62 """Wraps a git commit object. 

63 

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

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

66 

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

71 

72 # ENVIRONMENT VARIABLES 

73 # Read when creating new commits. 

74 env_author_date = "GIT_AUTHOR_DATE" 

75 env_committer_date = "GIT_COMMITTER_DATE" 

76 

77 # CONFIGURATION KEYS 

78 conf_encoding = "i18n.commitencoding" 

79 

80 # INVARIANTS 

81 default_encoding = "UTF-8" 

82 

83 # Options to :manpage:`git-rev-list(1)` that can overwrite files. 

84 unsafe_git_rev_options = [ 

85 "--output", 

86 "-o", 

87 ] 

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( 

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. 

273 

274 :param paths: 

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

276 actually containing the paths. 

277 

278 :param allow_unsafe_options: 

279 Allow unsafe options, like ``--output``. 

280 

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. 

285 

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 ) 

293 

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()) 

299 

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`. 

306 

307 :note: 

308 Mostly useful for UI purposes. 

309 """ 

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

311 

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. 

322 

323 :param repo: 

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

325 

326 :param rev: 

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

328 

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. 

332 

333 :param kwargs: 

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

335 

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

339 

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 

346 

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 ) 

351 

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

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

354 

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

356 

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) 

363 

364 args_list.extend(paths_tup) 

365 # END if paths 

366 

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

368 return cls._iter_from_process_or_stream(repo, proc) 

369 

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

371 R"""Iterate *all* parents of this commit. 

372 

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. 

376 

377 :param kwargs: 

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

379 

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 

388 

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

390 

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. 

395 

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. 

400 

401 :return: 

402 :class:`Stats` 

403 """ 

404 

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 

412 

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) 

424 

425 @property 

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

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

428 

429 :note: 

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

431 :attr:`trailers_dict`. 

432 

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()} 

443 

444 @property 

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

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

447 

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

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

450 

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. 

453 

454 Valid message with trailer:: 

455 

456 Subject line 

457 

458 some body information 

459 

460 another information 

461 

462 key1: value1.1 

463 key1: value1.2 

464 key2 : value 2 with inner spaces 

465 

466 Returned list will look like this:: 

467 

468 [ 

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

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

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

472 ] 

473 

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() 

478 

479 if not trailer: 

480 return [] 

481 

482 trailer_list = [] 

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

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

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

486 

487 return trailer_list 

488 

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) 

509 

510 @property 

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

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

513 

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

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

516 

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. 

520 

521 Valid message with trailer:: 

522 

523 Subject line 

524 

525 some body information 

526 

527 another information 

528 

529 key1: value1.1 

530 key1: value1.2 

531 key2 : value 2 with inner spaces 

532 

533 Returned dictionary will look like this:: 

534 

535 { 

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

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

538 } 

539 

540 

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) 

549 

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. 

553 

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

555 from our lighting fast object database. 

556 

557 :param proc: 

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

559 

560 :return: 

561 Iterator supplying :class:`Commit` objects 

562 """ 

563 

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

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

566 

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

568 # return hasattr(proc_or_stream, 'readline') 

569 

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

580 

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 

591 

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

593 yield cls(repo, hex_to_bin(hexsha)) 

594 # END for each line in stream 

595 

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) 

601 

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. 

617 

618 :param repo: 

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

620 

621 :param tree: 

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

623 The tree of the new commit. 

624 

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. 

628 

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. 

634 

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. 

639 

640 :param author: 

641 The name of the author, optional. 

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

643 

644 :param committer: 

645 The name of the committer, optional. 

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

647 

648 :param author_date: 

649 The timestamp for the author field. 

650 

651 :param commit_date: 

652 The timestamp for the committer field. 

653 

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)`. 

661 

662 :return: 

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

664 

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. 

668 

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 

687 

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. 

692 

693 # COMMITTER AND AUTHOR INFO 

694 cr = repo.config_reader() 

695 env = os.environ 

696 

697 committer = committer or Actor.committer(cr) 

698 author = author or Actor.author(cr) 

699 

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 

704 

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 

713 

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 

722 

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

728 

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 

734 

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

746 

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

748 # END apply trailers 

749 

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 ) 

765 

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

767 

768 if head: 

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

770 # well... 

771 import git.refs 

772 

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 

787 

788 return new_commit 

789 

790 # { Serializable Implementation 

791 

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) 

802 

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

807 

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 ) 

824 

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 ) 

839 

840 if self.encoding != self.default_encoding: 

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

842 

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 

850 

851 write(b"\n") 

852 

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 

860 

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

864 

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) 

876 

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() 

881 

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 

889 

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

894 

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() 

929 

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 ) 

944 

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 

959 

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 

973 

974 return self 

975 

976 # } END serializable implementation 

977 

978 @property 

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

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

981 

982 Details on co-authors: 

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

984 

985 :return: 

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

987 """ 

988 co_authors = [] 

989 

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])) 

1007 

1008 return co_authors