diff --git a/git/diff.py b/git/diff.py index 23cb5675e..eeac6767b 100644 --- a/git/diff.py +++ b/git/diff.py @@ -113,7 +113,10 @@ def decode_path(path: bytes, has_ab_prefix: bool = True) -> Optional[bytes]: path = _octal_byte_re.sub(_octal_repl, path) if has_ab_prefix: - assert path.startswith(b"a/") or path.startswith(b"b/") + # Support standard (a/b) and mnemonicPrefix (c/w/i/o/h) prefixes + # See git-config diff.mnemonicPrefix documentation + valid_prefixes = (b"a/", b"b/", b"c/", b"w/", b"i/", b"o/", b"h/") + assert any(path.startswith(p) for p in valid_prefixes), f"Unexpected path prefix: {path[:10]!r}" path = path[2:] return path @@ -367,10 +370,12 @@ class Diff: """ # Precompiled regex. + # Note: The path prefixes support both default (a/b) and mnemonicPrefix mode + # which can use prefixes like c/ (commit), w/ (worktree), i/ (index), o/ (object), and h/ (HEAD) re_header = re.compile( rb""" ^diff[ ]--git - [ ](?P"?[ab]/.+?"?)[ ](?P"?[ab]/.+?"?)\n + [ ](?P"?[abciwoh]/.+?"?)[ ](?P"?[abciwoh]/.+?"?)\n (?:^old[ ]mode[ ](?P\d+)\n ^new[ ]mode[ ](?P\d+)(?:\n|$))? (?:^similarity[ ]index[ ]\d+%\n diff --git a/test/test_diff.py b/test/test_diff.py index 612fbd9e0..1c4d521da 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -281,6 +281,52 @@ def test_diff_unsafe_paths(self): self.assertEqual(res[13].a_path, 'a/"with-quotes"') self.assertEqual(res[13].b_path, 'b/"with even more quotes"') + def test_diff_mnemonic_prefix(self): + """Test that diff parsing works with mnemonicPrefix enabled. + + When diff.mnemonicPrefix=true is set in git config, git uses different + prefixes for diff paths: + - c/ for commit + - w/ for worktree + - i/ for index + - o/ for object + - h/ for HEAD + + This addresses issue #2013 where the regex only matched [ab]/ prefixes. + """ + # Test all mnemonicPrefix combinations + # Each tuple is (a_prefix, b_prefix) representing different comparison types + prefix_pairs = [ + (b"c/", b"w/"), # commit vs worktree + (b"c/", b"i/"), # commit vs index + (b"i/", b"w/"), # index vs worktree + (b"o/", b"w/"), # object vs worktree + (b"h/", b"i/"), # HEAD vs index + (b"h/", b"w/"), # HEAD vs worktree + ] + + for a_prefix, b_prefix in prefix_pairs: + with self.subTest(a_prefix=a_prefix, b_prefix=b_prefix): + diff_mnemonic = ( + b"diff --git " + a_prefix + b".vscode/launch.json " + b_prefix + b".vscode/launch.json\n" + b"index 1234567890abcdef1234567890abcdef12345678.." + b"abcdef1234567890abcdef1234567890abcdef12 100644\n" + b"--- " + a_prefix + b".vscode/launch.json\n" + b"+++ " + b_prefix + b".vscode/launch.json\n" + b"@@ -1,3 +1,3 @@\n" + b"-old content\n" + b"+new content\n" + ) + diff_proc = StringProcessAdapter(diff_mnemonic) + diffs = Diff._index_from_patch_format(self.rorepo, diff_proc) + + # Should parse successfully (previously would fail or return empty) + self.assertEqual(len(diffs), 1) + diff = diffs[0] + # The path should be extracted correctly (without the prefix) + self.assertEqual(diff.a_path, ".vscode/launch.json") + self.assertEqual(diff.b_path, ".vscode/launch.json") + def test_diff_patch_format(self): # Test all of the 'old' format diffs for completeness - it should at least be # able to deal with it.