Skip to content

Commit e39a341

Browse files
ychingitster
authored andcommitted
xdiff: re-diff shifted change groups when using histogram algorithm
After a diff algorithm has been run, the compaction phase (xdl_change_compact()) shifts and merges change groups to produce a cleaner output. However, this shifting could create a new matched group where both sides now have matching lines. This results in a wrong-looking diff output which contains redundant lines that are the same on both files. Fix this by detecting this situation, and re-diff the texts on each side to find similar lines, using the fall-back Myer's diff. Only do this for histogram diff as it's the only algorithm where this is relevant. Below contains an example, and more details. For an example, consider two files below: file1: A A A A A A A file2: A A x A A A A When using Myer's diff, the algorithm finds that only the "x" has been changed, and produces a final diff result (these are line diffs, but using word-diff syntax for ease of presentation): A A[-A-]{+x+}A AAA When using histogram diff, the algorithm first discovers the LCS "A AAA", which it uses as anchor, then produces an intermediate diff: {+A Ax+}A AAA[- AAA-]. This is a longer diff than Myer's, but it's still self-consistent. However, the compaction phase attempts to shift the first file's diff group upwards (note that this shift crosses the anchor that histogram had used), leading to the final results for histogram diff: [-A AA-]{+A Ax+}A AAA This is a technically correct patch but looks clearly redundant to a human as the first 3 lines should not be in the diff. The fix would detect that a shift has caused matching to a new group, and re-diff the "A AA" and "A Ax" parts, which results in "A A" correctly re-marked as unchanged. This creates the now correct histogram diff: A A[-A-]{+x+}A AAA This issue is not applicable to Myer's diff algorithm as it already generates a minimal diff, which means a shift cannot result in a smaller diff output (the default Myer's diff in xdiff is not guaranteed to be minimal for performance reasons, but it typically does a good enough job). It's also not applicable to patience diff, because it uses only unique lines as anchor for its splits, and falls back to Myer's diff within each split. Shifting requires both ends having the same lines, and therefore cannot cross the unique line boundaries established by the patience algorithm. In contrast histogram diff uses non-unique lines as anchors, and therefore shifting can cross over them. This issue is rare in a normal repository. Below is a table of repositories (`git log --no-merges -p --histogram -1000`), showing how many times a re-diff was done and how many times it resulted in finding matching lines (therefore addressing this issue) with the fix. In general it is fewer than 1% of diff's that exhibit this offending behavior: | Repo (1k commits) | Re-diff | Found matching lines | |--------------------|---------|----------------------| | llvm-project | 45 | 11 | | vim | 110 | 9 | | git | 18 | 2 | | WebKit | 168 | 1 | | ripgrep | 22 | 1 | | cpython | 32 | 0 | | vscode | 13 | 0 | Signed-off-by: Yee Cheng Chin <ychin.git@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>
1 parent 9a2fb14 commit e39a341

File tree

3 files changed

+181
-0
lines changed

3 files changed

+181
-0
lines changed

t/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,7 @@ integration_tests = [
496496
't4070-diff-pairs.sh',
497497
't4071-diff-minimal.sh',
498498
't4072-diff-max-depth.sh',
499+
't4073-diff-shifted-matched-group.sh',
499500
't4100-apply-stat.sh',
500501
't4101-apply-nonl.sh',
501502
't4102-apply-rename.sh',
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/bin/sh
2+
3+
test_description='shifted diff groups re-diffing during histogram diff'
4+
5+
. ./test-lib.sh
6+
7+
test_expect_success 'shifted diff group should re-diff to minimize patch' '
8+
test_write_lines A x A A A x A A A >file1 &&
9+
test_write_lines A x A Z A x A A A >file2 &&
10+
11+
file1_h=$(git rev-parse --short $(git hash-object file1)) &&
12+
file2_h=$(git rev-parse --short $(git hash-object file2)) &&
13+
14+
cat >expect <<-EOF &&
15+
diff --git a/file1 b/file2
16+
index $file1_h..$file2_h 100644
17+
--- a/file1
18+
+++ b/file2
19+
@@ -1,7 +1,7 @@
20+
A
21+
x
22+
A
23+
-A
24+
+Z
25+
A
26+
x
27+
A
28+
EOF
29+
30+
test_expect_code 1 git diff --no-index --histogram file1 file2 >output &&
31+
test_cmp expect output
32+
'
33+
34+
test_expect_success 're-diff should preserve diff flags' '
35+
test_write_lines a b c a b c >file1 &&
36+
test_write_lines x " b" z a b c >file2 &&
37+
38+
file1_h=$(git rev-parse --short $(git hash-object file1)) &&
39+
file2_h=$(git rev-parse --short $(git hash-object file2)) &&
40+
41+
cat >expect <<-EOF &&
42+
diff --git a/file1 b/file2
43+
index $file1_h..$file2_h 100644
44+
--- a/file1
45+
+++ b/file2
46+
@@ -1,6 +1,6 @@
47+
-a
48+
-b
49+
-c
50+
+x
51+
+ b
52+
+z
53+
a
54+
b
55+
c
56+
EOF
57+
58+
test_expect_code 1 git diff --no-index --histogram file1 file2 >output &&
59+
test_cmp expect output &&
60+
61+
cat >expect_iwhite <<-EOF &&
62+
diff --git a/file1 b/file2
63+
index $file1_h..$file2_h 100644
64+
--- a/file1
65+
+++ b/file2
66+
@@ -1,6 +1,6 @@
67+
-a
68+
+x
69+
b
70+
-c
71+
+z
72+
a
73+
b
74+
c
75+
EOF
76+
77+
test_expect_code 1 git diff --no-index --histogram --ignore-all-space file1 file2 >output_iwhite &&
78+
test_cmp expect_iwhite output_iwhite
79+
'
80+
81+
test_expect_success 'shifting on either side should trigger re-diff properly' '
82+
test_write_lines a b c a b c a b c >file1 &&
83+
test_write_lines a b c a1 a2 a3 b c1 a b c >file2 &&
84+
85+
file1_h=$(git rev-parse --short $(git hash-object file1)) &&
86+
file2_h=$(git rev-parse --short $(git hash-object file2)) &&
87+
88+
cat >expect1 <<-EOF &&
89+
diff --git a/file1 b/file2
90+
index $file1_h..$file2_h 100644
91+
--- a/file1
92+
+++ b/file2
93+
@@ -1,9 +1,11 @@
94+
a
95+
b
96+
c
97+
-a
98+
+a1
99+
+a2
100+
+a3
101+
b
102+
-c
103+
+c1
104+
a
105+
b
106+
c
107+
EOF
108+
109+
test_expect_code 1 git diff --no-index --histogram file1 file2 >output1 &&
110+
test_cmp expect1 output1 &&
111+
112+
cat >expect2 <<-EOF &&
113+
diff --git a/file2 b/file1
114+
index $file2_h..$file1_h 100644
115+
--- a/file2
116+
+++ b/file1
117+
@@ -1,11 +1,9 @@
118+
a
119+
b
120+
c
121+
-a1
122+
-a2
123+
-a3
124+
+a
125+
b
126+
-c1
127+
+c
128+
a
129+
b
130+
c
131+
EOF
132+
133+
test_expect_code 1 git diff --no-index --histogram file2 file1 >output2 &&
134+
test_cmp expect2 output2
135+
'
136+
137+
test_done

xdiff/xdiffi.c

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,7 @@ static int group_slide_up(xdfile_t *xdf, struct xdlgroup *g)
793793
*/
794794
int xdl_change_compact(xdfile_t *xdf, xdfile_t *xdfo, long flags) {
795795
struct xdlgroup g, go;
796+
struct xdlgroup g_orig, go_orig;
796797
long earliest_end, end_matching_other;
797798
long groupsize;
798799

@@ -806,6 +807,9 @@ int xdl_change_compact(xdfile_t *xdf, xdfile_t *xdfo, long flags) {
806807
if (g.end == g.start)
807808
goto next;
808809

810+
g_orig = g;
811+
go_orig = go;
812+
809813
/*
810814
* Now shift the change up and then down as far as possible in
811815
* each direction. If it bumps into any other changes, merge
@@ -915,6 +919,45 @@ int xdl_change_compact(xdfile_t *xdf, xdfile_t *xdfo, long flags) {
915919
}
916920
}
917921

922+
/*
923+
* If this has a matching group from the other file, it could
924+
* either be the original match from the diff algorithm, or
925+
* arrived at by shifting and joining groups. When it's the
926+
* latter, it's possible for the two newly joined sides to have
927+
* matching lines. Re-diff the group to mark these matching
928+
* lines as unchanged and remove from the diff output.
929+
*
930+
* Only do this for histogram diff as its LCS algorithm makes
931+
* this scenario possible. In contrast, patience diff finds LCS
932+
* of unique lines that groups cannot be shifted across.
933+
* Myer's diff (standalone or used as fall-back in patience
934+
* diff) already finds minimal edits so it is not possible for
935+
* shifted groups to result in a smaller diff. (Without
936+
* XDF_NEED_MINIMAL, Myer's isn't technically guaranteed to be
937+
* minimal, but it should be so most of the time)
938+
*/
939+
if (end_matching_other != -1 &&
940+
XDF_DIFF_ALG(flags) == XDF_HISTOGRAM_DIFF &&
941+
(g.start != g_orig.start ||
942+
g.end != g_orig.end ||
943+
go.start != go_orig.start ||
944+
go.end != go_orig.end)) {
945+
xpparam_t xpp;
946+
xdfenv_t xe;
947+
948+
memset(&xpp, 0, sizeof(xpp));
949+
xpp.flags = flags & ~XDF_DIFF_ALGORITHM_MASK;
950+
951+
memcpy(&xe.xdf1, xdf, sizeof(xdfile_t));
952+
memcpy(&xe.xdf2, xdfo, sizeof(xdfile_t));
953+
954+
if (xdl_fall_back_diff(&xe, &xpp,
955+
g.start + 1, g.end - g.start,
956+
go.start + 1, go.end - go.start)) {
957+
return -1;
958+
}
959+
}
960+
918961
next:
919962
/* Move past the just-processed group: */
920963
if (group_next(xdf, &g))

0 commit comments

Comments
 (0)