diff --git a/internal/modify/apply.go b/internal/modify/apply.go index 6dfe940..b39ecd4 100644 --- a/internal/modify/apply.go +++ b/internal/modify/apply.go @@ -495,8 +495,14 @@ func ApplyPlan( result.MovedBranches++ } - // Restore original branch - _ = git.CheckoutBranch(currentBranch) + // Check out the best branch — the original if it's still in the stack, + // otherwise the nearest surviving branch. + targetBranch := resolveCheckoutBranch(currentBranch, plan, snapshot, s) + if err := git.CheckoutBranch(targetBranch); err == nil { + if targetBranch != currentBranch { + cfg.Printf("Switched to %s (original branch %s is no longer in the stack)", targetBranch, currentBranch) + } + } // Update base SHAs updateBaseSHAs(s) @@ -520,6 +526,126 @@ func ApplyPlan( return result, nil, nil } +// resolveCheckoutBranch determines which branch to check out after a modify +// operation completes. If the user's original branch was dropped, folded, or +// renamed, this returns the most appropriate surviving branch. +func resolveCheckoutBranch(originalBranch string, plan []Action, snapshot Snapshot, s *stack.Stack) string { + // Check if the original branch is still in the stack — quick exit. + if s.IndexOf(originalBranch) >= 0 { + return originalBranch + } + + // Build a rename map (old name → new name) so we can translate snapshot + // neighbor names that may have been renamed in the same modify operation. + renames := make(map[string]string) + for _, a := range plan { + if a.Type == "rename" && a.NewName != "" { + renames[a.Branch] = a.NewName + } + } + + // resolvedName returns the post-rename name for a branch, or the + // original name if it wasn't renamed. + resolvedName := func(name string) string { + if newName, ok := renames[name]; ok { + return newName + } + return name + } + + // Scan the plan for an action that targeted the original branch. + for _, a := range plan { + if a.Branch != originalBranch { + continue + } + + switch a.Type { + case "rename": + if a.NewName != "" && s.IndexOf(a.NewName) >= 0 { + return a.NewName + } + + case "fold_down": + // Fold-down merges into the branch below in the original order. + if target := adjacentSnapshotBranch(snapshot, originalBranch, -1); target != "" { + resolved := resolvedName(target) + if s.IndexOf(resolved) >= 0 { + return resolved + } + } + + case "fold_up": + // Fold-up merges into the branch above in the original order. + if target := adjacentSnapshotBranch(snapshot, originalBranch, +1); target != "" { + resolved := resolvedName(target) + if s.IndexOf(resolved) >= 0 { + return resolved + } + } + + case "drop": + // Prefer the branch that was directly above in the original order, + // then fall back to the one below. + if nearest := nearestSurvivingBranch(snapshot, originalBranch, s, resolvedName); nearest != "" { + return nearest + } + } + } + + // Fallback: topmost branch in the stack. + if len(s.Branches) > 0 { + return s.Branches[len(s.Branches)-1].Branch + } + return originalBranch +} + +// adjacentSnapshotBranch returns the branch adjacent to target in the snapshot. +// direction -1 means below (toward trunk), +1 means above (away from trunk). +func adjacentSnapshotBranch(snapshot Snapshot, target string, direction int) string { + for i, bs := range snapshot.Branches { + if bs.Name == target { + adj := i + direction + if adj >= 0 && adj < len(snapshot.Branches) { + return snapshot.Branches[adj].Name + } + return "" + } + } + return "" +} + +// nearestSurvivingBranch finds the closest branch to the dropped branch that +// still exists in the stack. Prefers the branch above (higher index), then below. +// resolvedName translates snapshot names through any renames from the same operation. +func nearestSurvivingBranch(snapshot Snapshot, dropped string, s *stack.Stack, resolvedName func(string) string) string { + pos := -1 + for i, bs := range snapshot.Branches { + if bs.Name == dropped { + pos = i + break + } + } + if pos < 0 { + return "" + } + + // Search above first (higher indices = away from trunk) + for i := pos + 1; i < len(snapshot.Branches); i++ { + name := resolvedName(snapshot.Branches[i].Name) + if s.IndexOf(name) >= 0 { + return name + } + } + // Then below (lower indices = toward trunk) + for i := pos - 1; i >= 0; i-- { + name := resolvedName(snapshot.Branches[i].Name) + if s.IndexOf(name) >= 0 { + return name + } + } + return "" +} + // ContinueApply resumes a modify operation after the user resolves a rebase conflict. // It finishes the in-progress git rebase, then continues the cascading rebase for // remaining branches stored in the state file. @@ -657,9 +783,14 @@ func ContinueApply( cfg.Successf("Rebased %s onto %s", branchName, newBase) } - // All rebases done — restore original branch + // All rebases done — check out the best branch if state.OriginalBranch != "" { - _ = git.CheckoutBranch(state.OriginalBranch) + targetBranch := resolveCheckoutBranch(state.OriginalBranch, state.Plan, state.Snapshot, s) + if err := git.CheckoutBranch(targetBranch); err == nil { + if targetBranch != state.OriginalBranch { + cfg.Printf("Switched to %s (original branch %s is no longer in the stack)", targetBranch, state.OriginalBranch) + } + } } // Update base SHAs diff --git a/internal/modify/apply_test.go b/internal/modify/apply_test.go index d98b75b..084bc92 100644 --- a/internal/modify/apply_test.go +++ b/internal/modify/apply_test.go @@ -1344,3 +1344,345 @@ func TestApplyPlan_ClearsStateForLocalStack(t *testing.T) { // Local stack (no ID) should clear the state file assert.False(t, StateExists(gitDir)) } + +// ─── resolveCheckoutBranch ────────────────────────────────────────────────── + +func TestResolveCheckoutBranch_StillInStack(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "B"}}, + } + snapshot := Snapshot{ + Branches: []BranchSnapshot{{Name: "A", Position: 0}, {Name: "B", Position: 1}}, + } + + result := resolveCheckoutBranch("A", nil, snapshot, s) + assert.Equal(t, "A", result) +} + +func TestResolveCheckoutBranch_Renamed(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "new-A"}, {Branch: "B"}}, + } + snapshot := Snapshot{ + Branches: []BranchSnapshot{{Name: "A", Position: 0}, {Name: "B", Position: 1}}, + } + plan := []Action{{Type: "rename", Branch: "A", NewName: "new-A"}} + + result := resolveCheckoutBranch("A", plan, snapshot, s) + assert.Equal(t, "new-A", result) +} + +func TestResolveCheckoutBranch_FoldDown(t *testing.T) { + // B is folded down into A. After fold, stack has [A, C]. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "C"}}, + } + snapshot := Snapshot{ + Branches: []BranchSnapshot{ + {Name: "A", Position: 0}, + {Name: "B", Position: 1}, + {Name: "C", Position: 2}, + }, + } + plan := []Action{{Type: "fold_down", Branch: "B"}} + + result := resolveCheckoutBranch("B", plan, snapshot, s) + assert.Equal(t, "A", result) +} + +func TestResolveCheckoutBranch_FoldUp(t *testing.T) { + // B is folded up into C. After fold, stack has [A, C]. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "C"}}, + } + snapshot := Snapshot{ + Branches: []BranchSnapshot{ + {Name: "A", Position: 0}, + {Name: "B", Position: 1}, + {Name: "C", Position: 2}, + }, + } + plan := []Action{{Type: "fold_up", Branch: "B"}} + + result := resolveCheckoutBranch("B", plan, snapshot, s) + assert.Equal(t, "C", result) +} + +func TestResolveCheckoutBranch_Dropped_HasAbove(t *testing.T) { + // B is dropped. Stack has [A, C]. Should pick C (above B). + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "C"}}, + } + snapshot := Snapshot{ + Branches: []BranchSnapshot{ + {Name: "A", Position: 0}, + {Name: "B", Position: 1}, + {Name: "C", Position: 2}, + }, + } + plan := []Action{{Type: "drop", Branch: "B"}} + + result := resolveCheckoutBranch("B", plan, snapshot, s) + assert.Equal(t, "C", result) +} + +func TestResolveCheckoutBranch_Dropped_TopBranch(t *testing.T) { + // C (topmost) is dropped. Stack has [A, B]. Should pick B (below C). + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "B"}}, + } + snapshot := Snapshot{ + Branches: []BranchSnapshot{ + {Name: "A", Position: 0}, + {Name: "B", Position: 1}, + {Name: "C", Position: 2}, + }, + } + plan := []Action{{Type: "drop", Branch: "C"}} + + result := resolveCheckoutBranch("C", plan, snapshot, s) + assert.Equal(t, "B", result) +} + +func TestResolveCheckoutBranch_Dropped_MultipleDropped(t *testing.T) { + // B and C both dropped. Stack has [A, D]. Original on B → should pick D (nearest above). + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "D"}}, + } + snapshot := Snapshot{ + Branches: []BranchSnapshot{ + {Name: "A", Position: 0}, + {Name: "B", Position: 1}, + {Name: "C", Position: 2}, + {Name: "D", Position: 3}, + }, + } + plan := []Action{ + {Type: "drop", Branch: "B"}, + {Type: "drop", Branch: "C"}, + } + + result := resolveCheckoutBranch("B", plan, snapshot, s) + assert.Equal(t, "D", result) +} + +func TestResolveCheckoutBranch_Fallback_EmptyStack(t *testing.T) { + // All branches removed — falls back to original (no crash). + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{}, + } + snapshot := Snapshot{ + Branches: []BranchSnapshot{{Name: "A", Position: 0}}, + } + plan := []Action{{Type: "drop", Branch: "A"}} + + result := resolveCheckoutBranch("A", plan, snapshot, s) + // No surviving branches → returns original as last resort + assert.Equal(t, "A", result) +} + +func TestResolveCheckoutBranch_Fallback_TopBranch(t *testing.T) { + // Original branch not in plan and not in stack → fallback to topmost. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "X"}, {Branch: "Y"}}, + } + snapshot := Snapshot{ + Branches: []BranchSnapshot{{Name: "A", Position: 0}}, + } + + result := resolveCheckoutBranch("A", nil, snapshot, s) + assert.Equal(t, "Y", result) +} + +func TestResolveCheckoutBranch_FoldDown_TargetRenamed(t *testing.T) { + // B is folded down into A, and A is renamed to new-A in the same operation. + // After apply, stack has [new-A, C]. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "new-A"}, {Branch: "C"}}, + } + snapshot := Snapshot{ + Branches: []BranchSnapshot{ + {Name: "A", Position: 0}, + {Name: "B", Position: 1}, + {Name: "C", Position: 2}, + }, + } + plan := []Action{ + {Type: "rename", Branch: "A", NewName: "new-A"}, + {Type: "fold_down", Branch: "B"}, + } + + result := resolveCheckoutBranch("B", plan, snapshot, s) + assert.Equal(t, "new-A", result) +} + +func TestResolveCheckoutBranch_Dropped_NeighborRenamed(t *testing.T) { + // B is dropped, and C (above) is renamed to new-C in the same operation. + // After apply, stack has [A, new-C]. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "new-C"}}, + } + snapshot := Snapshot{ + Branches: []BranchSnapshot{ + {Name: "A", Position: 0}, + {Name: "B", Position: 1}, + {Name: "C", Position: 2}, + }, + } + plan := []Action{ + {Type: "rename", Branch: "C", NewName: "new-C"}, + {Type: "drop", Branch: "B"}, + } + + result := resolveCheckoutBranch("B", plan, snapshot, s) + assert.Equal(t, "new-C", result) +} + +// ─── ApplyPlan: Checkout behavior after drop ──────────────────────────────── + +func TestApplyPlan_Drop_ChecksOutNearestBranch(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + {Branch: "C"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + "C": "sha-C", + } + + var lastCheckout string + mock := newApplyMock(gitDir, branchSHAs) + mock.CheckoutBranchFn = func(name string) error { + lastCheckout = name + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + // Drop B, user was on B + nodes := makeNodes(&sf.Stacks[0]) + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} + nodes[1].Removed = true + + _, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs) + require.NoError(t, err) + + // Should check out C (branch above B), not B + assert.Equal(t, "C", lastCheckout) +} + +func TestApplyPlan_FoldDown_ChecksOutTarget(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + } + + var lastCheckout string + mock := newApplyMock(gitDir, branchSHAs) + mock.CheckoutBranchFn = func(name string) error { + lastCheckout = name + return nil + } + mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{{SHA: "commit-1"}}, nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + // Fold B down into A, user was on B + nodes := makeNodes(&sf.Stacks[0]) + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldDown} + nodes[1].Removed = true + + _, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs) + require.NoError(t, err) + + // Should check out A (fold target), not B + assert.Equal(t, "A", lastCheckout) +} + +func TestApplyPlan_Rename_ChecksOutNewName(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + } + + var lastCheckout string + mock := newApplyMock(gitDir, branchSHAs) + mock.CheckoutBranchFn = func(name string) error { + lastCheckout = name + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + // Rename A to new-A, user was on A + nodes := makeNodes(&sf.Stacks[0]) + nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-A"} + + _, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + require.NoError(t, err) + + // Should check out new-A, not A + assert.Equal(t, "new-A", lastCheckout) +}