@@ -140,3 +140,153 @@ func TestCascadeReturnsToOriginalBranch(t *testing.T) {
140140 env .AssertAncestor ("feature-a" , "feature-b" )
141141 env .AssertAncestor ("feature-b" , "feature-c" )
142142}
143+
144+ func TestCascadeStaleForkPointFromManualRebase (t * testing.T ) {
145+ // Reproduces the bug where a manual rebase outside gh-stack leaves the
146+ // fork point stale. On the next restack after main advances, the stale
147+ // fork point would trigger an --onto rebase that replays too many commits.
148+ env := NewTestEnv (t )
149+ env .MustRun ("init" )
150+
151+ env .MustRun ("create" , "feature-a" )
152+ env .CreateCommit ("feature a work" )
153+
154+ // Advance main
155+ env .Git ("checkout" , "main" )
156+ env .CreateCommit ("main update 1" )
157+
158+ // Manually rebase (outside gh-stack) -- fork point stays stale
159+ env .Git ("checkout" , "feature-a" )
160+ env .Git ("rebase" , "main" )
161+
162+ // Run restack while already up-to-date so fork point gets refreshed
163+ env .MustRun ("restack" )
164+
165+ // Advance main again
166+ env .Git ("checkout" , "main" )
167+ env .CreateCommit ("main update 2" )
168+
169+ // This restack should use a simple rebase (not --onto with stale fork point)
170+ env .Git ("checkout" , "feature-a" )
171+ result := env .MustRun ("restack" )
172+
173+ // Should NOT say "using fork point" -- that would mean the stale fork
174+ // point incorrectly triggered the --onto path
175+ if result .ContainsStdout ("using fork point" ) {
176+ t .Error ("restack should use simple rebase, not --onto with stale fork point" )
177+ }
178+
179+ env .AssertAncestor ("main" , "feature-a" )
180+ env .AssertNoRebaseInProgress ()
181+ }
182+
183+ func TestCascadeStaleForkPointDetectedDuringRebase (t * testing.T ) {
184+ // Even if the "already up to date" refresh was missed, the ancestor check
185+ // in the useOnto logic should prevent --onto with a stale fork point.
186+ env := NewTestEnv (t )
187+ env .MustRun ("init" )
188+
189+ env .MustRun ("create" , "feature-a" )
190+ env .CreateCommit ("feature a work" )
191+
192+ // Record fork point before any manipulation
193+ originalFP := env .GetStackConfig ("branch.feature-a.stackforkpoint" )
194+ if originalFP == "" {
195+ t .Fatal ("expected fork point to be set after create" )
196+ }
197+
198+ // Advance main
199+ env .Git ("checkout" , "main" )
200+ env .CreateCommit ("main advance 1" )
201+ env .CreateCommit ("main advance 2" )
202+
203+ // Manually rebase feature-a onto current main
204+ env .Git ("checkout" , "feature-a" )
205+ env .Git ("rebase" , "main" )
206+
207+ // Fork point is still the old value (stale)
208+ fpAfterManual := env .GetStackConfig ("branch.feature-a.stackforkpoint" )
209+ if fpAfterManual != originalFP {
210+ t .Fatal ("expected fork point to still be the original (stale) value" )
211+ }
212+
213+ // Advance main further
214+ env .Git ("checkout" , "main" )
215+ env .CreateCommit ("main advance 3" )
216+
217+ // Restack from feature-a -- this triggers NeedsRebase=true with a stale
218+ // fork point that differs from merge-base. The fix should detect the stale
219+ // fork point is an ancestor of the merge-base and use a simple rebase.
220+ env .Git ("checkout" , "feature-a" )
221+ result := env .MustRun ("restack" )
222+
223+ if result .ContainsStdout ("using fork point" ) {
224+ t .Error ("restack should NOT use --onto with a stale fork point that is an ancestor of merge-base" )
225+ }
226+
227+ env .AssertAncestor ("main" , "feature-a" )
228+ env .AssertNoRebaseInProgress ()
229+ }
230+
231+ func TestCascadeForkPointUpdatedAfterContinue (t * testing.T ) {
232+ env := NewTestEnv (t )
233+ env .MustRun ("init" )
234+
235+ // Create a stack that will conflict
236+ conflictFile := env .CreateStackWithConflict ()
237+
238+ // Cascade will hit a conflict on feature-b
239+ result := env .Run ("cascade" )
240+ if result .Success () {
241+ t .Fatal ("expected conflict" )
242+ }
243+
244+ // Resolve and continue
245+ env .ResolveConflict (conflictFile )
246+ env .MustRun ("continue" )
247+
248+ // After continue, fork point for feature-a should be updated to main's tip.
249+ // (feature-a was the one that got rebased before the conflict on feature-b.)
250+ mainTip := env .BranchTip ("main" )
251+ featureAFP := env .GetStackConfig ("branch.feature-a.stackforkpoint" )
252+ if featureAFP != mainTip {
253+ t .Errorf ("feature-a fork point after continue = %s, want main tip %s" , featureAFP [:7 ], mainTip [:7 ])
254+ }
255+
256+ // feature-b should also have its fork point updated (to feature-a's tip)
257+ featureATip := env .BranchTip ("feature-a" )
258+ featureBFP := env .GetStackConfig ("branch.feature-b.stackforkpoint" )
259+ if featureBFP != featureATip {
260+ t .Errorf ("feature-b fork point after continue = %s, want feature-a tip %s" , featureBFP [:7 ], featureATip [:7 ])
261+ }
262+ }
263+
264+ func TestCascadeOntoUsedForRewrittenParent (t * testing.T ) {
265+ // Verify that --onto IS used when the parent's history was actually
266+ // rewritten (not just a stale fork point).
267+ env := NewTestEnv (t )
268+ env .MustRun ("init" )
269+
270+ env .MustRun ("create" , "feature-a" )
271+ env .CreateCommit ("feature a work" )
272+
273+ env .MustRun ("create" , "feature-b" )
274+ env .CreateCommit ("feature b work" )
275+
276+ // Amend feature-a's commit (rewrites its history)
277+ env .Git ("checkout" , "feature-a" )
278+ env .WriteFile ("feature-a-amended.txt" , "amended content" )
279+ env .Git ("add" , "." )
280+ env .Git ("commit" , "--amend" , "--no-edit" )
281+
282+ // Restack from feature-a. feature-b's fork point (old feature-a tip)
283+ // is now on a different history line → --onto should be used.
284+ result := env .MustRun ("restack" )
285+
286+ if ! result .ContainsStdout ("using fork point" ) {
287+ t .Error ("restack should use --onto when parent history was rewritten" )
288+ }
289+
290+ env .AssertAncestor ("feature-a" , "feature-b" )
291+ env .AssertNoRebaseInProgress ()
292+ }
0 commit comments