@@ -240,6 +240,85 @@ func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) {
240240 "b4 should rebase --onto b3 with b3's original SHA as oldBase" )
241241}
242242
243+ // TestRebase_StaleOntoOldBase_FallsBackToMergeBase verifies that when a branch
244+ // was already rebased past the merged branch's tip (e.g. by a previous run),
245+ // the stale ontoOldBase is detected via IsAncestor and replaced with
246+ // merge-base(newBase, branch) to avoid replaying already-applied commits.
247+ func TestRebase_StaleOntoOldBase_FallsBackToMergeBase (t * testing.T ) {
248+ s := stack.Stack {
249+ Trunk : stack.BranchRef {Branch : "main" },
250+ Branches : []stack.BranchRef {
251+ {Branch : "b1" , PullRequest : & stack.PullRequestRef {Number : 10 , Merged : true }},
252+ {Branch : "b2" },
253+ {Branch : "b3" },
254+ },
255+ }
256+
257+ tmpDir := t .TempDir ()
258+ writeStackFile (t , tmpDir , s )
259+
260+ var rebaseCalls []rebaseCall
261+
262+ // b1's local ref is the stale pre-squash tip from before a previous rebase.
263+ // b2 was already rebased onto main by a previous run, so b1's old tip
264+ // is NOT an ancestor of b2.
265+ branchSHAs := map [string ]string {
266+ "main" : "main-sha" ,
267+ "b1" : "b1-stale-presquash-sha" ,
268+ "b2" : "b2-on-main-sha" ,
269+ "b3" : "b3-on-b2-sha" ,
270+ }
271+
272+ mock := newRebaseMock (tmpDir , "b2" )
273+ mock .BranchExistsFn = func (name string ) bool { return true }
274+ mock .RevParseFn = func (ref string ) (string , error ) {
275+ if sha , ok := branchSHAs [ref ]; ok {
276+ return sha , nil
277+ }
278+ return "default-sha" , nil
279+ }
280+ mock .IsAncestorFn = func (ancestor , descendant string ) (bool , error ) {
281+ // b1's stale SHA is NOT an ancestor of b2 (b2 was already rebased onto main)
282+ if ancestor == "b1-stale-presquash-sha" {
283+ return false , nil
284+ }
285+ return true , nil
286+ }
287+ mock .MergeBaseFn = func (a , b string ) (string , error ) {
288+ if a == "main" && b == "b2" {
289+ return "main-b2-mergebase" , nil
290+ }
291+ return "default-mergebase" , nil
292+ }
293+ mock .RebaseOntoFn = func (newBase , oldBase , branch string ) error {
294+ rebaseCalls = append (rebaseCalls , rebaseCall {newBase , oldBase , branch })
295+ return nil
296+ }
297+
298+ restore := git .SetOps (mock )
299+ defer restore ()
300+
301+ cfg , _ , _ := config .NewTestConfig ()
302+ cmd := RebaseCmd (cfg )
303+ cmd .SetOut (io .Discard )
304+ cmd .SetErr (io .Discard )
305+ err := cmd .Execute ()
306+
307+ cfg .Out .Close ()
308+ cfg .Err .Close ()
309+
310+ assert .NoError (t , err )
311+ require .Len (t , rebaseCalls , 2 )
312+
313+ // b2: stale ontoOldBase detected → falls back to merge-base(main, b2)
314+ assert .Equal (t , rebaseCall {"main" , "main-b2-mergebase" , "b2" }, rebaseCalls [0 ],
315+ "b2 should use merge-base as oldBase when ontoOldBase is stale" )
316+
317+ // b3: b2's SHA is a valid ancestor → uses it directly
318+ assert .Equal (t , rebaseCall {"b2" , "b2-on-main-sha" , "b3" }, rebaseCalls [1 ],
319+ "b3 should use b2's original SHA as oldBase (not stale)" )
320+ }
321+
243322// TestRebase_ConflictSavesState verifies that when a rebase conflict occurs,
244323// the state is saved with the conflict branch and remaining branches.
245324func TestRebase_ConflictSavesState (t * testing.T ) {
@@ -495,6 +574,67 @@ func TestRebase_UpstackOnly(t *testing.T) {
495574 assert .Equal (t , "b2" , allRebaseCalls [1 ].newBase , "b3 should be rebased onto b2" )
496575}
497576
577+ // TestRebase_UpstackWithMergedBranchBelow verifies that --upstack pre-seeds
578+ // --onto state when a merged branch exists immediately below the rebase range.
579+ func TestRebase_UpstackWithMergedBranchBelow (t * testing.T ) {
580+ s := stack.Stack {
581+ Trunk : stack.BranchRef {Branch : "main" },
582+ Branches : []stack.BranchRef {
583+ {Branch : "b1" , PullRequest : & stack.PullRequestRef {Number : 10 , Merged : true }},
584+ {Branch : "b2" },
585+ {Branch : "b3" },
586+ },
587+ }
588+
589+ tmpDir := t .TempDir ()
590+ writeStackFile (t , tmpDir , s )
591+
592+ var allRebaseCalls []rebaseCall
593+ var currentCheckedOut string
594+
595+ mock := newRebaseMock (tmpDir , "b2" )
596+ mock .CheckoutBranchFn = func (name string ) error {
597+ currentCheckedOut = name
598+ return nil
599+ }
600+ mock .BranchExistsFn = func (name string ) bool { return true }
601+ mock .RebaseFn = func (base string ) error {
602+ allRebaseCalls = append (allRebaseCalls , rebaseCall {newBase : base , oldBase : "" , branch : currentCheckedOut })
603+ return nil
604+ }
605+ mock .RebaseOntoFn = func (newBase , oldBase , branch string ) error {
606+ allRebaseCalls = append (allRebaseCalls , rebaseCall {newBase , oldBase , branch })
607+ return nil
608+ }
609+
610+ restore := git .SetOps (mock )
611+ defer restore ()
612+
613+ cfg , _ , _ := config .NewTestConfig ()
614+ cmd := RebaseCmd (cfg )
615+ cmd .SetArgs ([]string {"--upstack" })
616+ cmd .SetOut (io .Discard )
617+ cmd .SetErr (io .Discard )
618+ err := cmd .Execute ()
619+
620+ cfg .Out .Close ()
621+ cfg .Err .Close ()
622+
623+ assert .NoError (t , err )
624+ // b2 is at index 1, upstack = [b2, b3]. b1 is merged below.
625+ // b2 should use --onto because b1 was merged.
626+ require .Len (t , allRebaseCalls , 2 , "upstack should rebase b2 and b3" )
627+
628+ // b2: --onto rebase with b1's old SHA as old base
629+ assert .Equal (t , "main" , allRebaseCalls [0 ].newBase , "b2 should be rebased onto main (first non-merged ancestor)" )
630+ assert .Equal (t , "sha-b1" , allRebaseCalls [0 ].oldBase , "b2 should use b1's original SHA as old base" )
631+ assert .Equal (t , "b2" , allRebaseCalls [0 ].branch , "b2 should be the branch being rebased" )
632+
633+ // b3: --onto continues to propagate
634+ assert .Equal (t , "b2" , allRebaseCalls [1 ].newBase , "b3 should be rebased onto b2" )
635+ assert .NotEmpty (t , allRebaseCalls [1 ].oldBase , "b3 should also use --onto" )
636+ }
637+
498638// TestRebase_SkipsMergedBranches verifies that merged branches are skipped
499639// with an appropriate message.
500640func TestRebase_SkipsMergedBranches (t * testing.T ) {
@@ -1044,11 +1184,12 @@ func TestRebase_BranchDiverged_NoFF(t *testing.T) {
10441184
10451185func TestRebase_SkipsMergedBranchesNotExistingLocally (t * testing.T ) {
10461186 // Simulates a stack where b1 is merged and its branch was auto-deleted
1047- // from the remote, so it doesn't exist locally.
1187+ // from the remote, so it doesn't exist locally. The stored Head SHA is
1188+ // used as ontoOldBase for the next branch's --onto rebase.
10481189 s := stack.Stack {
10491190 Trunk : stack.BranchRef {Branch : "main" },
10501191 Branches : []stack.BranchRef {
1051- {Branch : "b1" , PullRequest : & stack.PullRequestRef {Number : 42 , Merged : true }},
1192+ {Branch : "b1" , Head : "b1-stored-head-sha" , PullRequest : & stack.PullRequestRef {Number : 42 , Merged : true }},
10521193 {Branch : "b2" },
10531194 },
10541195 }
@@ -1095,7 +1236,10 @@ func TestRebase_SkipsMergedBranchesNotExistingLocally(t *testing.T) {
10951236 assert .NoError (t , err )
10961237 assert .Contains (t , output , "Skipping b1" )
10971238
1098- // Only b2 should be rebased
1239+ // Only b2 should be rebased, and the rebase should use b1's stored
1240+ // Head SHA as oldBase so `git rebase --onto` receives valid arguments.
10991241 require .Len (t , rebaseCalls , 1 )
11001242 assert .Equal (t , "b2" , rebaseCalls [0 ].branch )
1243+ assert .Equal (t , "main" , rebaseCalls [0 ].newBase )
1244+ assert .Equal (t , "b1-stored-head-sha" , rebaseCalls [0 ].oldBase )
11011245}
0 commit comments