Skip to content

Commit 4fcb053

Browse files
committed
t3454: cover merge-replay scenarios with the historian helper
Add a dedicated test script for `git history reword` (and `git replay` via the same code path) across 2-parent merges, using the `test-tool historian` fixture builder so each scenario reads as a small declarative recipe rather than a sequence of plumbing commands. The script exercises the cases that motivated the merge-replay work: * a clean merge where each side touches unrelated files; * a non-trivial merge where the same line was changed on both sides and the user resolved by hand (textual manual resolution must be preserved through the replay); * a non-trivial merge where the user also touched a line outside any conflict region (a "semantic" edit must also be preserved through the replay); * an octopus merge in the rewrite path, which is rejected; * a function rename across the merge with a brand-new caller introduced by the rewritten parents. The pre-existing caller that the user manually renamed in the original merge must keep its rename, and the brand-new caller must _not_ be rewritten (calvin/hobbes naming chosen for legibility). This second part is the documented limitation: the replay propagates the textual diffs the user actually made, it does not extrapolate symbol-level intent. Symbol-aware refactoring is out of scope, just as it is for plain rebase. The fixture builder lets each scenario sit in roughly a dozen lines of historian directives plus the assertions, which keeps the test file readable when more scenarios are added later. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
1 parent e5b71bf commit 4fcb053

1 file changed

Lines changed: 307 additions & 0 deletions

File tree

t/t3454-history-merges.sh

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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

Comments
 (0)