Files
github__gh-stack/cmd/modify.go
T
Sameen Karim 49a753708c insert branches with modify (#107)
* 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

* update add error msg to direct users to modify for inserting branches

* docs updates

* fix insert branch bugs in modify TUI

Fix three bugs with the insert branch feature in the modify TUI, and
adjust rename behavior on inserted nodes.

## Bug 1: False "moved" annotations on existing branches

After inserting a branch, all branches below the insertion point
displayed "↕ moved 1 layer down" annotations. This happened because
`nodeAnnotation` and `toNodeData` compared each node's
`OriginalPosition` against its raw array index, which gets shifted
when an inserted node is added to the slice.

Fix: introduce an `effectiveIdx` parameter that counts only
non-inserted nodes, so position comparisons reflect the original
ordering. The View loop computes effective indices by incrementing
only for non-inserted nodes and passes them to the rendering
functions.

## Bug 2: Header branch count inflated by staged inserts

The branch count in the header ("N branches") included inserted
placeholder nodes, making it appear as though the stack had grown
before changes were applied.

Fix: `buildHeaderConfig` now excludes `IsInserted` nodes from the
branch count. The count reflects only the original branches in the
stack.

## Bug 3: Operations allowed on inserted placeholder nodes

Inserted nodes could be folded into other branches, which makes no
sense for a placeholder with no commits. Additionally, the "last
branch" guard counted inserted nodes as active, allowing users to
drop all original branches and bypass the empty-stack check.

Fix:
- `fold()` rejects inserted nodes with a descriptive error message.
- `toggleDrop()` on an inserted node removes it entirely and pops
  the original insert action from the undo stack (clean cancellation
  rather than a separate undo entry).
- All three "active branch" guards (`toggleDrop`, `fold`, `tryApply`)
  now exclude `IsInserted` nodes, ensuring at least one original
  branch always remains in the stack.

## Rename on inserted branches

Instead of blocking renames on inserted nodes, pressing `r` now
enters rename mode and updates the insert action's name in place.
The node's `Ref.Branch` and `PendingAction.NewName` are both updated
directly — no separate rename action is created in the undo stack.
This lets users fix a typo without having to drop and re-insert.

## Tests added

- `TestInsertDoesNotShowMovedAnnotation` — verifies no false move
  annotations appear on existing branches after an insert
- `TestBranchCountExcludesInserts` — verifies header count stays
  stable after insert
- `TestCannotFoldInsertedBranch` — verifies fold is blocked
- `TestCannotRenameInsertedBranch` — verifies rename updates the
  insert name in place
- `TestDropInsertedBranchRemovesIt` — verifies drop removes the node
- `TestDropInsertedBranchCanBeUndone` — verifies drop pops the
  original insert from the undo stack
- `TestCannotDropAllOriginalBranchesWithInsert` — verifies the
  empty-stack guard excludes inserted nodes

* ensure cannot fold into an inserted branch

* rm dead code

* delete inserted branches during abort
2026-05-26 17:39:38 -04:00

354 lines
9.5 KiB
Go

package cmd
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/modify"
"github.com/github/gh-stack/internal/tui/modifyview"
"github.com/github/gh-stack/internal/tui/stackview"
"github.com/spf13/cobra"
)
type modifyOptions struct {
abort bool
cont bool
}
func ModifyCmd(cfg *config.Config) *cobra.Command {
opts := &modifyOptions{}
cmd := &cobra.Command{
Use: "modify",
Short: "Interactively restructure a stack",
Long: `Open an interactive TUI to restructure the current stack.
Operations available:
• Drop branches from the stack
• Fold branches into adjacent branches
• Insert new branches into the stack
• Reorder branches
• Rename branches
All changes are staged in the TUI and applied together when you press Ctrl+S.
If your changes affect branches with pull requests, run 'gh stack submit'
afterward to push changes, update PRs, and recreate the stack on GitHub.`,
Example: ` # Open the interactive TUI to restructure the stack
$ gh stack modify
# Abort a modify session and restore the stack
$ gh stack modify --abort
# Continue after resolving conflicts from a modify
$ gh stack modify --continue`,
RunE: func(cmd *cobra.Command, args []string) error {
if opts.abort {
return runModifyAbort(cfg)
}
if opts.cont {
return runModifyContinue(cfg)
}
return runModify(cfg)
},
}
cmd.Flags().BoolVar(&opts.abort, "abort", false, "Abort the modify session and restore the stack to its pre-modify state")
cmd.Flags().BoolVar(&opts.cont, "continue", false, "Continue after resolving conflicts")
return cmd
}
func runModify(cfg *config.Config) error {
// Run all precondition checks
result, err := checkModifyPreconditions(cfg)
if err != nil {
return err
}
gitDir := result.GitDir
sf := result.StackFile
s := result.Stack
currentBranch := result.CurrentBranch
// Load branch data for the TUI
viewNodes := stackview.LoadBranchNodes(cfg, s, currentBranch, result.PRDetails)
// Reverse so index 0 = top of stack (matching visual order)
reversed := make([]stackview.BranchNode, len(viewNodes))
for i, n := range viewNodes {
reversed[len(viewNodes)-1-i] = n
}
// Convert to ModifyBranchNodes
modifyNodes := make([]modifyview.ModifyBranchNode, len(reversed))
for i, n := range reversed {
modifyNodes[i] = modifyview.ModifyBranchNode{
BranchNode: n,
OriginalPosition: i,
}
}
// Run the TUI
model := modifyview.New(modifyNodes, s.Trunk, Version)
p := tea.NewProgram(
model,
tea.WithAltScreen(),
tea.WithMouseAllMotion(),
)
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("running TUI: %w", err)
}
m, ok := finalModel.(modifyview.Model)
if !ok {
return fmt.Errorf("unexpected model type")
}
// Handle TUI result
if m.Cancelled() {
return nil
}
if !m.ApplyRequested() {
return nil
}
// Apply the staged changes
// Re-reverse nodes back to stack order (bottom to top) for the apply engine
applyNodes := m.Nodes()
reordered := make([]modifyview.ModifyBranchNode, len(applyNodes))
for i, n := range applyNodes {
reordered[len(applyNodes)-1-i] = n
}
applyResult, conflict, applyErr := modify.ApplyPlan(cfg, gitDir, s, sf, reordered, currentBranch, updateBaseSHAs)
if conflict != nil {
isCherryPick := applyErr != nil && strings.Contains(applyErr.Error(), "cherry-pick")
if isCherryPick {
cfg.Warningf("Cherry-pick conflict folding %s", conflict.Branch)
} else {
cfg.Warningf("Rebasing %s — conflict", conflict.Branch)
}
printConflictDetailsWithContinue(cfg, conflict.Branch, "gh stack modify --continue")
cfg.Printf("")
cfg.Printf("Or restore the stack to its pre-modify state with `%s`",
cfg.ColorCyan("gh stack modify --abort"))
return ErrConflict
}
if applyErr != nil {
cfg.Errorf("failed to apply modifications: %s", applyErr)
return ErrSilent
}
// Print success summary
printModifySuccess(cfg, applyResult)
return nil
}
// printModifySuccess prints a summary of what was applied.
func printModifySuccess(cfg *config.Config, result *modifyview.ApplyResult) {
if result == nil {
return
}
cfg.Printf("")
cfg.Successf("Stack modified successfully")
for _, r := range result.RenamedBranches {
cfg.Printf(" Renamed: %s → %s", r.OldName, r.NewName)
}
for _, name := range result.InsertedBranches {
cfg.Printf(" Inserted: %s", name)
}
for _, d := range result.DroppedPRs {
cfg.Printf(" Dropped: %s (PR #%d remains open — close with `%s`)",
d.Branch, d.PRNumber, cfg.ColorCyan(fmt.Sprintf("gh pr close %d", d.PRNumber)))
}
if result.MovedBranches > 0 {
cfg.Printf(" Rebased %d %s", result.MovedBranches,
plural(result.MovedBranches, "branch", "branches"))
}
cfg.Printf("")
if result.NeedsSubmit {
cfg.Printf("Run `%s` to push your changes and update the stack of PRs on GitHub",
cfg.ColorCyan("gh stack submit"))
}
}
// runModifyAbort handles recovery to a pre-modify state.
func runModifyAbort(cfg *config.Config) error {
gitDir, err := git.GitDir()
if err != nil {
cfg.Errorf("not a git repository")
return ErrNotInStack
}
state, err := modify.LoadState(gitDir)
if err != nil {
cfg.Errorf("failed to read modify state: %s", err)
return ErrSilent
}
if state == nil {
cfg.Printf("No modify session to abort")
return nil
}
switch state.Phase {
case modify.PhaseApplying:
cfg.Printf("A modify session was interrupted during the apply phase")
cfg.Printf("Restoring stack to pre-modify state...")
if err := modify.UnwindFromStateFile(cfg, gitDir); err != nil {
cfg.Errorf("recovery failed: %s", err)
cfg.Printf("The stack may be in an inconsistent state.")
cfg.Printf("Try `%s` to fix, or `%s` + `%s` to recreate.",
cfg.ColorCyan("gh stack rebase"), cfg.ColorCyan("gh stack unstack --local"),
cfg.ColorCyan("gh stack init --adopt"))
return ErrSilent
}
cfg.Successf("Stack restored successfully")
return nil
case modify.PhasePendingSubmit:
cfg.Printf("A modify completed but the stack has not been submitted")
cfg.Printf("Run `%s` to push changes and recreate the stack on GitHub",
cfg.ColorCyan("gh stack submit"))
return nil
default:
cfg.Errorf("unexpected modify state phase: %s", state.Phase)
cfg.Printf("Clearing invalid state file...")
modify.ClearState(gitDir)
return nil
}
}
// runModifyContinue continues applying after the user resolves a rebase conflict.
func runModifyContinue(cfg *config.Config) error {
gitDir, err := git.GitDir()
if err != nil {
cfg.Errorf("not a git repository")
return ErrNotInStack
}
if err := modify.ContinueApply(cfg, gitDir, updateBaseSHAs); err != nil {
cfg.Errorf("%s", err)
return ErrConflict
}
return nil
}
// ---------------------------------------------------------------------------
// Preconditions
// ---------------------------------------------------------------------------
// checkModifyPreconditions runs all precondition checks for the modify command.
func checkModifyPreconditions(cfg *config.Config) (*loadStackResult, error) {
if !cfg.IsInteractive() {
cfg.Errorf("modify requires an interactive terminal")
return nil, ErrSilent
}
result, err := loadStack(cfg, "")
if err != nil {
return nil, ErrNotInStack
}
gitDir := result.GitDir
s := result.Stack
// No existing modify state file
if err := checkNoModifyInProgress(cfg, gitDir); err != nil {
return nil, err
}
// No rebase in progress
if git.IsRebaseInProgress() {
cfg.Errorf("a rebase is currently in progress")
cfg.Printf("Complete the rebase with `%s` or abort with `%s`",
cfg.ColorCyan("gh stack rebase --continue"),
cfg.ColorCyan("gh stack rebase --abort"))
return nil, ErrRebaseActive
}
// Clean working tree
if dirty, err := git.HasUncommittedChanges(); err != nil {
cfg.Errorf("failed to check working tree status: %s", err)
return nil, ErrSilent
} else if dirty {
cfg.Errorf("uncommitted changes in working tree")
cfg.Printf("Commit or stash your changes before running modify")
return nil, ErrSilent
}
// Show loading indicator while syncing PRs
fmt.Fprintf(cfg.Err, "Loading stack...")
// Sync PR state and check merge queue
prDetails := syncStackPRs(cfg, s)
result.PRDetails = prDetails
fmt.Fprintf(cfg.Err, "\r\033[2K")
if err := modify.CheckNoMergeQueuePRs(cfg, s); err != nil {
return nil, ErrSilent
}
// Stack linearity check
if err := modify.CheckStackLinearity(cfg, s); err != nil {
return nil, ErrSilent
}
return result, nil
}
// checkNoModifyInProgress checks if a modify state file already exists.
func checkNoModifyInProgress(cfg *config.Config, gitDir string) error {
state, err := modify.LoadState(gitDir)
if err != nil {
cfg.Warningf("failed to read modify state: %v", err)
return nil
}
if state == nil {
return nil
}
switch state.Phase {
case modify.PhaseApplying:
cfg.Errorf("a previous modify session was interrupted")
cfg.Printf("Run `%s` to restore your stack",
cfg.ColorCyan("gh stack modify --abort"))
return ErrModifyRecovery
case modify.PhaseConflict:
cfg.Errorf("a modify has unresolved conflicts")
cfg.Printf("Run `%s` to continue, or `%s` to restore your stack",
cfg.ColorCyan("gh stack modify --continue"),
cfg.ColorCyan("gh stack modify --abort"))
return ErrSilent
case modify.PhasePendingSubmit:
cfg.Errorf("a modify was completed but the stack has not been submitted yet")
cfg.Printf("Run `%s` to push changes and recreate the stack on GitHub",
cfg.ColorCyan("gh stack submit"))
return ErrSilent
default:
cfg.Errorf("unexpected modify state phase: %s", state.Phase)
return ErrSilent
}
}