Skip to content

Commit 22f873c

Browse files
authored
Merge pull request #2295 from Keerthana-64/diff-patch-header-paths
fix(diff): read patch header paths the way git apply does
2 parents 9d3aff0 + de50b14 commit 22f873c

2 files changed

Lines changed: 147 additions & 18 deletions

File tree

‎git/diff.py‎

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,28 @@
99
import re
1010
import warnings
1111

12-
from git.cmd import Git, handle_process_output
13-
from git.compat import defenc
14-
from git.objects.base import IndexObject
15-
from git.objects.blob import Blob
16-
from git.objects.util import mode_str_to_int
17-
from git.util import finalize_process, hex_to_bin
18-
1912
# typing ------------------------------------------------------------------
20-
2113
from typing import (
14+
TYPE_CHECKING,
2215
Any,
2316
Iterator,
2417
List,
2518
Match,
2619
Optional,
2720
Sequence,
2821
Tuple,
29-
TYPE_CHECKING,
3022
TypeVar,
3123
Union,
3224
cast,
3325
)
34-
from git.types import PathLike, Literal
26+
27+
from git.cmd import Git, handle_process_output
28+
from git.compat import defenc
29+
from git.objects.base import IndexObject
30+
from git.objects.blob import Blob
31+
from git.objects.util import mode_str_to_int
32+
from git.types import Literal, PathLike
33+
from git.util import finalize_process, hex_to_bin
3534

3635
if TYPE_CHECKING:
3736
from subprocess import Popen
@@ -410,7 +409,8 @@ class Diff:
410409
re_header = re.compile(
411410
rb"""
412411
^diff[ ]--git
413-
[ ](?P<a_path_fallback>"?[ab]/.+?"?)[ ](?P<b_path_fallback>"?[ab]/.+?"?)\n
412+
[ ](?P<header_names>(?P<a_path_fallback>"?[ab]/.+?"?)
413+
[ ](?P<b_path_fallback>"?[ab]/.+?"?))\n
414414
(?:^old[ ]mode[ ](?P<old_mode>\d+)\n
415415
^new[ ]mode[ ](?P<new_mode>\d+)(?:\n|$))?
416416
(?:^similarity[ ]index[ ]\d+%\n
@@ -419,7 +419,7 @@ class Diff:
419419
(?:^new[ ]file[ ]mode[ ](?P<new_file_mode>.+)(?:\n|$))?
420420
(?:^deleted[ ]file[ ]mode[ ](?P<deleted_file_mode>.+)(?:\n|$))?
421421
(?:^similarity[ ]index[ ]\d+%\n
422-
^copy[ ]from[ ].*\n
422+
^copy[ ]from[ ](?P<copied_from_name>.*)\n
423423
^copy[ ]to[ ](?P<copied_file_name>.*)(?:\n|$))?
424424
(?:^index[ ](?P<a_blob_id>[0-9A-Fa-f]+)
425425
\.\.(?P<b_blob_id>[0-9A-Fa-f]+)[ ]?(?P<b_mode>.+)?(?:\n|$))?
@@ -626,6 +626,42 @@ def _pick_best_path(cls, path_match: bytes, rename_match: bytes, path_fallback_m
626626

627627
return None
628628

629+
@staticmethod
630+
def _same_path_in_header(names: bytes) -> Optional[bytes]:
631+
"""Return the path that a ``diff --git a/<path> b/<path>`` line names twice.
632+
633+
git separates the two names with a space but does not quote a path for
634+
containing one, so once the path contains ``" a/"`` or ``" b/"`` the line alone
635+
does not show where the first name ends. When no ``---``, ``+++``, rename or
636+
copy line names the file, both names are the same path, and the split is
637+
accepted only where the two halves agree, as ``git_header_name`` in git's
638+
``apply.c`` does. A quoted first name ends at its closing quote.
639+
640+
:return:
641+
The path without its ``a/`` prefix, or ``None`` if no split agrees.
642+
"""
643+
if names.startswith(b'"'):
644+
end = 1
645+
while end < len(names) and names[end : end + 1] != b'"':
646+
end += 2 if names[end : end + 1] == b"\\" else 1
647+
split = end + 1
648+
else:
649+
# Equal unquoted paths have equal-length names, so only the midpoint
650+
# can separate them. Trying every space would copy the header quadratically.
651+
if len(names) % 2 == 0:
652+
return None
653+
split = len(names) // 2
654+
if names[split : split + 1] != b" ":
655+
return None
656+
a_name, b_name = names[:split], names[split + 1 :]
657+
if a_name.startswith(b'"'):
658+
if not (a_name.endswith(b'"') and b_name.startswith(b'"') and b_name.endswith(b'"')):
659+
return None
660+
a_name, b_name = _unquote_path(a_name[1:-1]), _unquote_path(b_name[1:-1])
661+
if a_name.startswith(b"a/") and b_name.startswith(b"b/") and a_name[2:] == b_name[2:]:
662+
return a_name[2:]
663+
return None
664+
629665
@classmethod
630666
def _index_from_patch_format(cls, repo: "Repo", proc: Union["Popen", "Git.AutoInterrupt"]) -> DiffIndex["Diff"]:
631667
"""Create a new :class:`DiffIndex` from the given process output which must be
@@ -662,6 +698,7 @@ def finalize_process_with_stderr(proc: Union["Popen", "Git.AutoInterrupt"]) -> N
662698
a_mode, b_mode = None, None # For mypy.
663699
for _header in cls.re_header.finditer(text):
664700
(
701+
header_names,
665702
a_path_fallback,
666703
b_path_fallback,
667704
old_mode,
@@ -670,6 +707,7 @@ def finalize_process_with_stderr(proc: Union["Popen", "Git.AutoInterrupt"]) -> N
670707
rename_to,
671708
new_file_mode,
672709
deleted_file_mode,
710+
copied_from_name,
673711
copied_file_name,
674712
a_blob_id,
675713
b_blob_id,
@@ -684,8 +722,15 @@ def finalize_process_with_stderr(proc: Union["Popen", "Git.AutoInterrupt"]) -> N
684722
bool(copied_file_name),
685723
)
686724

687-
a_path = cls._pick_best_path(a_path, rename_from, a_path_fallback)
688-
b_path = cls._pick_best_path(b_path, rename_to, b_path_fallback)
725+
# Mode changes, binary files and empty files have no `---` and `+++` lines,
726+
# so only the `diff --git` line names them, and it names the same path twice.
727+
named_elsewhere = any((a_path, b_path, rename_from, rename_to, copied_from_name, copied_file_name))
728+
same_path = None if named_elsewhere else cls._same_path_in_header(header_names)
729+
if same_path is None:
730+
a_path = cls._pick_best_path(a_path, rename_from or copied_from_name, a_path_fallback)
731+
b_path = cls._pick_best_path(b_path, rename_to or copied_file_name, b_path_fallback)
732+
else:
733+
a_path = b_path = same_path
689734

690735
# Our only means to find the actual text is to see what has not been matched
691736
# by our regex, and then retro-actively assign it to our index.

‎test/test_diff.py‎

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,21 @@
44
# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
55

66
import gc
7-
from io import BytesIO
87
import os.path as osp
98
import shutil
109
import sys
1110
import tempfile
11+
from io import BytesIO
1212

1313
import ddt
1414
import pytest
15-
1615
from gitdb.base import IStream
1716

18-
from git import NULL_TREE, Blob, Diff, DiffIndex, Diffable, GitCommandError, Repo, Submodule
17+
from git import NULL_TREE, Blob, Diff, Diffable, DiffIndex, GitCommandError, Repo, Submodule
1918
from git.cmd import Git
2019
from git.diff import decode_path
2120
from git.exc import UnsafeOptionError
2221
from git.index.typ import BaseIndexEntry
23-
2422
from test.lib import StringProcessAdapter, TestBase, fixture, with_rw_directory
2523

2624

@@ -270,6 +268,92 @@ def test_diff_raw_paths_with_colon_or_line_feed(self, rw_dir):
270268
self.assertEqual([d.b_path for d in diffs], sorted(paths))
271269
self.assertEqual({d.change_type for d in diffs}, {"A"})
272270

271+
@with_rw_directory
272+
def test_diff_patch_paths_named_only_by_the_header_line(self, rw_dir):
273+
# Binary files, empty files, mode changes and copies have no `---` and `+++`
274+
# lines, so their paths come from the `diff --git a/<path> b/<path>` line, where
275+
# git does not quote a space. Each path contains " a/" or " b/" so that the line
276+
# alone does not show where the first name ends.
277+
repo = Repo.init(rw_dir)
278+
binary = repo.odb.store(IStream(Blob.type, 1, BytesIO(b"\0"))).binsha
279+
empty = repo.odb.store(IStream(Blob.type, 0, BytesIO(b""))).binsha
280+
paths = ["assets b/logo.png", "docs a/empty", "quoted\t a/tab"]
281+
repo.index.add(
282+
[
283+
BaseIndexEntry((0o100644, binary, 0, paths[0])),
284+
BaseIndexEntry((0o100644, empty, 0, paths[1])),
285+
BaseIndexEntry((0o100644, binary, 0, paths[2])),
286+
]
287+
)
288+
commit = repo.index.commit("paths")
289+
290+
diffs = commit.diff(NULL_TREE, create_patch=True)
291+
self.assertEqual({(d.a_path, d.b_path) for d in diffs}, {(path, path) for path in paths})
292+
293+
repo.index.add(
294+
[
295+
BaseIndexEntry((0o100755, binary, 0, paths[0])),
296+
BaseIndexEntry((0o100644, binary, 0, "copy b/logo.png")),
297+
]
298+
)
299+
changed = repo.index.commit("mode change and copy")
300+
301+
diffs = commit.diff(changed, create_patch=True, C=True, find_copies_harder=True)
302+
self.assertEqual(
303+
{(d.a_path, d.b_path, d.copied_file) for d in diffs},
304+
{(paths[0], paths[0], False), (paths[0], "copy b/logo.png", True)},
305+
)
306+
307+
@ddt.data(
308+
(b"a/x b/x", b"x"),
309+
(b"a/x b/x ", b"x "),
310+
(b"a/x b/y", None),
311+
(b"a/x b/xx", None),
312+
(b"a/x-b/x", None),
313+
(b"", None),
314+
(b'"a/x\\t" "b/x\\t"', b"x\t"),
315+
(b'"a/x\\t" "b/x\\011"', b"x\t"),
316+
(b'"a/x\\t" "b/y\\t"', None),
317+
(b'"a/x\\t" "b/x\\t', None),
318+
)
319+
def test_same_path_in_header(self, case):
320+
names, expected = case
321+
self.assertEqual(Diff._same_path_in_header(names), expected)
322+
323+
def test_same_path_in_header_copies_linear_bytes(self):
324+
class SliceCountingBytes(bytes):
325+
sliced_bytes = 0
326+
327+
def __getitem__(self, key):
328+
result = super().__getitem__(key)
329+
if isinstance(key, slice):
330+
self.sliced_bytes += len(result)
331+
return result
332+
333+
path = b"x " * 1000
334+
names = SliceCountingBytes(b"a/" + path + b" b/" + path)
335+
self.assertEqual(Diff._same_path_in_header(names), path)
336+
# Count copied bytes rather than asserting a machine-dependent time limit.
337+
self.assertLessEqual(names.sliced_bytes, 2 * len(names))
338+
339+
@with_rw_directory
340+
def test_diff_patch_mode_only_with_long_space_heavy_path(self, rw_dir):
341+
repo = Repo.init(rw_dir)
342+
path = b"x " * 250000
343+
blob = repo.odb.store(IStream(Blob.type, 0, BytesIO(b""))).binsha
344+
trees = []
345+
# Store trees directly: Git accepts paths longer than filesystem or index limits.
346+
for mode in (b"100644", b"100755"):
347+
data = mode + b" " + path + b"\0" + blob
348+
tree = repo.odb.store(IStream(b"tree", len(data), BytesIO(data)))
349+
trees.append(repo.tree(tree.binsha.hex()))
350+
351+
diffs = trees[0].diff(trees[1], create_patch=True)
352+
self.assertEqual(len(diffs), 1)
353+
self.assertEqual((diffs[0].a_rawpath, diffs[0].b_rawpath), (path, path))
354+
self.assertEqual((diffs[0].a_mode, diffs[0].b_mode), (0o100644, 0o100755))
355+
self.assertEqual(diffs[0].diff, b"")
356+
273357
def test_empty_diff(self):
274358
res = []
275359
Diff._handle_diff_line(b"", None, res)

0 commit comments

Comments
 (0)