From 39004313aba479fd029405e4a1e421c6fe3d9145 Mon Sep 17 00:00:00 2001 From: Keerthana KT Date: Tue, 27 Oct 2026 13:55:45 +0530 Subject: [PATCH] fix(repo): unquote the filename line in `Repo.blame_incremental` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Repo.blame_incremental` took `BlameEntry.orig_path` from the `filename` line of `git blame --incremental` verbatim. Git writes that line with `write_name_quoted`, so a path containing a double quote, a backslash, a control character or, with the default `core.quotePath=true`, any non-ASCII byte is C-quoted: a line attributed to `café.txt` came back as `orig_path == '"caf\303\251.txt"'` and `"quoted".txt` as `'"\\"quoted\\".txt"'`, names that exist nowhere in the repository. The quoted form reaches callers whenever the blamed file was renamed from such a name, which the author of the history chooses. Decode a quoted `filename` value with the existing `_unquote_path` helper from `git.diff`, which already reverses Git's quoting for diff headers; unquoted values are unchanged. The non-incremental porcelain parser in `Repo.blame` reads the same line but does not expose it, so it needs no change. Adds a `blame-quoted-filename` case to `tests/fixtures/repo.sh` and `test_blame_incremental_unquotes_filenames` in `test/test_repo.py`, which fails before the change and passes after on both the CLI and the Gix backend. Ruff, mypy and ShellCheck pass. --- git/repo/base.py | 6 ++++++ test/test_repo.py | 11 +++++++++++ tests/fixtures/repo.sh | 8 ++++++++ 3 files changed, 25 insertions(+) diff --git a/git/repo/base.py b/git/repo/base.py index 0de87d01c..3965ba1a1 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,10 @@ def blame_incremental( orig_filename = value break + # Git C-quotes a filename containing control, quote, backslash or, + # with core.quotePath, non-ASCII bytes, the way it does in diff output. + 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..57d674770 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -792,6 +792,17 @@ def test_blame_content_with_carriage_returns(self): self.assertEqual(blames[0][0].author.name, "GitPython Tests") self.assertEqual(blames[0][1], ["first", forged]) + def test_blame_incremental_unquotes_filenames(self): + # Git C-quotes the filename line of its incremental output for a path with + # non-ASCII, control, quote or backslash bytes. The entry must name the path. + for name in ("caf\u00e9.txt", '"quoted".txt', "tab\tname.txt", "back\\slash.txt"): + with self.subTest(name=name), fixture_repo("repo.sh", "blame-quoted-filename", name) as repo: + quoted = repo.git.blame("HEAD", "-p", "--incremental", "--", "plain.txt", stdout_as_string=False) + assert b'\nfilename "' in quoted + entries = list(repo.blame_incremental("HEAD", "plain.txt")) + self.assertEqual([entry.orig_path for entry in entries], [name]) + self.assertEqual([entry.linenos for entry in entries], [range(1, 3)]) + def test_untracked_files(self): for command in ("index", "git"): with fixture_repo("repo.sh", "untracked", write=True) as repo: diff --git a/tests/fixtures/repo.sh b/tests/fixtures/repo.sh index c6d636c33..85955c6d5 100644 --- a/tests/fixtures/repo.sh +++ b/tests/fixtures/repo.sh @@ -123,6 +123,14 @@ blame-carriage-returns) git -C repo add file.txt git -C repo commit -qm init --author='GitPython Tests ' ;; +blame-quoted-filename) + git init -q repo + printf 'first\nsecond\n' > "repo/$2" + git -C repo add -- "$2" + git -C repo commit -qm init --author='GitPython Tests ' + git -C repo mv -- "$2" plain.txt + git -C repo commit -qm rename --author='GitPython Tests ' + ;; relocated-git-dir) git init -q repo mv repo/.git repo/.real