diff --git a/git/repo/base.py b/git/repo/base.py index 21eb2e118..6e582becf 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1550,9 +1550,15 @@ class InfoTD(TypedDict, total=False): if blames[-1][1] is not None: line: str | bytes if not is_binary: - if line_str and line_str[0] == "\t": - line_str = line_str[1:] - line = line_str + # Recover the exact line bytes. A porcelain content + # line is a leading tab, the blamed line's bytes, and + # git's terminating line feed, so rstrip() would drop + # the line's own trailing whitespace and the carriage + # return of a CRLF line. + content = line_bytes[1:] if line_bytes[:1] == b"\t" else line_bytes + if content.endswith(b"\n"): + content = content[:-1] + line = content.decode(defenc) else: line = line_bytes # NOTE: We are actually parsing lines out of binary diff --git a/test/test_refs.py b/test/test_refs.py index bd0febf82..97b14b61e 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -10,7 +10,7 @@ import tarfile import unittest from itertools import chain -from pathlib import Path +from pathlib import Path, PurePosixPath from unittest.mock import Mock, patch import pytest @@ -270,7 +270,7 @@ def test_merge_base_preserves_nested_reference_arguments(object_format, ref_form @pytest.mark.parametrize("object_format", ["sha1", "sha256"]) @pytest.mark.parametrize("ref_format", ["files", "reftable"]) -@pytest.mark.parametrize("path_type", [Path, PathLikeMock]) +@pytest.mark.parametrize("path_type", [PurePosixPath, PathLikeMock]) def test_revision_arguments_preserve_pathlike_paths(object_format, ref_format, path_type): with fixture_repo("exact-refs.sh", object_format, ref_format, "shadowed") as repo: local = repo.head.commit diff --git a/test/test_repo.py b/test/test_repo.py index 38ac3e630..137024311 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -784,6 +784,12 @@ def test_blame_incremental_preserves_historical_paths(self): ) self.assertEqual(list(repo.blame("HEAD", "current.txt", incremental=True)), entries) + def test_blame_preserves_line_content(self): + """Blame returns each line's exact bytes, not a whitespace-stripped version.""" + with fixture_repo("blame-content.sh") as repo: + lines = [line for _commit, blamed in repo.blame("HEAD", "file.txt") for line in blamed] + self.assertEqual(lines, ["alpha ", "beta", "\t", "crlf\r", "gamma"]) + @mock.patch.object(Git, "_call_process") def test_blame_complex_revision(self, git): git.return_value = fixture("blame_complex_revision") diff --git a/tests/fixtures/blame-content.sh b/tests/fixtures/blame-content.sh new file mode 100644 index 000000000..7ee38af9f --- /dev/null +++ b/tests/fixtures/blame-content.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -eu +git init -q --initial-branch=main repo +cd repo +# Build the blob directly so trailing whitespace, a whitespace-only line, and a +# CRLF line survive fixture creation on every platform. +blob=$(printf 'alpha \nbeta\n\t\ncrlf\r\ngamma\n' | git hash-object -w --stdin) +tree=$(printf '100644 blob %s\tfile.txt\0' "$blob" | git mktree -z) +commit=$(git -c user.name=A -c user.email=a@example.com commit-tree "$tree" -m content) +git update-ref refs/heads/main "$commit"