Skip to content

Commit b4c2af0

Browse files
committed
fix for stale ontoOldBase causing rebase conflicts
1 parent bc94a0b commit b4c2af0

File tree

4 files changed

+202
-4
lines changed

4 files changed

+202
-4
lines changed

cmd/rebase.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,18 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
245245
}
246246
}
247247

248-
if err := git.RebaseOnto(newBase, ontoOldBase, br.Branch); err != nil {
248+
// If ontoOldBase is stale (not an ancestor of the branch), the
249+
// branch was already rebased past it (e.g. by a previous run).
250+
// Fall back to merge-base(newBase, branch) which gives the correct
251+
// divergence point and avoids replaying already-applied commits.
252+
actualOldBase := ontoOldBase
253+
if isAnc, err := git.IsAncestor(ontoOldBase, br.Branch); err == nil && !isAnc {
254+
if mb, err := git.MergeBase(newBase, br.Branch); err == nil {
255+
actualOldBase = mb
256+
}
257+
}
258+
259+
if err := git.RebaseOnto(newBase, actualOldBase, br.Branch); err != nil {
249260
cfg.Warningf("Rebasing %s onto %s — conflict", br.Branch, newBase)
250261

251262
remaining := make([]string, 0)

cmd/rebase_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
245324
func TestRebase_ConflictSavesState(t *testing.T) {

cmd/sync.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,18 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
195195
}
196196
}
197197

198-
if err := git.RebaseOnto(newBase, ontoOldBase, br.Branch); err != nil {
198+
// If ontoOldBase is stale (not an ancestor of the branch), the
199+
// branch was already rebased past it (e.g. by a previous run).
200+
// Fall back to merge-base(newBase, branch) to avoid replaying
201+
// already-applied commits.
202+
actualOldBase := ontoOldBase
203+
if isAnc, err := git.IsAncestor(ontoOldBase, br.Branch); err == nil && !isAnc {
204+
if mb, err := git.MergeBase(newBase, br.Branch); err == nil {
205+
actualOldBase = mb
206+
}
207+
}
208+
209+
if err := git.RebaseOnto(newBase, actualOldBase, br.Branch); err != nil {
199210
// Conflict detected — abort and restore everything
200211
if git.IsRebaseInProgress() {
201212
_ = git.RebaseAbort()

cmd/sync_test.go

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,12 @@ func TestSync_MergedBranch_UsesOnto(t *testing.T) {
564564
return "default-sha", nil
565565
}
566566
mock.IsAncestorFn = func(a, d string) (bool, error) {
567-
return a == "local-sha" && d == "remote-sha", nil
567+
// Trunk: local is behind remote → triggers fast-forward
568+
if a == "local-sha" && d == "remote-sha" {
569+
return true, nil
570+
}
571+
// For --onto stale-check: old bases are valid ancestors (first-run)
572+
return true, nil
568573
}
569574
mock.UpdateBranchRefFn = func(string, string) error { return nil }
570575
mock.CheckoutBranchFn = func(string) error { return nil }
@@ -603,6 +608,93 @@ func TestSync_MergedBranch_UsesOnto(t *testing.T) {
603608
assert.True(t, pushCalls[0].force)
604609
}
605610

611+
// TestSync_StaleOntoOldBase_FallsBackToMergeBase verifies that when a branch
612+
// was already rebased past the merged branch's tip, sync detects the stale
613+
// ontoOldBase and falls back to merge-base for the correct divergence point.
614+
func TestSync_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) {
615+
s := stack.Stack{
616+
Trunk: stack.BranchRef{Branch: "main"},
617+
Branches: []stack.BranchRef{
618+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
619+
{Branch: "b2"},
620+
{Branch: "b3"},
621+
},
622+
}
623+
624+
tmpDir := t.TempDir()
625+
writeStackFile(t, tmpDir, s)
626+
627+
var rebaseOntoCalls []rebaseCall
628+
629+
branchSHAs := map[string]string{
630+
"b1": "b1-stale-presquash-sha",
631+
"b2": "b2-on-main-sha",
632+
"b3": "b3-on-b2-sha",
633+
}
634+
635+
mock := newSyncMock(tmpDir, "b2")
636+
mock.BranchExistsFn = func(name string) bool { return true }
637+
mock.RevParseFn = func(ref string) (string, error) {
638+
if ref == "main" {
639+
return "local-sha", nil
640+
}
641+
if ref == "origin/main" {
642+
return "remote-sha", nil
643+
}
644+
if sha, ok := branchSHAs[ref]; ok {
645+
return sha, nil
646+
}
647+
return "default-sha", nil
648+
}
649+
mock.IsAncestorFn = func(a, d string) (bool, error) {
650+
// Trunk: local is behind remote
651+
if a == "local-sha" && d == "remote-sha" {
652+
return true, nil
653+
}
654+
// b1's stale SHA is NOT an ancestor of b2 (already rebased)
655+
if a == "b1-stale-presquash-sha" {
656+
return false, nil
657+
}
658+
return true, nil
659+
}
660+
mock.MergeBaseFn = func(a, b string) (string, error) {
661+
if a == "main" && b == "b2" {
662+
return "main-b2-mergebase", nil
663+
}
664+
return "default-mergebase", nil
665+
}
666+
mock.UpdateBranchRefFn = func(string, string) error { return nil }
667+
mock.CheckoutBranchFn = func(string) error { return nil }
668+
mock.RebaseOntoFn = func(newBase, oldBase, branch string) error {
669+
rebaseOntoCalls = append(rebaseOntoCalls, rebaseCall{newBase, oldBase, branch})
670+
return nil
671+
}
672+
mock.PushFn = func(string, []string, bool, bool) error { return nil }
673+
674+
restore := git.SetOps(mock)
675+
defer restore()
676+
677+
cfg, _, _ := config.NewTestConfig()
678+
cmd := SyncCmd(cfg)
679+
cmd.SetOut(io.Discard)
680+
cmd.SetErr(io.Discard)
681+
err := cmd.Execute()
682+
683+
cfg.Out.Close()
684+
cfg.Err.Close()
685+
686+
assert.NoError(t, err)
687+
require.Len(t, rebaseOntoCalls, 2)
688+
689+
// b2: stale ontoOldBase → falls back to merge-base(main, b2)
690+
assert.Equal(t, rebaseCall{"main", "main-b2-mergebase", "b2"}, rebaseOntoCalls[0],
691+
"b2 should use merge-base as oldBase when ontoOldBase is stale")
692+
693+
// b3: b2's SHA is a valid ancestor → uses it directly
694+
assert.Equal(t, rebaseCall{"b2", "b2-on-main-sha", "b3"}, rebaseOntoCalls[1],
695+
"b3 should use b2's original SHA as oldBase")
696+
}
697+
606698
// TestSync_PushFailureAfterRebase verifies that when push fails after a
607699
// successful rebase, the command does not return a fatal error — only a
608700
// warning is printed about the push failure.
@@ -890,7 +982,12 @@ func TestSync_MergedBranchDeletedFromRemote(t *testing.T) {
890982
return "sha-" + ref, nil
891983
}
892984
mock.IsAncestorFn = func(a, d string) (bool, error) {
893-
return a == "local-sha" && d == "remote-sha", nil
985+
// Trunk FF check
986+
if a == "local-sha" && d == "remote-sha" {
987+
return true, nil
988+
}
989+
// For --onto stale-check: old bases are valid ancestors (first-run)
990+
return true, nil
894991
}
895992
mock.UpdateBranchRefFn = func(string, string) error { return nil }
896993
mock.CheckoutBranchFn = func(string) error { return nil }

0 commit comments

Comments
 (0)