Skip to content

Commit ed2b46d

Browse files
committed
Restore stacks after incomplete cascade rebases
Roll back branches already rewritten when a later rebase cannot start or final ancestry verification fails, preventing retries from replaying stale history. Preserve retryable modify state without repeating completed work, and add regression coverage for remote-qualified trunk normalization.
1 parent dde62a5 commit ed2b46d

6 files changed

Lines changed: 415 additions & 12 deletions

File tree

cmd/rebase.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,11 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
226226

227227
if rebaseResult.Err != nil {
228228
cfg.Errorf("%v", rebaseResult.Err)
229-
_ = git.CheckoutBranch(currentBranch)
229+
if rebaseResult.Rebased {
230+
restoreRebaseRefs(cfg, currentBranch, originalRefs)
231+
} else {
232+
_ = git.CheckoutBranch(currentBranch)
233+
}
230234
return ErrSilent
231235
}
232236

@@ -266,6 +270,9 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
266270

267271
if unstacked := verifyStacked(s, trunk.Ref, startIdx, endIdx); len(unstacked) > 0 {
268272
reportUnstacked(cfg, trunk.Ref, unstacked)
273+
if rebaseResult.Rebased {
274+
restoreRebaseRefs(cfg, currentBranch, originalRefs)
275+
}
269276
return ErrSilent
270277
}
271278

@@ -408,6 +415,8 @@ func continueRebase(cfg *config.Config, gitDir string) error {
408415

409416
if result.Err != nil {
410417
cfg.Errorf("%v", result.Err)
418+
restoreRebaseRefs(cfg, state.OriginalBranch, state.OriginalRefs)
419+
clearRebaseState(gitDir)
411420
return ErrSilent
412421
}
413422

@@ -444,6 +453,8 @@ func continueRebase(cfg *config.Config, gitDir string) error {
444453
}
445454
if unstacked := verifyStacked(s, trunkBase, verifyStart, verifyEnd); len(unstacked) > 0 {
446455
reportUnstacked(cfg, trunkRef, unstacked)
456+
restoreRebaseRefs(cfg, state.OriginalBranch, state.OriginalRefs)
457+
clearRebaseState(gitDir)
447458
return ErrSilent
448459
}
449460

cmd/sync.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,15 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
153153
// --- Step 3: Cascade rebase ---
154154
needsRebase := trunk.Moved || len(updatedBranches) > 0 || stackNeedsRebase(s, trunk.Ref)
155155
rebased := false
156+
var originalRefs map[string]string
156157
if needsRebase {
157158
cfg.Printf("")
158159
cfg.Printf("Rebasing stack ...")
159160

160161
// Sync PR state to detect merged PRs before rebasing.
161162
_ = syncStackPRs(cfg, s)
162163

163-
originalRefs, err := resolveOriginalRefs(s)
164+
originalRefs, err = resolveOriginalRefs(s)
164165
if err != nil {
165166
cfg.Warningf("Could not resolve branch SHAs — skipping rebase: %v", err)
166167
} else {
@@ -175,7 +176,11 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
175176

176177
if result.Err != nil {
177178
cfg.Errorf("%v", result.Err)
178-
_ = git.CheckoutBranch(currentBranch)
179+
if result.Rebased {
180+
restoreRebaseRefs(cfg, currentBranch, originalRefs)
181+
} else {
182+
_ = git.CheckoutBranch(currentBranch)
183+
}
179184
stack.SaveNonBlocking(gitDir, sf)
180185
return ErrSilent
181186
}
@@ -209,6 +214,9 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
209214
if unstacked := verifyStacked(s, trunk.Ref, 0, len(s.Branches)); len(unstacked) > 0 {
210215
_ = git.CheckoutBranch(currentBranch)
211216
reportUnstacked(cfg, trunk.Ref, unstacked)
217+
if rebased && originalRefs != nil {
218+
restoreRebaseRefs(cfg, currentBranch, originalRefs)
219+
}
212220
stack.SaveNonBlocking(gitDir, sf)
213221
return ErrSilent
214222
}
@@ -402,6 +410,12 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
402410
func restoreBranches(originalRefs map[string]string) []string {
403411
var errors []string
404412
for branch, sha := range originalRefs {
413+
if !git.BranchExists(branch) {
414+
continue
415+
}
416+
if currentSHA, err := git.RevParse(branch); err == nil && currentSHA == sha {
417+
continue
418+
}
405419
if err := git.CheckoutBranch(branch); err != nil {
406420
errors = append(errors, fmt.Sprintf("checkout %s: %s", branch, err))
407421
continue
@@ -413,6 +427,12 @@ func restoreBranches(originalRefs map[string]string) []string {
413427
return errors
414428
}
415429

430+
func restoreRebaseRefs(cfg *config.Config, originalBranch string, originalRefs map[string]string) {
431+
restoreErrors := restoreBranches(originalRefs)
432+
_ = git.CheckoutBranch(originalBranch)
433+
reportRestoreStatus(cfg, restoreErrors)
434+
}
435+
416436
// reportRestoreStatus prints whether branch restoration succeeded or partially failed.
417437
func reportRestoreStatus(cfg *config.Config, restoreErrors []string) {
418438
if len(restoreErrors) > 0 {

cmd/sync_test.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,11 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) {
475475
var checkouts []string
476476
currentBranch := "b1"
477477
abortCalled := false
478+
branchSHAs := map[string]string{
479+
"b1": "sha-b1",
480+
"b2": "sha-b2",
481+
"b3": "sha-b3",
482+
}
478483

479484
mock := newSyncMock(tmpDir, "b1")
480485
mock.RevParseFn = func(ref string) (string, error) {
@@ -484,6 +489,9 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) {
484489
if ref == "origin/main" {
485490
return "remote-sha", nil
486491
}
492+
if sha, ok := branchSHAs[ref]; ok {
493+
return sha, nil
494+
}
487495
return "sha-" + ref, nil
488496
}
489497
mock.IsAncestorFn = func(a, d string) (bool, error) {
@@ -495,7 +503,10 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) {
495503
currentBranch = name
496504
return nil
497505
}
498-
mock.RebaseFn = func(string, git.RebaseOpts) error { return nil } // b1 succeeds
506+
mock.RebaseFn = func(string, git.RebaseOpts) error {
507+
branchSHAs["b1"] = "rebased-b1"
508+
return nil
509+
}
499510
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
500511
if branch == "b2" {
501512
return fmt.Errorf("conflict")
@@ -508,6 +519,7 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) {
508519
}
509520
mock.ResetHardFn = func(ref string) error {
510521
resets = append(resets, resetCall{currentBranch, ref})
522+
branchSHAs[currentBranch] = ref
511523
return nil
512524
}
513525

@@ -528,14 +540,15 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) {
528540
assert.Contains(t, output, "Conflict detected")
529541
assert.Contains(t, output, "gh stack rebase")
530542

531-
// All branches should be restored
543+
// The branch rewritten before the conflict should be restored. Unchanged
544+
// branches are left alone.
532545
resetMap := make(map[string]string)
533546
for _, r := range resets {
534547
resetMap[r.branch] = r.sha
535548
}
536549
assert.Equal(t, "sha-b1", resetMap["b1"])
537-
assert.Equal(t, "sha-b2", resetMap["b2"])
538-
assert.Equal(t, "sha-b3", resetMap["b3"])
550+
assert.NotContains(t, resetMap, "b2")
551+
assert.NotContains(t, resetMap, "b3")
539552

540553
_ = abortCalled // RebaseAbort is called if IsRebaseInProgress returns true
541554
}

0 commit comments

Comments
 (0)