From b911984101830cac300f3131674fb965d51d0fdc Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 27 Oct 2026 10:55:49 +0100 Subject: [PATCH 1/3] fix(repo): return actual historical paths from incremental blame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 https://github.com/gitpython-developers/GitPython/pull/2314. No implementation or tests were taken from that PR. Assisted-by: GPT 6.1 Sol Co-authored-by: GPT 6.1 Sol --- git/repo/base.py | 5 +++++ test/test_repo.py | 29 +++++++++++++++++++++++++++++ tests/fixtures/blame-paths.sh | 20 ++++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 tests/fixtures/blame-paths.sh diff --git a/git/repo/base.py b/git/repo/base.py index 0de87d01c..af182740b 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1293,6 +1293,8 @@ def blame_incremental( If you combine all line number ranges outputted by this command, you should get a continuous range spanning all line numbers in the file. """ + from git.diff import _unquote_path + if not allow_unsafe_options: Git.check_unsafe_options( options=Git._option_candidates([rev], kwargs), @@ -1375,6 +1377,9 @@ def blame_incremental( orig_filename = value break + if orig_filename.startswith(b'"') and orig_filename.endswith(b'"'): + orig_filename = _unquote_path(orig_filename[1:-1]) + yield BlameEntry( commits[hexsha], range(lineno, lineno + num_lines), diff --git a/test/test_repo.py b/test/test_repo.py index 7ecf2a920..f2e129260 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -36,6 +36,7 @@ Submodule, Tree, ) +from git.compat import safe_decode from git.exc import BadObject, UnsafeOptionError, UnsafeProtocolError from git.repo.fun import find_worktree_git_dir from git.util import bin_to_hex, cwd, rmfile, rmtree @@ -754,6 +755,34 @@ def test_blame_incremental(self, git): ), ) + def test_blame_incremental_preserves_historical_paths(self): + paths = ( + b"plain.txt", + b" spaced name.txt ", + "café.txt".encode(), + b"byte-\xff.txt", + b'"literal-\\303\\251.txt"', + b'quote"name.txt', + b"back\\slash.txt", + b"control\a\b\t\n\v\f\r.txt", + ) + for quote_path in ("true", "false"): + for i, path in enumerate(paths): + with self.subTest(quote_path=quote_path, path=path), fixture_repo( + "blame-paths.sh", quote_path, subdir=str(i) + ) as repo: + entries = list(repo.blame_incremental("HEAD", "current.txt")) + original = repo.commit("HEAD~2") + self.assertEqual( + sorted(entries, key=lambda e: e.linenos.start), + [ + (original, range(1, 2), safe_decode(path), range(1, 2)), + (repo.head.commit, range(2, 3), "current.txt", range(2, 3)), + (original, range(3, 4), safe_decode(path), range(3, 4)), + ], + ) + self.assertEqual(list(repo.blame("HEAD", "current.txt", incremental=True)), entries) + @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-paths.sh b/tests/fixtures/blame-paths.sh new file mode 100644 index 000000000..020b8e3be --- /dev/null +++ b/tests/fixtures/blame-paths.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -eu +# Tree entries allow historical names that cannot be checked out on every platform. +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') +for i in "${!paths[@]}"; do + git init -q --initial-branch=main "$i" + ( + cd "$i" + git config core.quotePath "$1" + blob=$(printf 'first\nmiddle\nlast\n' | git hash-object -w --stdin) + tree=$(printf '100644 blob %s\t%s\0' "$blob" "${paths[$i]}" | git mktree -z) + original=$(git commit-tree "$tree" -m original) + tree=$(printf '100644 blob %s\tcurrent.txt\0' "$blob" | git mktree -z) + renamed=$(git commit-tree "$tree" -p "$original" -m rename) + blob=$(printf 'first\nchanged\nlast\n' | git hash-object -w --stdin) + tree=$(printf '100644 blob %s\tcurrent.txt\0' "$blob" | git mktree -z) + edited=$(git commit-tree "$tree" -p "$renamed" -m edit) + git update-ref refs/heads/main "$edited" + ) +done From 874d8d9de3167250958bfc8fb1ab1f7265a3df74 Mon Sep 17 00:00:00 2001 From: Codex GPT-6 Date: Tue, 27 Oct 2026 12:06:53 +0100 Subject: [PATCH 2/3] test(repo): show complete incremental blame path failures The `test (windows, 3.15)` and `test (windows, 3.12, gix)` CI jobs fail the new historical-path regression for its combined control-character filename under both `core.quotePath` settings. Their assertion messages truncate the actual and expected entries and omit the full diff, leaving the precise filename mismatch unavailable in the job logs. Set `TestCase.maxDiff` to `None` in this regression so CI reports the complete values. This preserves every assertion and the implementation; it provides the evidence needed to distinguish fixture handling from a parser defect before changing behavior. The focused regression passes locally on the CLI and Gix backends with all sixteen filename/quoting subcases. Ruff lint and formatting checks and `git diff --check` pass. --- test/test_repo.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_repo.py b/test/test_repo.py index f2e129260..38ac3e630 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -756,6 +756,7 @@ def test_blame_incremental(self, git): ) def test_blame_incremental_preserves_historical_paths(self): + self.maxDiff = None paths = ( b"plain.txt", b" spaced name.txt ", From bb378dd7c27aa2f326111a15af6c09e4098910aa Mon Sep 17 00:00:00 2001 From: Codex GPT-6 Date: Tue, 27 Oct 2026 12:16:23 +0100 Subject: [PATCH 3/3] test(repo): preserve carriage returns in blame fixtures Windows CI with Git `2.56.0.windows.2` fails the historical-path regression for its combined control-character filename under both `core.quotePath` settings. The attributed original commit is `e5ed22d659d5aa4ff5f7527069752d20f74fc2d9`, which a local Git reproduction matches exactly when removing the carriage return from the original path. Keeping that byte produces `73813b4d58d085503f9426c6e50ce5c201f715b6`. The failure therefore comes from fixture creation losing the byte, rather than incremental blame returning a different path from the stored tree. Represent special paths as Git C-quoted text and feed the original tree through `git mktree` instead of sending raw control bytes to `mktree -z`. Git decodes the escapes while constructing the tree. This keeps carriage returns out of the raw fixture input and retains coverage for every control character, invalid UTF-8, literal escapes, and both quoting settings. The parser implementation and expected results are unchanged. The focused regression passes locally on both backends with all sixteen subcases. Raw `git ls-tree -z HEAD~2` confirms the carriage return is present, and the original commit retains its expected ID. ShellCheck, `bash -n`, and `git diff --check` pass. Windows CI must verify the fixture on its native Git/Bash combination. --- tests/fixtures/blame-paths.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/blame-paths.sh b/tests/fixtures/blame-paths.sh index 020b8e3be..3ce20214c 100644 --- a/tests/fixtures/blame-paths.sh +++ b/tests/fixtures/blame-paths.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash set -eu -# Tree entries allow historical names that cannot be checked out on every platform. -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') +# C-quoted tree input preserves control bytes through Windows fixture creation. +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"') for i in "${!paths[@]}"; do git init -q --initial-branch=main "$i" ( cd "$i" git config core.quotePath "$1" blob=$(printf 'first\nmiddle\nlast\n' | git hash-object -w --stdin) - tree=$(printf '100644 blob %s\t%s\0' "$blob" "${paths[$i]}" | git mktree -z) + tree=$(printf '100644 blob %s\t%s\n' "$blob" "${paths[$i]}" | git mktree) original=$(git commit-tree "$tree" -m original) tree=$(printf '100644 blob %s\tcurrent.txt\0' "$blob" | git mktree -z) renamed=$(git commit-tree "$tree" -p "$original" -m rename)