From 35027d14e377e11265836274e49f4281d249175a Mon Sep 17 00:00:00 2001 From: Keerthana KT Date: Mon, 2 Nov 2026 22:24:23 +0530 Subject: [PATCH 1/2] fix(repo): preserve trailing whitespace in blamed line content `Repo.blame` built each blamed line with `line_bytes.rstrip().decode(defenc)` and then stripped the leading tab, but a `git blame -p` content line is a leading tab, the blamed line's exact bytes, and git's own terminating line feed. `rstrip` therefore dropped a line's trailing spaces and tabs, emptied a whitespace-only line, and removed the carriage return of a CRLF line, so the content returned by `repo.blame()` differed from both the file and `git blame`. A caller that reconstructs or compares file content from blame, for example to find the commit that introduced an exact line, was handed altered text. Recover the exact bytes instead: drop the leading tab and only git's trailing line feed, then decode. Binary lines and the hexsha/header parsing are unchanged, so the only behavior change is that blamed lines keep their original trailing whitespace and carriage returns. Adds `tests/fixtures/blame-content.sh` and `test_blame_preserves_line_content` in `test/test_repo.py`, which fail before the change (`['alpha', 'beta', '', 'crlf', 'gamma']`) and pass after (`['alpha ', 'beta', '\t', 'crlf\r', 'gamma']`) on both the `cli` and `gix` backends. `ruff check`, `ruff format --check` and `mypy` pass on the touched files. The repository's pre-existing shallow-clone failures occur identically with and without this change. Agent-Model: claude-opus-4-8 --- git/repo/base.py | 12 +++++++++--- test/test_repo.py | 6 ++++++ tests/fixtures/blame-content.sh | 10 ++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/blame-content.sh 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_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" From ff48765daf968bd4f084fc9451b29962627a0cdc Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 4 Nov 2026 10:52:21 +0100 Subject: [PATCH 2/2] test(refs): keep PathLike reference names portable on Windows `test_revision_arguments_preserve_pathlike_paths` fails in the Windows `Python package` jobs because `Path("refs/heads/main")` emits `refs\heads\main`. Git rejects backslashes in reference names, so the test raises `ValueError: Invalid reference` before exercising revision argument handling. All four combinations of SHA-1/SHA-256 and files/reftable fail. The same failures are present in the final Windows Python 3.14t job for the introducing PR #2336, so this is inherited by PR #2339 rather than caused by its blame-content change. Use `PurePosixPath` for the reference-name test parameter. Reference names use forward slashes on every platform; filesystem paths elsewhere in the tests continue to use `Path`. Keep `PathLikeMock` coverage and the existing reference validation unchanged. Validation: 28 focused reference/revision tests pass on each of the `cli` and `gix` backends, including all eight PathLike cases, nested merge-base operands, reflog walks, fork-point lookup, and remote archives. Both blame-content regressions also pass on each backend. Ruff 0.17.2 lint and formatting checks pass for `test/test_refs.py`; `git diff --check` passes. Windows validation is left to the PR's CI matrix. Agent-Model: unknown --- test/test_refs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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