Skip to content

Commit ba5fc18

Browse files
committed
fix(commit): end commit headers only at an empty line
`Commit._deserialize` read the headers after `committer` with `readline().strip()` and stopped at the first line that was empty after stripping. Git ends the header block only at a truly empty line, so a header line holding only whitespace (`\r`, a bare continuation space, `\t`) ended the headers in GitPython but not in git. Everything up to the real blank line was then returned as part of `Commit.message`, and any `encoding` or `gpgsig` header after that line was missed. Commit headers are chosen by whoever wrote the commit. This object is accepted by `git hash-object -t commit -w` and `git fsck --strict` (git 2.50.1): tree <tree> author A <a@x> 1700000000 +0000 committer A <a@x> 1700000000 +0000 x-note a \r Approved-by: Mallory <m@evil> real subject `git log --format=%B` shows `real subject`, while GitPython returned `Approved-by: Mallory <m@evil>\n\nreal subject\n` as the message and the forged line as `summary`; a co-author trailer placed there the same way is listed by `co_authors`. With an `encoding ISO-8859-1` header after a ` ` line, git decoded the author and message as Latin-1, but GitPython kept UTF-8 and showed the `encoding` line as message text. The loop now ends only at an empty line or the end of the stream, and strips only trailing whitespace before it matches header names. Leading whitespace is no longer stripped, so a continuation line such as ` encoding X` is not read as a header of its own. Commits whose headers end at a plain `\n` parse exactly as before. Adds `test_deserialization_headers_end_at_empty_line` to `test/test_commit.py`. It fails before this change and passes after. `test/test_commit.py` passes (43 tests). In the full suite, 7 tests fail in this environment; they fail the same way without this change. `ruff check`, `ruff format --check` and `mypy` are clean on the changed files.
1 parent ccc4811 commit ba5fc18

2 files changed

Lines changed: 31 additions & 6 deletions

File tree

‎git/objects/commit.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -892,10 +892,13 @@ def _deserialize(self, stream: BytesIO) -> "Commit":
892892
self.encoding = self.default_encoding
893893
self.gpgsig = ""
894894

895-
# Read headers.
896-
enc = next_line
897-
buf = enc.strip()
898-
while buf:
895+
# Read headers. Like git, end them only at an empty line. A line holding only
896+
# whitespace (such as "\r" or a bare continuation space) is still a header
897+
# line, and only trailing whitespace is stripped, so a continuation line is
898+
# never read as a header of its own.
899+
line = next_line
900+
while line not in (b"", b"\n"):
901+
buf = line.rstrip()
899902
if buf[0:9] == b"encoding ":
900903
raw_encoding = buf[9:]
901904
try:
@@ -917,15 +920,15 @@ def _deserialize(self, stream: BytesIO) -> "Commit":
917920
if not sigbuf:
918921
break
919922
if sigbuf[0:1] != b" ":
920-
buf = sigbuf.strip()
923+
line = sigbuf
921924
is_next_header = True
922925
break
923926
sig_lines.append(sigbuf[1:])
924927
# END read all signature
925928
self.gpgsig = b"".join(sig_lines).rstrip(b"\n").decode(self.encoding, "ignore")
926929
if is_next_header:
927930
continue
928-
buf = readline().strip()
931+
line = readline()
929932

930933
# Decode the author's name.
931934
try:

‎test/test_commit.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,28 @@ def test_deserialization_multiple_encoding_headers(self):
513513
self.assertEqual(cmt.committer.name, "André")
514514
self.assertEqual(cmt.message, "Résumé\n")
515515

516+
def test_deserialization_headers_end_at_empty_line(self):
517+
# Git ends the headers only at an empty line. A line holding only whitespace
518+
# belongs to the headers, so what follows it is not the message.
519+
for blank in (b"\r\n", b" \n", b"\t\n"):
520+
with self.subTest(blank=blank):
521+
data = (
522+
b"tree 3385f7b1e5f91930a3bd3e07105e113fca29981d\n"
523+
b"author Andr\xe9 <andre@example.com> 1112591163 +0200\n"
524+
b"committer Andr\xe9 <andre@example.com> 1112591163 +0200\n"
525+
b"x-note a\n" + blank + b"Co-authored-by: Mallory <mallory@example.com>\n"
526+
b"encoding ISO-8859-1\n"
527+
b"\n"
528+
b"R\xe9sum\xe9\n"
529+
)
530+
cmt = self.rorepo.commit()
531+
cmt._deserialize(BytesIO(data))
532+
533+
self.assertEqual(cmt.encoding, "ISO-8859-1")
534+
self.assertEqual(cmt.author.name, "André")
535+
self.assertEqual(cmt.message, "Résumé\n")
536+
self.assertEqual(cmt.co_authors, [])
537+
516538
@with_rw_directory
517539
def test_identity_cannot_alter_headers(self, rw_dir):
518540
"""A name or email must not add header lines or present another identity."""

0 commit comments

Comments
 (0)