|
| 1 | +#!/bin/sh |
| 2 | + |
| 3 | +test_description='git history reword across merge commits |
| 4 | +
|
| 5 | +Exercises the merge-replay path in `git history reword` using the |
| 6 | +`test-tool historian` test fixture builder so each scenario is |
| 7 | +described in a small declarative input rather than a sprawling |
| 8 | +sequence of plumbing commands. The interesting cases are: |
| 9 | +
|
| 10 | + * a clean merge with each side touching unrelated files; |
| 11 | + * a non-trivial merge whose conflicting line was resolved by hand |
| 12 | + (textually) and whose resolution must be preserved through the |
| 13 | + replay; |
| 14 | + * a non-trivial merge with a manual *semantic* edit (an additional |
| 15 | + change outside the conflict region) that must also be preserved. |
| 16 | +' |
| 17 | + |
| 18 | +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main |
| 19 | +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME |
| 20 | + |
| 21 | +. ./test-lib.sh |
| 22 | + |
| 23 | +# Replace the commit's message via a fake editor and run reword. |
| 24 | +reword_to () { |
| 25 | + new_msg="$1" |
| 26 | + target="$2" |
| 27 | + write_script fake-editor.sh <<-EOF && |
| 28 | + echo "$new_msg" >"\$1" |
| 29 | + EOF |
| 30 | + test_set_editor "$(pwd)/fake-editor.sh" && |
| 31 | + git history reword "$target" && |
| 32 | + rm fake-editor.sh |
| 33 | +} |
| 34 | + |
| 35 | +build_clean_merge () { |
| 36 | + test-tool historian <<-\EOF |
| 37 | + # Setup: |
| 38 | + # A (a) --- C (a, h) ----+--- M (a, g, h) |
| 39 | + # \ / |
| 40 | + # +-- B (a, g) ------+ |
| 41 | + # |
| 42 | + # Topic touches `g` only; main touches `h` only. The auto-merge |
| 43 | + # at M is clean. |
| 44 | + blob a "shared content" |
| 45 | + blob g guarded |
| 46 | + blob h host |
| 47 | + commit A main "A" a=a |
| 48 | + commit B topic "B (introduces g)" from=A a=a g=g |
| 49 | + commit C main "C (introduces h)" a=a h=h |
| 50 | + commit M main "Merge topic" merge=B a=a g=g h=h |
| 51 | + EOF |
| 52 | +} |
| 53 | + |
| 54 | +test_expect_success 'clean merge: both sides touch unrelated files' ' |
| 55 | + test_when_finished "rm -rf repo" && |
| 56 | + git init repo && |
| 57 | + ( |
| 58 | + cd repo && |
| 59 | + build_clean_merge && |
| 60 | +
|
| 61 | + reword_to "AA" A && |
| 62 | +
|
| 63 | + # The merge is still a 2-parent merge with the same subject |
| 64 | + # and tree (clean replay leaves content unchanged). |
| 65 | + test_cmp_rev HEAD^{tree} M^{tree} && |
| 66 | +
|
| 67 | + echo "Merge topic" >expect-subject && |
| 68 | + git log -1 --format=%s HEAD >subject && |
| 69 | + test_cmp expect-subject subject && |
| 70 | +
|
| 71 | + git rev-list --merges HEAD~..HEAD >merges && |
| 72 | + test_line_count = 1 merges |
| 73 | + ) |
| 74 | +' |
| 75 | + |
| 76 | +build_textual_resolution () { |
| 77 | + test-tool historian <<-\EOF |
| 78 | + # Both sides change the same line of `a`; the user resolved with |
| 79 | + # their own combined text, recorded directly as the merge tree. |
| 80 | + blob a_v1 line1 line2 line3 |
| 81 | + blob a_main line1 line2-main line3 |
| 82 | + blob a_topic line1 line2-topic line3 |
| 83 | + blob a_resolution line1 line2-merged-by-hand line3 |
| 84 | + commit A main "A" a=a_v1 |
| 85 | + commit B topic "B (line2 on topic)" from=A a=a_topic |
| 86 | + commit C main "C (line2 on main)" a=a_main |
| 87 | + commit M main "Merge topic" merge=B a=a_resolution |
| 88 | + EOF |
| 89 | +} |
| 90 | + |
| 91 | +test_expect_success 'non-trivial merge: textual manual resolution is preserved' ' |
| 92 | + test_when_finished "rm -rf repo" && |
| 93 | + git init repo && |
| 94 | + ( |
| 95 | + cd repo && |
| 96 | + build_textual_resolution && |
| 97 | +
|
| 98 | + reword_to "AA" A && |
| 99 | +
|
| 100 | + git show HEAD:a >after && |
| 101 | + test_write_lines line1 line2-merged-by-hand line3 >expect && |
| 102 | + test_cmp expect after |
| 103 | + ) |
| 104 | +' |
| 105 | + |
| 106 | +build_semantic_edit () { |
| 107 | + test-tool historian <<-\EOF |
| 108 | + # Topic and main conflict on line2 of `a`. The user's resolution |
| 109 | + # at M not only picks combined text on line2 but ALSO touches |
| 110 | + # line5 (a "semantic" edit outside any conflict region) -- this |
| 111 | + # kind of edit is invisible to a naive pick-one-side strategy and |
| 112 | + # must be preserved by replay. |
| 113 | + blob a_v1 line1 line2 line3 line4 line5 |
| 114 | + blob a_main line1 line2-main line3 line4 line5 |
| 115 | + blob a_topic line1 line2-topic line3 line4 line5 |
| 116 | + blob a_resolution line1 line2-merged line3 line4 line5-touched |
| 117 | + commit A main "A" a=a_v1 |
| 118 | + commit B topic "B (line2 on topic)" from=A a=a_topic |
| 119 | + commit C main "C (line2 on main)" a=a_main |
| 120 | + commit M main "Merge topic" merge=B a=a_resolution |
| 121 | + EOF |
| 122 | +} |
| 123 | + |
| 124 | +test_expect_success 'non-trivial merge: semantic edit outside conflict region is preserved' ' |
| 125 | + test_when_finished "rm -rf repo" && |
| 126 | + git init repo && |
| 127 | + ( |
| 128 | + cd repo && |
| 129 | + build_semantic_edit && |
| 130 | +
|
| 131 | + reword_to "AA" A && |
| 132 | +
|
| 133 | + git show HEAD:a >after && |
| 134 | + test_write_lines line1 line2-merged line3 line4 line5-touched \ |
| 135 | + >expect && |
| 136 | + test_cmp expect after |
| 137 | + ) |
| 138 | +' |
| 139 | + |
| 140 | +build_octopus () { |
| 141 | + test-tool historian <<-\EOF |
| 142 | + blob a "x" |
| 143 | + commit A main "A" a=a |
| 144 | + commit B b1 "B" from=A a=a |
| 145 | + commit C b2 "C" from=A a=a |
| 146 | + commit D b3 "D" from=A a=a |
| 147 | + commit O main "octopus" merge=B merge=C merge=D a=a |
| 148 | + EOF |
| 149 | +} |
| 150 | + |
| 151 | +test_expect_success 'octopus merge in the rewrite path is rejected' ' |
| 152 | + test_when_finished "rm -rf repo" && |
| 153 | + git init repo && |
| 154 | + ( |
| 155 | + cd repo && |
| 156 | + build_octopus && |
| 157 | +
|
| 158 | + test_must_fail git -c core.editor=true history reword \ |
| 159 | + --dry-run A 2>err && |
| 160 | + test_grep "octopus" err |
| 161 | + ) |
| 162 | +' |
| 163 | + |
| 164 | +build_off_spine_first_parent () { |
| 165 | + test-tool historian <<-\EOF |
| 166 | + # Setup an "evil merge" topology where the rewrite path crosses |
| 167 | + # a 2-parent merge whose _first_ parent sits off the rewrite |
| 168 | + # range: |
| 169 | + # |
| 170 | + # side -- O (a=v0) |
| 171 | + # \ |
| 172 | + # M (parent1=O, parent2=R, a=v0, s=top) |
| 173 | + # / |
| 174 | + # A (a=v0) -- R (a=v0) -- T (a=v0, s=top) |
| 175 | + # | |
| 176 | + # reword target |
| 177 | + # |
| 178 | + # The walk for `history reword A` excludes A and its ancestors, |
| 179 | + # so O is off-walk-not-BOTTOM. Replaying M correctly requires M's |
| 180 | + # first parent to remain at O (preserve, not replant). |
| 181 | + blob v0 line1 line2 line3 |
| 182 | + blob top "marker" |
| 183 | + commit X side "X" v0=v0 |
| 184 | + commit O side "O" v0=v0 |
| 185 | + commit A main "A" from=X v0=v0 |
| 186 | + commit R main "R" v0=v0 |
| 187 | + commit M main "Merge side into main" from=O merge=R v0=v0 s=top |
| 188 | + commit T main "T" v0=v0 s=top |
| 189 | + EOF |
| 190 | +} |
| 191 | + |
| 192 | +# A descendant merge whose first parent sits off the rewrite range |
| 193 | +# is a topology that any reasonable replay of merges has to handle |
| 194 | +# correctly: the off-walk parent must be preserved verbatim, while |
| 195 | +# the in-walk parent is rewritten. Without that, the replayed merge |
| 196 | +# would silently graft itself onto a different ancestry than the |
| 197 | +# author chose, which is far worse than a loud failure. |
| 198 | +test_expect_success 'merge whose first parent sits off the rewrite path keeps that parent' ' |
| 199 | + test_when_finished "rm -rf repo" && |
| 200 | + git init repo && |
| 201 | + ( |
| 202 | + cd repo && |
| 203 | + build_off_spine_first_parent && |
| 204 | +
|
| 205 | + reword_to "AA" A && |
| 206 | +
|
| 207 | + # The replayed M (now HEAD~) is still a 2-parent merge. |
| 208 | + # Its first parent is the original O (preserved, off-walk), |
| 209 | + # its second parent is the rewritten R (walked, in the |
| 210 | + # map). T was rebased on top of M, so HEAD = T. |
| 211 | + git rev-list --parents -1 HEAD~ >parents && |
| 212 | + new_p1=$(awk "{print \$2}" parents) && |
| 213 | + new_p2=$(awk "{print \$3}" parents) && |
| 214 | +
|
| 215 | + # First parent is preserved verbatim. |
| 216 | + test_cmp_rev O $new_p1 && |
| 217 | +
|
| 218 | + # Second parent is the rewritten R: a fresh commit whose |
| 219 | + # subject is still "R" but whose OID differs from the |
| 220 | + # original (because its parent A is now reworded). |
| 221 | + echo R >expect && |
| 222 | + git log -1 --format=%s $new_p2 >actual && |
| 223 | + test_cmp expect actual && |
| 224 | + ! test_cmp_rev R $new_p2 && |
| 225 | +
|
| 226 | + # T was rebased on top of the new M, and its tree still |
| 227 | + # contains the s=top marker introduced in the original M. |
| 228 | + echo "marker" >expect && |
| 229 | + git show HEAD:s >actual && |
| 230 | + test_cmp expect actual |
| 231 | + ) |
| 232 | +' |
| 233 | + |
| 234 | +build_function_rename () { |
| 235 | + test-tool historian <<-\EOF |
| 236 | + # Topic renames harry() -> hermione() (defs.h plus caller1). main |
| 237 | + # adds caller2 calling harry(); the original merge M manually |
| 238 | + # renames caller2 to hermione(). The "newer" base on a side branch |
| 239 | + # contains caller2 AND a brand-new caller3 calling harry(); |
| 240 | + # replaying onto `newer` therefore introduces caller3 into the |
| 241 | + # merged tree. |
| 242 | + blob defs_harry "void harry(void);" |
| 243 | + blob defs_hermione "void hermione(void);" |
| 244 | + blob harry_call "harry();" |
| 245 | + blob hermione_call "hermione();" |
| 246 | + commit A main "A" defs.h=defs_harry caller1=harry_call |
| 247 | + commit B topic "B (rename)" from=A defs.h=defs_hermione caller1=hermione_call |
| 248 | + commit C main "C (caller2 calls harry)" defs.h=defs_harry caller1=harry_call caller2=harry_call |
| 249 | + commit M main "Merge topic" merge=B defs.h=defs_hermione caller1=hermione_call caller2=hermione_call |
| 250 | + commit NEW newer "newer base with caller3" from=A defs.h=defs_harry caller1=harry_call caller2=harry_call caller3=harry_call |
| 251 | + EOF |
| 252 | +} |
| 253 | + |
| 254 | +# This case checks two things at once. First, the manual semantic |
| 255 | +# edit in M (renaming caller2) must be preserved when we replay onto |
| 256 | +# a different base; that is the case `git history` and `git replay` |
| 257 | +# need to handle correctly, even though nothing in the conflict |
| 258 | +# markers tells us about it. Second, a file that only enters the |
| 259 | +# tree via the rewritten parents (caller3, present on the `newer` |
| 260 | +# base) is _not_ renamed by the replay. The replay propagates the |
| 261 | +# textual diffs the user actually made in M; it does _not_ infer |
| 262 | +# the user's symbol-level intent ("rename every caller of harry"). |
| 263 | +# This is a known and intentional limitation. Symbol-aware |
| 264 | +# refactoring is out of scope here, just as it is for plain rebase. |
| 265 | +test_expect_success 'preserves manual rename of pre-existing caller; does not extrapolate to new files' ' |
| 266 | + test_when_finished "rm -rf repo" && |
| 267 | + git init repo && |
| 268 | + ( |
| 269 | + cd repo && |
| 270 | + build_function_rename && |
| 271 | +
|
| 272 | + # Replay (C, B, M) onto the newer base. A `main..M` style |
| 273 | + # range across two unrelated branches is awkward; spin up a |
| 274 | + # temp branch as the spine and use --advance. |
| 275 | + git branch tmp main && |
| 276 | + git replay --ref-action=print --onto NEW A..tmp >result && |
| 277 | + new_tip=$(cut -f 3 -d " " result) && |
| 278 | +
|
| 279 | + # defs.h and caller1 came from B (clean cherry-pick of the |
| 280 | + # rename commit) and must reflect the rename. |
| 281 | + echo "void hermione(void);" >expect && |
| 282 | + git show $new_tip:defs.h >actual && |
| 283 | + test_cmp expect actual && |
| 284 | +
|
| 285 | + echo "hermione();" >expect && |
| 286 | + git show $new_tip:caller1 >actual && |
| 287 | + test_cmp expect actual && |
| 288 | +
|
| 289 | + # caller2 existed in the original M; its manual rename to |
| 290 | + # hermione() is the semantic edit the replay must preserve. |
| 291 | + echo "hermione();" >expect && |
| 292 | + git show $new_tip:caller2 >actual && |
| 293 | + test_cmp expect actual && |
| 294 | +
|
| 295 | + # caller3 only exists on the newer base, so it was brought |
| 296 | + # in by N (the auto-merge of the rewritten parents). The |
| 297 | + # replay has no way to know the user intended to rename |
| 298 | + # every caller; caller3 keeps harry(). The resulting tree |
| 299 | + # is therefore _not_ symbol-correct and needs a follow-up |
| 300 | + # edit. This is the documented limitation. |
| 301 | + echo "harry();" >expect && |
| 302 | + git show $new_tip:caller3 >actual && |
| 303 | + test_cmp expect actual |
| 304 | + ) |
| 305 | +' |
| 306 | + |
| 307 | +test_done |
0 commit comments