Skip to content

Commit 2c93c6d

Browse files
skarimCopilot
andcommitted
address review feedback: error handling and index validation
- resolveOriginalRefs now returns (map, error) instead of silently swallowing RevParseMap failures; sync warns and skips rebase, rebase aborts with a clear error - cascadeRebase uses a new Err field on the result struct to distinguish fatal errors (e.g. checkout failure) from recoverable conflicts; callers no longer enter conflict-recovery flow for non-conflict errors - continueRebase validates that remaining branch indices are contiguous in stack order, erroring out if the stack was reordered between conflict and --continue Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c46e74e commit 2c93c6d

3 files changed

Lines changed: 68 additions & 50 deletions

File tree

cmd/rebase.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,10 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
166166
// Sync PR state before rebase so we can detect merged PRs.
167167
_ = syncStackPRs(cfg, s)
168168

169-
originalRefs := resolveOriginalRefs(s)
169+
originalRefs, err := resolveOriginalRefs(s)
170+
if err != nil {
171+
return fmt.Errorf("resolving branch refs: %w", err)
172+
}
170173

171174
// Get --onto state from merged/queued branches below the rebase range.
172175
// Ensures that when --upstack excludes skipped branches, we still check
@@ -193,6 +196,11 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
193196
OntoOldBase: ontoOldBase,
194197
})
195198

199+
if rebaseResult.Err != nil {
200+
cfg.Errorf("%v", rebaseResult.Err)
201+
return ErrSilent
202+
}
203+
196204
if rebaseResult.Conflicted {
197205
cfg.Warningf("Rebasing %s onto %s — conflict", rebaseResult.ConflictBranch, rebaseResult.ConflictBase)
198206

@@ -308,17 +316,19 @@ func continueRebase(cfg *config.Config, gitDir string) error {
308316

309317
// Rebase remaining branches using the shared cascade helper.
310318
if len(state.RemainingBranches) > 0 {
311-
// Validate all remaining branches still exist in the stack and
312-
// build the BranchRef slice with correct absolute indices.
319+
// Validate all remaining branches still exist in the stack,
320+
// are in contiguous ascending order, and build the BranchRef slice.
313321
remainingRefs := make([]stack.BranchRef, 0, len(state.RemainingBranches))
314322
startAbsIdx := -1
315-
for _, name := range state.RemainingBranches {
323+
for i, name := range state.RemainingBranches {
316324
idx := s.IndexOf(name)
317325
if idx < 0 {
318326
return fmt.Errorf("branch %q from saved rebase state is no longer in the stack — the stack may have been modified since the rebase started; consider aborting with --abort", name)
319327
}
320328
if startAbsIdx < 0 {
321329
startAbsIdx = idx
330+
} else if idx != startAbsIdx+i {
331+
return fmt.Errorf("branch %q is at stack index %d, expected %d — the stack may have been reordered since the rebase started; consider aborting with --abort", name, idx, startAbsIdx+i)
322332
}
323333
remainingRefs = append(remainingRefs, s.Branches[idx])
324334
}
@@ -333,6 +343,11 @@ func continueRebase(cfg *config.Config, gitDir string) error {
333343
OntoOldBase: state.OntoOldBase,
334344
})
335345

346+
if result.Err != nil {
347+
cfg.Errorf("%v", result.Err)
348+
return ErrSilent
349+
}
350+
336351
if result.Conflicted {
337352
cfg.Warningf("Rebasing %s onto %s — conflict", result.ConflictBranch, result.ConflictBase)
338353

cmd/sync.go

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -110,37 +110,47 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
110110
// Sync PR state to detect merged PRs before rebasing.
111111
_ = syncStackPRs(cfg, s)
112112

113-
originalRefs := resolveOriginalRefs(s)
114-
115-
result := cascadeRebase(cascadeRebaseOpts{
116-
Cfg: cfg,
117-
Stack: s,
118-
Branches: s.Branches,
119-
StartAbsIdx: 0,
120-
OriginalRefs: originalRefs,
121-
})
122-
123-
if result.Conflicted {
124-
// Abort and restore everything — sync is non-interactive.
125-
if git.IsRebaseInProgress() {
126-
_ = git.RebaseAbort()
113+
originalRefs, err := resolveOriginalRefs(s)
114+
if err != nil {
115+
cfg.Warningf("Could not resolve branch SHAs — skipping rebase: %v", err)
116+
} else {
117+
result := cascadeRebase(cascadeRebaseOpts{
118+
Cfg: cfg,
119+
Stack: s,
120+
Branches: s.Branches,
121+
StartAbsIdx: 0,
122+
OriginalRefs: originalRefs,
123+
})
124+
125+
if result.Err != nil {
126+
cfg.Errorf("%v", result.Err)
127+
_ = git.CheckoutBranch(currentBranch)
128+
stack.SaveNonBlocking(gitDir, sf)
129+
return ErrSilent
130+
}
131+
132+
if result.Conflicted {
133+
// Abort and restore everything — sync is non-interactive.
134+
if git.IsRebaseInProgress() {
135+
_ = git.RebaseAbort()
136+
}
137+
restoreErrors := restoreBranches(originalRefs)
138+
_ = git.CheckoutBranch(currentBranch)
139+
140+
cfg.Errorf("Conflict detected rebasing %s onto %s", result.ConflictBranch, result.ConflictBase)
141+
reportRestoreStatus(cfg, restoreErrors)
142+
cfg.Printf(" Run `%s` to resolve conflicts interactively.",
143+
cfg.ColorCyan("gh stack rebase"))
144+
145+
// Persist refreshed PR state even on conflict, then bail out
146+
// before pushing or reporting success.
147+
stack.SaveNonBlocking(gitDir, sf)
148+
return ErrConflict
127149
}
128-
restoreErrors := restoreBranches(originalRefs)
129-
_ = git.CheckoutBranch(currentBranch)
130-
131-
cfg.Errorf("Conflict detected rebasing %s onto %s", result.ConflictBranch, result.ConflictBase)
132-
reportRestoreStatus(cfg, restoreErrors)
133-
cfg.Printf(" Run `%s` to resolve conflicts interactively.",
134-
cfg.ColorCyan("gh stack rebase"))
135-
136-
// Persist refreshed PR state even on conflict, then bail out
137-
// before pushing or reporting success.
138-
stack.SaveNonBlocking(gitDir, sf)
139-
return ErrConflict
140-
}
141150

142-
if result.Rebased {
143-
rebased = true
151+
if result.Rebased {
152+
rebased = true
153+
}
144154
}
145155
_ = git.CheckoutBranch(currentBranch)
146156
}

cmd/utils.go

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -615,28 +615,28 @@ func fastForwardBranches(cfg *config.Config, s *stack.Stack, remote, currentBran
615615
// branches in the stack. Merged branches that no longer exist locally are
616616
// backfilled from the stack metadata. This map is used as the "before" state
617617
// for cascade rebases and conflict recovery.
618-
func resolveOriginalRefs(s *stack.Stack) map[string]string {
618+
func resolveOriginalRefs(s *stack.Stack) (map[string]string, error) {
619619
branchNames := make([]string, 0, len(s.Branches))
620620
for _, b := range s.Branches {
621621
if b.IsMerged() && !git.BranchExists(b.Branch) {
622622
continue
623623
}
624624
branchNames = append(branchNames, b.Branch)
625625
}
626-
originalRefs, _ := git.RevParseMap(branchNames)
626+
originalRefs, err := git.RevParseMap(branchNames)
627+
if err != nil {
628+
return nil, fmt.Errorf("resolving branch SHAs: %w", err)
629+
}
627630

628631
// Backfill merged branches that were deleted locally.
629632
for _, b := range s.Branches {
630633
if b.IsMerged() && !git.BranchExists(b.Branch) {
631634
if b.Head != "" {
632-
if originalRefs == nil {
633-
originalRefs = make(map[string]string)
634-
}
635635
originalRefs[b.Branch] = b.Head
636636
}
637637
}
638638
}
639-
return originalRefs
639+
return originalRefs, nil
640640
}
641641

642642
// fastForwardTrunk fast-forwards the trunk branch to match its remote tracking
@@ -700,7 +700,8 @@ type cascadeRebaseOpts struct {
700700
// cascadeRebaseResult describes the outcome of a cascade rebase.
701701
type cascadeRebaseResult struct {
702702
Rebased bool // at least one branch was successfully rebased
703-
Conflicted bool // a conflict was detected
703+
Conflicted bool // a rebase conflict was detected (recoverable via --continue)
704+
Err error // a fatal error occurred (not recoverable via --continue)
704705
ConflictIdx int // absolute index in Stack.Branches of the conflicting branch
705706
ConflictBranch string // name of the conflicting branch
706707
ConflictBase string // base branch we were rebasing onto
@@ -789,17 +790,9 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult {
789790
rebaseErr = git.RebaseOnto(base, originalRefs[base], br.Branch)
790791
} else {
791792
if err := git.CheckoutBranch(br.Branch); err != nil {
792-
remaining := make([]string, 0, len(opts.Branches)-i-1)
793-
for j := i + 1; j < len(opts.Branches); j++ {
794-
remaining = append(remaining, opts.Branches[j].Branch)
795-
}
796793
return cascadeRebaseResult{
797-
Rebased: result.Rebased,
798-
Conflicted: true,
799-
ConflictIdx: absIdx,
800-
ConflictBranch: br.Branch,
801-
ConflictBase: base,
802-
Remaining: remaining,
794+
Rebased: result.Rebased,
795+
Err: fmt.Errorf("checking out %s: %w", br.Branch, err),
803796
}
804797
}
805798
rebaseErr = git.Rebase(base)

0 commit comments

Comments
 (0)