Skip to content

Commit 8dd6ae0

Browse files
committed
add insert branch operation to modify TUI
Add `i` (insert below) and `I` (insert above) key bindings to the interactive modify view, allowing users to insert new empty branches into an existing stack. This follows Vim-inspired semantics where lowercase `i` inserts below the cursor and uppercase `I` inserts above. ## TUI behavior When the user presses `i` or `I`, the TUI enters an insert input mode (similar to rename mode) where they type a new branch name. The input is validated against git ref naming rules, local branch uniqueness, and in-stack name collisions. On confirm, a placeholder node is inserted at the correct position in the branch list with a green "✚ insert" annotation badge and green connector styling. Insert is a structure operation — it works alongside fold, rename, and drop, but is mutually exclusive with reorder (consistent with existing mode exclusivity rules). Undo (`z`) removes the inserted node cleanly. ## Apply engine At apply time (Step 2 in the pipeline, between renames and folds), the engine creates the new git branch at the parent branch's tip via `git.CreateBranch` and inserts a `BranchRef` into the stack metadata at the correct position. If the insertion changes the base of a branch that has an open PR, `affectsPRs` is set to trigger a required `gh stack submit` afterward. ## Header shortcut updates - Combined the fold shortcuts into a single line: `d/u - fold down/up` - Added insert shortcuts on their own line: `i/I - insert below/above` - Reordered fold references throughout to list "down" before "up" for consistency with the insert shortcut ordering ## Files changed - types.go: ActionInsertBelow/ActionInsertAbove types, IsInserted field, InsertedBranches in ApplyResult - model.go: key bindings, insert input mode, undo, mode exclusivity, annotation, styling, header shortcuts, effective-index tracking to prevent false reorder detection when inserts shift node positions - styles.go: green insert badge/branch/connector styles - status.go: insert counting in pending change summary - help.go: new "Insert below / above" section, reordered fold heading - apply.go: BuildPlan and ApplyPlan handle insert actions - modify.go: updated command description and success summary - README.md: updated keybindings table ## Test coverage - 16 new TUI tests: insert below/above, top/bottom edges, undo, mode exclusivity, merged branch guard, cancel/empty input, duplicate name validation, pending summary counting, annotation rendering, mixed operations with drop/fold, apply acceptance - 4 new apply tests: BuildPlan produces correct insert actions, ApplyPlan creates branches and updates stack metadata, insert at stack start uses trunk as parent, affectsPRs triggered when inserting before a branch with an open PR
1 parent abd7102 commit 8dd6ae0

9 files changed

Lines changed: 1003 additions & 45 deletions

File tree

cmd/modify.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func ModifyCmd(cfg *config.Config) *cobra.Command {
2929
Operations available:
3030
• Drop branches from the stack
3131
• Fold branches into adjacent branches
32+
• Insert new branches into the stack
3233
• Reorder branches
3334
• Rename branches
3435
@@ -167,6 +168,10 @@ func printModifySuccess(cfg *config.Config, result *modifyview.ApplyResult) {
167168
cfg.Printf(" Renamed: %s → %s", r.OldName, r.NewName)
168169
}
169170

171+
for _, name := range result.InsertedBranches {
172+
cfg.Printf(" Inserted: %s", name)
173+
}
174+
170175
for _, d := range result.DroppedPRs {
171176
cfg.Printf(" Dropped: %s (PR #%d remains open — close with `%s`)",
172177
d.Branch, d.PRNumber, cfg.ColorCyan(fmt.Sprintf("gh pr close %d", d.PRNumber)))

internal/modify/apply.go

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,18 @@ func BuildSnapshot(s *stack.Stack) (Snapshot, error) {
5252
func BuildPlan(nodes []modifyview.ModifyBranchNode) []Action {
5353
var plan []Action
5454

55+
// When computing move detection, skip inserted nodes since they
56+
// shift the indices of existing nodes.
57+
effectiveIdx := 0
5558
for i, n := range nodes {
56-
if n.PendingAction == nil && n.OriginalPosition == i && !n.Removed {
57-
continue
59+
if n.IsInserted {
60+
// Inserted nodes always have a PendingAction — handle below
61+
} else {
62+
if n.PendingAction == nil && n.OriginalPosition == effectiveIdx && !n.Removed {
63+
effectiveIdx++
64+
continue
65+
}
66+
effectiveIdx++
5867
}
5968

6069
if n.Removed {
@@ -69,10 +78,14 @@ func BuildPlan(nodes []modifyview.ModifyBranchNode) []Action {
6978
if n.PendingAction.Type == modifyview.ActionRename {
7079
action.NewName = n.PendingAction.NewName
7180
}
81+
if n.PendingAction.Type == modifyview.ActionInsertBelow || n.PendingAction.Type == modifyview.ActionInsertAbove {
82+
action.NewName = n.PendingAction.NewName
83+
action.NewPosition = i
84+
}
7285
plan = append(plan, action)
7386
}
7487

75-
if n.OriginalPosition != i && n.PendingAction == nil {
88+
if !n.IsInserted && n.OriginalPosition != i && n.PendingAction == nil {
7689
plan = append(plan, Action{
7790
Type: "move",
7891
Branch: n.Ref.Branch,
@@ -216,7 +229,116 @@ func ApplyPlan(
216229
}
217230
}
218231

219-
// Step 2: Folds — absorb one branch's commits into an adjacent branch.
232+
// Step 2: Inserts — create new branches and add to stack metadata.
233+
// Process in order so positions are stable. The node's position in the
234+
// non-removed list determines the parent branch.
235+
for _, n := range nodes {
236+
if n.PendingAction == nil {
237+
continue
238+
}
239+
if n.PendingAction.Type != modifyview.ActionInsertBelow && n.PendingAction.Type != modifyview.ActionInsertAbove {
240+
continue
241+
}
242+
243+
newName := n.PendingAction.NewName
244+
245+
// Determine the parent branch: find the position of this node among
246+
// the non-removed, non-merged nodes in the apply-order list, then
247+
// look at the branch just before it (toward trunk).
248+
var parentBranch string
249+
insertPos := -1
250+
251+
// Build the active branch order from the stack (as of now, after renames)
252+
activeIdx := 0
253+
for _, b := range s.Branches {
254+
if b.IsMerged() {
255+
continue
256+
}
257+
if b.Branch == newName {
258+
// already added in a previous iteration — skip
259+
break
260+
}
261+
activeIdx++
262+
}
263+
264+
// Determine where in s.Branches the new branch should go.
265+
// Walk the non-removed nodes to find the relative position.
266+
nonRemovedPos := 0
267+
for _, other := range nodes {
268+
if other.Removed || other.Ref.IsMerged() {
269+
continue
270+
}
271+
if other.Ref.Branch == newName {
272+
insertPos = nonRemovedPos
273+
break
274+
}
275+
nonRemovedPos++
276+
}
277+
278+
if insertPos <= 0 {
279+
parentBranch = s.Trunk.Branch
280+
} else {
281+
// Find the branch at insertPos-1 among active branches
282+
activeCount := 0
283+
for _, b := range s.Branches {
284+
if b.IsMerged() {
285+
continue
286+
}
287+
if activeCount == insertPos-1 {
288+
parentBranch = b.Branch
289+
break
290+
}
291+
activeCount++
292+
}
293+
if parentBranch == "" {
294+
parentBranch = s.Trunk.Branch
295+
}
296+
}
297+
298+
// Create the git branch at the parent's tip
299+
if err := git.CreateBranch(newName, parentBranch); err != nil {
300+
unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan)
301+
if unwindErr != nil {
302+
return nil, nil, fmt.Errorf("creating branch %s failed (%v) and unwind failed (%v)", newName, err, unwindErr)
303+
}
304+
return nil, nil, fmt.Errorf("creating branch %s from %s: %w", newName, parentBranch, err)
305+
}
306+
307+
// Insert BranchRef into s.Branches at the correct position
308+
newRef := stack.BranchRef{Branch: newName}
309+
targetIdx := len(s.Branches) // default: append at end
310+
if insertPos >= 0 {
311+
// Map the active position back to s.Branches index
312+
activeCount := 0
313+
for j, b := range s.Branches {
314+
if b.IsMerged() {
315+
continue
316+
}
317+
if activeCount == insertPos {
318+
targetIdx = j
319+
break
320+
}
321+
activeCount++
322+
}
323+
}
324+
s.Branches = append(s.Branches, stack.BranchRef{})
325+
copy(s.Branches[targetIdx+1:], s.Branches[targetIdx:])
326+
s.Branches[targetIdx] = newRef
327+
328+
// Check if the branch above the insertion point has a PR —
329+
// its base changes, so we need a submit
330+
if targetIdx < len(s.Branches)-1 {
331+
above := s.Branches[targetIdx+1]
332+
if above.PullRequest != nil {
333+
affectsPRs = true
334+
}
335+
}
336+
337+
result.InsertedBranches = append(result.InsertedBranches, newName)
338+
cfg.Successf("Inserted %s after %s", newName, parentBranch)
339+
}
340+
341+
// Step 3: Folds — absorb one branch's commits into an adjacent branch.
220342
//
221343
// Fold-down: cherry-pick the folded branch's commits onto the target below.
222344
// The target is below in the stack (closer to trunk), so it doesn't
@@ -347,7 +469,7 @@ func ApplyPlan(
347469
}
348470
}
349471

350-
// Step 3: Drops — remove from stack metadata
472+
// Step 4: Drops — remove from stack metadata
351473
// Process in reverse order to preserve indices
352474
for i := len(nodes) - 1; i >= 0; i-- {
353475
n := nodes[i]
@@ -373,7 +495,7 @@ func ApplyPlan(
373495
cfg.Successf("Dropped %s from stack", dropBranch)
374496
}
375497

376-
// Step 4: Reorder — build the desired branch order from the remaining nodes
498+
// Step 5: Reorder — build the desired branch order from the remaining nodes
377499
desiredOrder := make([]string, 0)
378500
for _, n := range nodes {
379501
if n.Removed {
@@ -439,7 +561,7 @@ func ApplyPlan(
439561
s.Branches = newBranches
440562
}
441563

442-
// Step 5: Cascading rebase — rebase each active branch onto its new parent.
564+
// Step 6: Cascading rebase — rebase each active branch onto its new parent.
443565
// Use the original parent tip SHA as the oldBase for --onto, so that only
444566
// the branch's own commits are replayed onto the new parent.
445567
for i, b := range s.Branches {

0 commit comments

Comments
 (0)