Skip to content

Commit e3bc5d1

Browse files
authored
Merge pull request #1576 from itsluketwist/fix-trailers
Fix up the commit trailers functionality
2 parents 61ed7ec + 9ef07a7 commit e3bc5d1

File tree

2 files changed

+121
-58
lines changed

2 files changed

+121
-58
lines changed

git/objects/commit.py

+80-22
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import os
2727
from io import BytesIO
2828
import logging
29+
from collections import defaultdict
2930

3031

3132
# typing ------------------------------------------------------------------
@@ -335,8 +336,72 @@ def stats(self) -> Stats:
335336
return Stats._list_from_string(self.repo, text)
336337

337338
@property
338-
def trailers(self) -> Dict:
339-
"""Get the trailers of the message as dictionary
339+
def trailers(self) -> Dict[str, str]:
340+
"""Get the trailers of the message as a dictionary
341+
342+
:note: This property is deprecated, please use either ``Commit.trailers_list`` or ``Commit.trailers_dict``.
343+
344+
:return:
345+
Dictionary containing whitespace stripped trailer information.
346+
Only contains the latest instance of each trailer key.
347+
"""
348+
return {
349+
k: v[0] for k, v in self.trailers_dict.items()
350+
}
351+
352+
@property
353+
def trailers_list(self) -> List[Tuple[str, str]]:
354+
"""Get the trailers of the message as a list
355+
356+
Git messages can contain trailer information that are similar to RFC 822
357+
e-mail headers (see: https://git-scm.com/docs/git-interpret-trailers).
358+
359+
This functions calls ``git interpret-trailers --parse`` onto the message
360+
to extract the trailer information, returns the raw trailer data as a list.
361+
362+
Valid message with trailer::
363+
364+
Subject line
365+
366+
some body information
367+
368+
another information
369+
370+
key1: value1.1
371+
key1: value1.2
372+
key2 : value 2 with inner spaces
373+
374+
375+
Returned list will look like this::
376+
377+
[
378+
("key1", "value1.1"),
379+
("key1", "value1.2"),
380+
("key2", "value 2 with inner spaces"),
381+
]
382+
383+
384+
:return:
385+
List containing key-value tuples of whitespace stripped trailer information.
386+
"""
387+
cmd = ["git", "interpret-trailers", "--parse"]
388+
proc: Git.AutoInterrupt = self.repo.git.execute(cmd, as_process=True, istream=PIPE) # type: ignore
389+
trailer: str = proc.communicate(str(self.message).encode())[0].decode("utf8")
390+
trailer = trailer.strip()
391+
392+
if not trailer:
393+
return []
394+
395+
trailer_list = []
396+
for t in trailer.split("\n"):
397+
key, val = t.split(":", 1)
398+
trailer_list.append((key.strip(), val.strip()))
399+
400+
return trailer_list
401+
402+
@property
403+
def trailers_dict(self) -> Dict[str, List[str]]:
404+
"""Get the trailers of the message as a dictionary
340405
341406
Git messages can contain trailer information that are similar to RFC 822
342407
e-mail headers (see: https://git-scm.com/docs/git-interpret-trailers).
@@ -345,42 +410,35 @@ def trailers(self) -> Dict:
345410
to extract the trailer information. The key value pairs are stripped of
346411
leading and trailing whitespaces before they get saved into a dictionary.
347412
348-
Valid message with trailer:
349-
350-
.. code-block::
413+
Valid message with trailer::
351414
352415
Subject line
353416
354417
some body information
355418
356419
another information
357420
358-
key1: value1
421+
key1: value1.1
422+
key1: value1.2
359423
key2 : value 2 with inner spaces
360424
361-
dictionary will look like this:
362425
363-
.. code-block::
426+
Returned dictionary will look like this::
364427
365428
{
366-
"key1": "value1",
367-
"key2": "value 2 with inner spaces"
429+
"key1": ["value1.1", "value1.2"],
430+
"key2": ["value 2 with inner spaces"],
368431
}
369432
370-
:return: Dictionary containing whitespace stripped trailer information
371433
434+
:return:
435+
Dictionary containing whitespace stripped trailer information.
436+
Mapping trailer keys to a list of their corresponding values.
372437
"""
373-
d = {}
374-
cmd = ["git", "interpret-trailers", "--parse"]
375-
proc: Git.AutoInterrupt = self.repo.git.execute(cmd, as_process=True, istream=PIPE) # type: ignore
376-
trailer: str = proc.communicate(str(self.message).encode())[0].decode()
377-
if trailer.endswith("\n"):
378-
trailer = trailer[0:-1]
379-
if trailer != "":
380-
for line in trailer.split("\n"):
381-
key, value = line.split(":", 1)
382-
d[key.strip()] = value.strip()
383-
return d
438+
d = defaultdict(list)
439+
for key, val in self.trailers_list:
440+
d[key].append(val)
441+
return dict(d)
384442

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

test/test_commit.py

+41-36
Original file line numberDiff line numberDiff line change
@@ -494,52 +494,57 @@ def test_datetimes(self):
494494

495495
def test_trailers(self):
496496
KEY_1 = "Hello"
497-
VALUE_1 = "World"
497+
VALUE_1_1 = "World"
498+
VALUE_1_2 = "Another-World"
498499
KEY_2 = "Key"
499500
VALUE_2 = "Value with inner spaces"
500501

501-
# Check if KEY 1 & 2 with Value 1 & 2 is extracted from multiple msg variations
502-
msgs = []
503-
msgs.append(f"Subject\n\n{KEY_1}: {VALUE_1}\n{KEY_2}: {VALUE_2}\n")
504-
msgs.append(f"Subject\n \nSome body of a function\n \n{KEY_1}: {VALUE_1}\n{KEY_2}: {VALUE_2}\n")
505-
msgs.append(
506-
f"Subject\n \nSome body of a function\n\nnon-key: non-value\n\n{KEY_1}: {VALUE_1}\n{KEY_2}: {VALUE_2}\n"
507-
)
508-
msgs.append(
509-
f"Subject\n \nSome multiline\n body of a function\n\nnon-key: non-value\n\n{KEY_1}: {VALUE_1}\n{KEY_2} : {VALUE_2}\n"
510-
)
511-
502+
# Check the following trailer example is extracted from multiple msg variations
503+
TRAILER = f"{KEY_1}: {VALUE_1_1}\n{KEY_2}: {VALUE_2}\n{KEY_1}: {VALUE_1_2}"
504+
msgs = [
505+
f"Subject\n\n{TRAILER}\n",
506+
f"Subject\n \nSome body of a function\n \n{TRAILER}\n",
507+
f"Subject\n \nSome body of a function\n\nnon-key: non-value\n\n{TRAILER}\n",
508+
(
509+
# check when trailer has inconsistent whitespace
510+
f"Subject\n \nSome multiline\n body of a function\n\nnon-key: non-value\n\n"
511+
f"{KEY_1}:{VALUE_1_1}\n{KEY_2} : {VALUE_2}\n{KEY_1}: {VALUE_1_2}\n"
512+
),
513+
]
512514
for msg in msgs:
513-
commit = self.rorepo.commit("master")
514-
commit = copy.copy(commit)
515+
commit = copy.copy(self.rorepo.commit("master"))
515516
commit.message = msg
516-
assert KEY_1 in commit.trailers.keys()
517-
assert KEY_2 in commit.trailers.keys()
518-
assert commit.trailers[KEY_1] == VALUE_1
519-
assert commit.trailers[KEY_2] == VALUE_2
520-
521-
# Check that trailer stays empty for multiple msg combinations
522-
msgs = []
523-
msgs.append(f"Subject\n")
524-
msgs.append(f"Subject\n\nBody with some\nText\n")
525-
msgs.append(f"Subject\n\nBody with\nText\n\nContinuation but\n doesn't contain colon\n")
526-
msgs.append(f"Subject\n\nBody with\nText\n\nContinuation but\n only contains one :\n")
527-
msgs.append(f"Subject\n\nBody with\nText\n\nKey: Value\nLine without colon\n")
528-
msgs.append(f"Subject\n\nBody with\nText\n\nLine without colon\nKey: Value\n")
517+
assert commit.trailers_list == [
518+
(KEY_1, VALUE_1_1),
519+
(KEY_2, VALUE_2),
520+
(KEY_1, VALUE_1_2),
521+
]
522+
assert commit.trailers_dict == {
523+
KEY_1: [VALUE_1_1, VALUE_1_2],
524+
KEY_2: [VALUE_2],
525+
}
526+
527+
# check that trailer stays empty for multiple msg combinations
528+
msgs = [
529+
f"Subject\n",
530+
f"Subject\n\nBody with some\nText\n",
531+
f"Subject\n\nBody with\nText\n\nContinuation but\n doesn't contain colon\n",
532+
f"Subject\n\nBody with\nText\n\nContinuation but\n only contains one :\n",
533+
f"Subject\n\nBody with\nText\n\nKey: Value\nLine without colon\n",
534+
f"Subject\n\nBody with\nText\n\nLine without colon\nKey: Value\n",
535+
]
529536

530537
for msg in msgs:
531-
commit = self.rorepo.commit("master")
532-
commit = copy.copy(commit)
538+
commit = copy.copy(self.rorepo.commit("master"))
533539
commit.message = msg
534-
assert len(commit.trailers.keys()) == 0
540+
assert commit.trailers_list == []
541+
assert commit.trailers_dict == {}
535542

536543
# check that only the last key value paragraph is evaluated
537-
commit = self.rorepo.commit("master")
538-
commit = copy.copy(commit)
539-
commit.message = f"Subject\n\nMultiline\nBody\n\n{KEY_1}: {VALUE_1}\n\n{KEY_2}: {VALUE_2}\n"
540-
assert KEY_1 not in commit.trailers.keys()
541-
assert KEY_2 in commit.trailers.keys()
542-
assert commit.trailers[KEY_2] == VALUE_2
544+
commit = copy.copy(self.rorepo.commit("master"))
545+
commit.message = f"Subject\n\nMultiline\nBody\n\n{KEY_1}: {VALUE_1_1}\n\n{KEY_2}: {VALUE_2}\n"
546+
assert commit.trailers_list == [(KEY_2, VALUE_2)]
547+
assert commit.trailers_dict == {KEY_2: [VALUE_2]}
543548

544549
def test_commit_co_authors(self):
545550
commit = copy.copy(self.rorepo.commit("4251bd5"))

0 commit comments

Comments
 (0)