Skip to content

Commit 35027d1

Browse files
committed
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
1 parent 8e919a4 commit 35027d1

3 files changed

Lines changed: 25 additions & 3 deletions

File tree

‎git/repo/base.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,9 +1550,15 @@ class InfoTD(TypedDict, total=False):
15501550
if blames[-1][1] is not None:
15511551
line: str | bytes
15521552
if not is_binary:
1553-
if line_str and line_str[0] == "\t":
1554-
line_str = line_str[1:]
1555-
line = line_str
1553+
# Recover the exact line bytes. A porcelain content
1554+
# line is a leading tab, the blamed line's bytes, and
1555+
# git's terminating line feed, so rstrip() would drop
1556+
# the line's own trailing whitespace and the carriage
1557+
# return of a CRLF line.
1558+
content = line_bytes[1:] if line_bytes[:1] == b"\t" else line_bytes
1559+
if content.endswith(b"\n"):
1560+
content = content[:-1]
1561+
line = content.decode(defenc)
15561562
else:
15571563
line = line_bytes
15581564
# NOTE: We are actually parsing lines out of binary

‎test/test_repo.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,12 @@ def test_blame_incremental_preserves_historical_paths(self):
784784
)
785785
self.assertEqual(list(repo.blame("HEAD", "current.txt", incremental=True)), entries)
786786

787+
def test_blame_preserves_line_content(self):
788+
"""Blame returns each line's exact bytes, not a whitespace-stripped version."""
789+
with fixture_repo("blame-content.sh") as repo:
790+
lines = [line for _commit, blamed in repo.blame("HEAD", "file.txt") for line in blamed]
791+
self.assertEqual(lines, ["alpha ", "beta", "\t", "crlf\r", "gamma"])
792+
787793
@mock.patch.object(Git, "_call_process")
788794
def test_blame_complex_revision(self, git):
789795
git.return_value = fixture("blame_complex_revision")

‎tests/fixtures/blame-content.sh‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env bash
2+
set -eu
3+
git init -q --initial-branch=main repo
4+
cd repo
5+
# Build the blob directly so trailing whitespace, a whitespace-only line, and a
6+
# CRLF line survive fixture creation on every platform.
7+
blob=$(printf 'alpha \nbeta\n\t\ncrlf\r\ngamma\n' | git hash-object -w --stdin)
8+
tree=$(printf '100644 blob %s\tfile.txt\0' "$blob" | git mktree -z)
9+
commit=$(git -c user.name=A -c user.email=a@example.com commit-tree "$tree" -m content)
10+
git update-ref refs/heads/main "$commit"

0 commit comments

Comments
 (0)