From ba5fc187c42ae9eb9689600050228e92c09711d8 Mon Sep 17 00:00:00 2001 From: Keerthana KT Date: Thu, 22 Oct 2026 23:13:31 +0530 Subject: [PATCH] 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 author A 1700000000 +0000 committer A 1700000000 +0000 x-note a \r Approved-by: Mallory real subject `git log --format=%B` shows `real subject`, while GitPython returned `Approved-by: Mallory \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. --- git/objects/commit.py | 15 +++++++++------ test/test_commit.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index 3239079c1..1a027d7fd 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -892,10 +892,13 @@ def _deserialize(self, stream: BytesIO) -> "Commit": self.encoding = self.default_encoding self.gpgsig = "" - # Read headers. - enc = next_line - buf = enc.strip() - while buf: + # Read headers. Like git, end them only at an empty line. A line holding only + # whitespace (such as "\r" or a bare continuation space) is still a header + # line, and only trailing whitespace is stripped, so a continuation line is + # never read as a header of its own. + line = next_line + while line not in (b"", b"\n"): + buf = line.rstrip() if buf[0:9] == b"encoding ": raw_encoding = buf[9:] try: @@ -917,7 +920,7 @@ def _deserialize(self, stream: BytesIO) -> "Commit": if not sigbuf: break if sigbuf[0:1] != b" ": - buf = sigbuf.strip() + line = sigbuf is_next_header = True break sig_lines.append(sigbuf[1:]) @@ -925,7 +928,7 @@ def _deserialize(self, stream: BytesIO) -> "Commit": self.gpgsig = b"".join(sig_lines).rstrip(b"\n").decode(self.encoding, "ignore") if is_next_header: continue - buf = readline().strip() + line = readline() # Decode the author's name. try: diff --git a/test/test_commit.py b/test/test_commit.py index a22daa87a..3317637f9 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -513,6 +513,28 @@ def test_deserialization_multiple_encoding_headers(self): self.assertEqual(cmt.committer.name, "André") self.assertEqual(cmt.message, "Résumé\n") + def test_deserialization_headers_end_at_empty_line(self): + # Git ends the headers only at an empty line. A line holding only whitespace + # belongs to the headers, so what follows it is not the message. + for blank in (b"\r\n", b" \n", b"\t\n"): + with self.subTest(blank=blank): + data = ( + b"tree 3385f7b1e5f91930a3bd3e07105e113fca29981d\n" + b"author Andr\xe9 1112591163 +0200\n" + b"committer Andr\xe9 1112591163 +0200\n" + b"x-note a\n" + blank + b"Co-authored-by: Mallory \n" + b"encoding ISO-8859-1\n" + b"\n" + b"R\xe9sum\xe9\n" + ) + cmt = self.rorepo.commit() + cmt._deserialize(BytesIO(data)) + + self.assertEqual(cmt.encoding, "ISO-8859-1") + self.assertEqual(cmt.author.name, "André") + self.assertEqual(cmt.message, "Résumé\n") + self.assertEqual(cmt.co_authors, []) + @with_rw_directory def test_identity_cannot_alter_headers(self, rw_dir): """A name or email must not add header lines or present another identity."""