Skip to content

Commit f706944

Browse files
committed
fix --onto rebase for merged branches (#43)
* backfill ontoOldBase for deleted merged branches * ensure upstack rebase checks immediate predecessor * fix for stale ontoOldBase causing rebase conflicts
1 parent 22c8ef4 commit f706944

4 files changed

Lines changed: 313 additions & 9 deletions

File tree

cmd/rebase.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,34 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
188188
return ErrSilent
189189
}
190190

191+
// Backfill originalRefs for merged branches that were deleted locally.
192+
// The rebase loop uses originalRefs[br.Branch] as ontoOldBase; without
193+
// a valid entry the subsequent --onto rebase would receive an empty ref.
194+
for _, b := range s.Branches {
195+
if b.IsMerged() && !git.BranchExists(b.Branch) {
196+
if b.Head != "" {
197+
originalRefs[b.Branch] = b.Head
198+
}
199+
}
200+
}
201+
191202
// Track --onto rebase state for merged branches.
192203
needsOnto := false
193204
var ontoOldBase string
194205

206+
// Get --onto state from merged branches below the rebase range.
207+
// Ensures that when --upstack excludes merged branches, we still check
208+
// the immediate predecessor for a merged PR and use --onto if needed.
209+
if startIdx > 0 {
210+
prev := s.Branches[startIdx-1]
211+
if prev.IsMerged() {
212+
if sha, ok := originalRefs[prev.Branch]; ok {
213+
needsOnto = true
214+
ontoOldBase = sha
215+
}
216+
}
217+
}
218+
195219
for i, br := range branchesToRebase {
196220
var base string
197221
absIdx := startIdx + i
@@ -221,7 +245,18 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
221245
}
222246
}
223247

224-
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 {
225260
cfg.Warningf("Rebasing %s onto %s — conflict", br.Branch, newBase)
226261

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

cmd/rebase_test.go

Lines changed: 147 additions & 3 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) {
@@ -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.
500640
func TestRebase_SkipsMergedBranches(t *testing.T) {
@@ -1044,11 +1184,12 @@ func TestRebase_BranchDiverged_NoFF(t *testing.T) {
10441184

10451185
func 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
}

cmd/sync.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,20 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
142142
}
143143
originalRefs, _ := git.RevParseMap(branchNames)
144144

145+
// Backfill originalRefs for merged branches that were deleted locally.
146+
// The rebase loop uses originalRefs[br.Branch] as ontoOldBase; without
147+
// a valid entry the subsequent --onto rebase would receive an empty ref.
148+
for _, b := range s.Branches {
149+
if b.IsMerged() && !git.BranchExists(b.Branch) {
150+
if b.Head != "" {
151+
if originalRefs == nil {
152+
originalRefs = make(map[string]string)
153+
}
154+
originalRefs[b.Branch] = b.Head
155+
}
156+
}
157+
}
158+
145159
needsOnto := false
146160
var ontoOldBase string
147161

@@ -181,7 +195,18 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
181195
}
182196
}
183197

184-
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 {
185210
// Conflict detected — abort and restore everything
186211
if git.IsRebaseInProgress() {
187212
_ = git.RebaseAbort()

0 commit comments

Comments
 (0)