Skip to content

Commit 1085b84

Browse files
authored
swtich to nearest surviving branch after modify (#105)
* swtich to nearest surviving branch after modify After `gh stack modify` applies changes, the user may end up on an orphaned branch that is no longer part of the stack — for example, if their checked-out branch was dropped, folded into another branch, or renamed. Previously, the code blindly restored the original branch regardless of whether it still existed in the stack. Add a `resolveCheckoutBranch` helper that inspects the modify plan and the post-modify stack to determine the best branch to check out: 1. Still in stack → keep the original branch (no-op) 2. Renamed → check out the new name 3. Folded down → check out the fold target (branch below) 4. Folded up → check out the fold target (branch above) 5. Dropped → check out the nearest surviving neighbor (prefer above, fall back to below) 6. Fallback → topmost branch in the stack Both `ApplyPlan` and `ContinueApply` (the `--continue` path) now use this helper instead of unconditionally restoring the original branch. When the resolved branch differs from the original, a message is printed so the user knows they've been switched. The resolution uses the pre-modify snapshot (already persisted in the state file) to determine original adjacency, so it works correctly even when multiple branches are removed in the same operation. Includes 12 new tests: - 9 unit tests for resolveCheckoutBranch covering all action types, edge cases (topmost dropped, multiple drops, empty stack), and the fallback path - 3 integration tests verifying ApplyPlan checks out the correct branch after drop, fold-down, and rename operations * handle CheckoutBranch errors * handle renamed branches
1 parent 743a249 commit 1085b84

2 files changed

Lines changed: 477 additions & 4 deletions

File tree

internal/modify/apply.go

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -495,8 +495,14 @@ func ApplyPlan(
495495
result.MovedBranches++
496496
}
497497

498-
// Restore original branch
499-
_ = git.CheckoutBranch(currentBranch)
498+
// Check out the best branch — the original if it's still in the stack,
499+
// otherwise the nearest surviving branch.
500+
targetBranch := resolveCheckoutBranch(currentBranch, plan, snapshot, s)
501+
if err := git.CheckoutBranch(targetBranch); err == nil {
502+
if targetBranch != currentBranch {
503+
cfg.Printf("Switched to %s (original branch %s is no longer in the stack)", targetBranch, currentBranch)
504+
}
505+
}
500506

501507
// Update base SHAs
502508
updateBaseSHAs(s)
@@ -520,6 +526,126 @@ func ApplyPlan(
520526
return result, nil, nil
521527
}
522528

529+
// resolveCheckoutBranch determines which branch to check out after a modify
530+
// operation completes. If the user's original branch was dropped, folded, or
531+
// renamed, this returns the most appropriate surviving branch.
532+
func resolveCheckoutBranch(originalBranch string, plan []Action, snapshot Snapshot, s *stack.Stack) string {
533+
// Check if the original branch is still in the stack — quick exit.
534+
if s.IndexOf(originalBranch) >= 0 {
535+
return originalBranch
536+
}
537+
538+
// Build a rename map (old name → new name) so we can translate snapshot
539+
// neighbor names that may have been renamed in the same modify operation.
540+
renames := make(map[string]string)
541+
for _, a := range plan {
542+
if a.Type == "rename" && a.NewName != "" {
543+
renames[a.Branch] = a.NewName
544+
}
545+
}
546+
547+
// resolvedName returns the post-rename name for a branch, or the
548+
// original name if it wasn't renamed.
549+
resolvedName := func(name string) string {
550+
if newName, ok := renames[name]; ok {
551+
return newName
552+
}
553+
return name
554+
}
555+
556+
// Scan the plan for an action that targeted the original branch.
557+
for _, a := range plan {
558+
if a.Branch != originalBranch {
559+
continue
560+
}
561+
562+
switch a.Type {
563+
case "rename":
564+
if a.NewName != "" && s.IndexOf(a.NewName) >= 0 {
565+
return a.NewName
566+
}
567+
568+
case "fold_down":
569+
// Fold-down merges into the branch below in the original order.
570+
if target := adjacentSnapshotBranch(snapshot, originalBranch, -1); target != "" {
571+
resolved := resolvedName(target)
572+
if s.IndexOf(resolved) >= 0 {
573+
return resolved
574+
}
575+
}
576+
577+
case "fold_up":
578+
// Fold-up merges into the branch above in the original order.
579+
if target := adjacentSnapshotBranch(snapshot, originalBranch, +1); target != "" {
580+
resolved := resolvedName(target)
581+
if s.IndexOf(resolved) >= 0 {
582+
return resolved
583+
}
584+
}
585+
586+
case "drop":
587+
// Prefer the branch that was directly above in the original order,
588+
// then fall back to the one below.
589+
if nearest := nearestSurvivingBranch(snapshot, originalBranch, s, resolvedName); nearest != "" {
590+
return nearest
591+
}
592+
}
593+
}
594+
595+
// Fallback: topmost branch in the stack.
596+
if len(s.Branches) > 0 {
597+
return s.Branches[len(s.Branches)-1].Branch
598+
}
599+
return originalBranch
600+
}
601+
602+
// adjacentSnapshotBranch returns the branch adjacent to target in the snapshot.
603+
// direction -1 means below (toward trunk), +1 means above (away from trunk).
604+
func adjacentSnapshotBranch(snapshot Snapshot, target string, direction int) string {
605+
for i, bs := range snapshot.Branches {
606+
if bs.Name == target {
607+
adj := i + direction
608+
if adj >= 0 && adj < len(snapshot.Branches) {
609+
return snapshot.Branches[adj].Name
610+
}
611+
return ""
612+
}
613+
}
614+
return ""
615+
}
616+
617+
// nearestSurvivingBranch finds the closest branch to the dropped branch that
618+
// still exists in the stack. Prefers the branch above (higher index), then below.
619+
// resolvedName translates snapshot names through any renames from the same operation.
620+
func nearestSurvivingBranch(snapshot Snapshot, dropped string, s *stack.Stack, resolvedName func(string) string) string {
621+
pos := -1
622+
for i, bs := range snapshot.Branches {
623+
if bs.Name == dropped {
624+
pos = i
625+
break
626+
}
627+
}
628+
if pos < 0 {
629+
return ""
630+
}
631+
632+
// Search above first (higher indices = away from trunk)
633+
for i := pos + 1; i < len(snapshot.Branches); i++ {
634+
name := resolvedName(snapshot.Branches[i].Name)
635+
if s.IndexOf(name) >= 0 {
636+
return name
637+
}
638+
}
639+
// Then below (lower indices = toward trunk)
640+
for i := pos - 1; i >= 0; i-- {
641+
name := resolvedName(snapshot.Branches[i].Name)
642+
if s.IndexOf(name) >= 0 {
643+
return name
644+
}
645+
}
646+
return ""
647+
}
648+
523649
// ContinueApply resumes a modify operation after the user resolves a rebase conflict.
524650
// It finishes the in-progress git rebase, then continues the cascading rebase for
525651
// remaining branches stored in the state file.
@@ -657,9 +783,14 @@ func ContinueApply(
657783
cfg.Successf("Rebased %s onto %s", branchName, newBase)
658784
}
659785

660-
// All rebases done — restore original branch
786+
// All rebases done — check out the best branch
661787
if state.OriginalBranch != "" {
662-
_ = git.CheckoutBranch(state.OriginalBranch)
788+
targetBranch := resolveCheckoutBranch(state.OriginalBranch, state.Plan, state.Snapshot, s)
789+
if err := git.CheckoutBranch(targetBranch); err == nil {
790+
if targetBranch != state.OriginalBranch {
791+
cfg.Printf("Switched to %s (original branch %s is no longer in the stack)", targetBranch, state.OriginalBranch)
792+
}
793+
}
663794
}
664795

665796
// Update base SHAs

0 commit comments

Comments
 (0)