Skip to content

Commit b911984

Browse files
Byroncodex
andcommitted
fix(repo): return actual historical paths from incremental blame
<!-- agent --> `Repo.blame_incremental()` copied Git's `filename` field into `BlameEntry.orig_path` without interpreting its C-style quoting. After renaming `café.txt` to `current.txt`, callers received `"caf\303\251.txt"` instead of `café.txt`, so following an attribution back to its original tree entry failed. Quotes, backslashes and control characters had the same problem, even with `core.quotePath=false`. Decode quoted filenames with the existing `git.diff._unquote_path()` helper before constructing each entry. This shared point handles both newly encountered and cached commits, leaves unquoted names unchanged, and retains `safe_decode()`'s byte-preserving `surrogateescape` behavior. `Repo.blame(..., incremental=True)` receives the same correction. Blame continues to use Git's CLI on both backends; this is an output-parser fix, not a new Gix implementation. Add an independently written tree-based fixture and regression covering eight historical filenames with both `core.quotePath` settings. Renaming then editing the middle line produces two separated entries attributed to the same original commit, exercising both parser branches. Check the commit, original path and both line ranges through both public entry points. Tree construction avoids platform restrictions on checking out control-character and invalid-UTF-8 filenames. The regression failed in ten quoted-filename subcases on each backend before the fix and passes afterward. Repository and diff tests pass on both backends; Ruff lint/format, mypy and ShellCheck also pass. Git evidence: the comparison binary is `git version 2.54.0 (Apple Git-157)`. Reference checkout revision `6de20f6092dcf9bdb1c8efe03db4b70c82b423dd` documents the terminating `filename` field in `Documentation/git-blame.adoc`; its `builtin/blame.c:write_filename_info()` uses `write_name_quoted()`. The regression's real `git blame --incremental HEAD -- current.txt` output quotes the historical path in both entries for the original commit, matching that format. Independent solution to the problem reported and investigated by `Keerthana-64` in #2314. No implementation or tests were taken from that PR. Assisted-by: GPT 6.1 Sol Co-authored-by: GPT 6.1 Sol <codex@openai.com>
1 parent 4debc51 commit b911984

3 files changed

Lines changed: 54 additions & 0 deletions

File tree

‎git/repo/base.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,8 @@ def blame_incremental(
12931293
If you combine all line number ranges outputted by this command, you should get
12941294
a continuous range spanning all line numbers in the file.
12951295
"""
1296+
from git.diff import _unquote_path
1297+
12961298
if not allow_unsafe_options:
12971299
Git.check_unsafe_options(
12981300
options=Git._option_candidates([rev], kwargs),
@@ -1375,6 +1377,9 @@ def blame_incremental(
13751377
orig_filename = value
13761378
break
13771379

1380+
if orig_filename.startswith(b'"') and orig_filename.endswith(b'"'):
1381+
orig_filename = _unquote_path(orig_filename[1:-1])
1382+
13781383
yield BlameEntry(
13791384
commits[hexsha],
13801385
range(lineno, lineno + num_lines),

‎test/test_repo.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
Submodule,
3737
Tree,
3838
)
39+
from git.compat import safe_decode
3940
from git.exc import BadObject, UnsafeOptionError, UnsafeProtocolError
4041
from git.repo.fun import find_worktree_git_dir
4142
from git.util import bin_to_hex, cwd, rmfile, rmtree
@@ -754,6 +755,34 @@ def test_blame_incremental(self, git):
754755
),
755756
)
756757

758+
def test_blame_incremental_preserves_historical_paths(self):
759+
paths = (
760+
b"plain.txt",
761+
b" spaced name.txt ",
762+
"café.txt".encode(),
763+
b"byte-\xff.txt",
764+
b'"literal-\\303\\251.txt"',
765+
b'quote"name.txt',
766+
b"back\\slash.txt",
767+
b"control\a\b\t\n\v\f\r.txt",
768+
)
769+
for quote_path in ("true", "false"):
770+
for i, path in enumerate(paths):
771+
with self.subTest(quote_path=quote_path, path=path), fixture_repo(
772+
"blame-paths.sh", quote_path, subdir=str(i)
773+
) as repo:
774+
entries = list(repo.blame_incremental("HEAD", "current.txt"))
775+
original = repo.commit("HEAD~2")
776+
self.assertEqual(
777+
sorted(entries, key=lambda e: e.linenos.start),
778+
[
779+
(original, range(1, 2), safe_decode(path), range(1, 2)),
780+
(repo.head.commit, range(2, 3), "current.txt", range(2, 3)),
781+
(original, range(3, 4), safe_decode(path), range(3, 4)),
782+
],
783+
)
784+
self.assertEqual(list(repo.blame("HEAD", "current.txt", incremental=True)), entries)
785+
757786
@mock.patch.object(Git, "_call_process")
758787
def test_blame_complex_revision(self, git):
759788
git.return_value = fixture("blame_complex_revision")

‎tests/fixtures/blame-paths.sh‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env bash
2+
set -eu
3+
# Tree entries allow historical names that cannot be checked out on every platform.
4+
paths=('plain.txt' ' spaced name.txt ' $'caf\303\251.txt' $'byte-\377.txt' '"literal-\303\251.txt"' 'quote"name.txt' 'back\slash.txt' $'control\a\b\t\n\v\f\r.txt')
5+
for i in "${!paths[@]}"; do
6+
git init -q --initial-branch=main "$i"
7+
(
8+
cd "$i"
9+
git config core.quotePath "$1"
10+
blob=$(printf 'first\nmiddle\nlast\n' | git hash-object -w --stdin)
11+
tree=$(printf '100644 blob %s\t%s\0' "$blob" "${paths[$i]}" | git mktree -z)
12+
original=$(git commit-tree "$tree" -m original)
13+
tree=$(printf '100644 blob %s\tcurrent.txt\0' "$blob" | git mktree -z)
14+
renamed=$(git commit-tree "$tree" -p "$original" -m rename)
15+
blob=$(printf 'first\nchanged\nlast\n' | git hash-object -w --stdin)
16+
tree=$(printf '100644 blob %s\tcurrent.txt\0' "$blob" | git mktree -z)
17+
edited=$(git commit-tree "$tree" -p "$renamed" -m edit)
18+
git update-ref refs/heads/main "$edited"
19+
)
20+
done

0 commit comments

Comments
 (0)