Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions git/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<a_path_fallback>"?[ab]/.+?"?)[ ](?P<b_path_fallback>"?[ab]/.+?"?)\n
[ ](?P<a_path_fallback>"?[abciwoh]/.+?"?)[ ](?P<b_path_fallback>"?[abciwoh]/.+?"?)\n
(?:^old[ ]mode[ ](?P<old_mode>\d+)\n
^new[ ]mode[ ](?P<new_mode>\d+)(?:\n|$))?
(?:^similarity[ ]index[ ]\d+%\n
Expand Down
46 changes: 46 additions & 0 deletions test/test_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring is missing the h/ prefix for HEAD. According to the PR description and Git documentation, h/ is also a valid mnemonicPrefix. The comment should list all supported prefixes: c/ (commit), w/ (worktree), i/ (index), o/ (object), and h/ (HEAD).

Suggested change
- o/ for object
- o/ for object
- h/ for HEAD

Copilot uses AI. Check for mistakes.
- 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.
Expand Down
Loading