Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ Interactively restructure the current stack.
gh stack modify [flags]
```

Opens a terminal UI for restructuring a stack. You can rename, drop, reorder, and fold branches into adjacent ones. All the changes are staged during the preview and applied at once on save.
Opens a terminal UI for restructuring a stack. You can drop, fold, insert, rename, and reorder branches. All the changes are staged during the preview and applied at once on save.

If the stack of PRs has been created on GitHub, run `gh stack submit` afterwards to push the changes and recreate the stack.

Expand All @@ -259,6 +259,7 @@ If the stack of PRs has been created on GitHub, run `gh stack submit` afterwards
- **Drop** (`x`): Remove a branch and its commits from the stack. Local branch and associated PR are preserved.
- **Fold down** (`d`): Absorb a branch's commits into the branch below (toward trunk). Folded branch removed from stack.
- **Fold up** (`u`): Absorb a branch's commits into the branch above (away from trunk). Folded branch removed from stack.
- **Insert** (`i`/`I`): Insert a new empty branch into the stack. `i` inserts below the cursor; `I` inserts above.
- **Reorder** (`Shift+↑`/`Shift+↓`): Move a branch up (away from trunk) or down (toward trunk) in the stack.
- **Rename** (`r`): Rename a branch locally and in the stack metadata.
- **Undo** (`z`): Undo the last staged action.
Expand All @@ -272,7 +273,8 @@ If the stack of PRs has been created on GitHub, run `gh stack submit` afterwards
| `c` | View commits |
| `x` | Drop branch |
| `r` | Rename branch |
| `u/d` | Fold branch up/down |
| `i/I` | Insert branch below/above |
| `d/u` | Fold branch down/up |
| `Shift+↑`/`Shift+↓` | Move branch up/down |
| `z` | Undo last action |
| `Ctrl+S` | Apply all changes |
Expand Down
3 changes: 2 additions & 1 deletion cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
// idx < 0 means we're on the trunk — that's allowed (we'll create
// a new branch from it). Only block if we're in the middle of the stack.
if idx >= 0 && idx < len(s.Branches)-1 {
cfg.Errorf("can only add branches on top of the stack; run `%s` to switch to %q", cfg.ColorCyan("gh stack top"), s.Branches[len(s.Branches)-1].Branch)
cfg.Errorf("can only add branches to the top of the stack; run `%s` then `%s`", cfg.ColorCyan("gh stack top"), cfg.ColorCyan("gh stack add"))
cfg.Printf("Or to restructure your stack and insert a branch, use `%s`", cfg.ColorCyan("gh stack modify"))
return ErrInvalidArgs
}

Expand Down
1 change: 1 addition & 0 deletions cmd/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func TestAdd_OnlyAllowedOnTopOfStack(t *testing.T) {
output := collectOutput(cfg, outR, errR)

assert.Contains(t, output, "top of the stack")
assert.Contains(t, output, "gh stack modify")
}

func TestAdd_MutuallyExclusiveFlags(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions cmd/modify.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func ModifyCmd(cfg *config.Config) *cobra.Command {
Operations available:
• Drop branches from the stack
• Fold branches into adjacent branches
• Insert new branches into the stack
• Reorder branches
• Rename branches

Expand Down Expand Up @@ -168,6 +169,10 @@ func printModifySuccess(cfg *config.Config, result *modifyview.ApplyResult) {
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)))
Expand Down
Binary file modified docs/src/assets/screenshots/modify-stack-tui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/src/content/docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ You can also add PRs to an existing stack from the GitHub UI. See [Adding to an

### How can I modify my stack?

Use `gh stack modify` to restructure a stack. It opens an interactive terminal UI where you can reorder, drop, fold (combine), and rename branches — then applies all changes at once. See the [Restructuring Stacks](/gh-stack/guides/modify/) guide for a full walkthrough.
Use `gh stack modify` to restructure a stack. It opens an interactive terminal UI where you can reorder, drop, fold (combine), insert, and rename branches — then applies all changes at once. See the [Restructuring Stacks](/gh-stack/guides/modify/) guide for a full walkthrough.

Alternatively, you can manually tear down and re-create the stack with `gh stack unstack` and `gh stack init`:

```sh
# 1. Remove the stack
gh stack unstack

# 2. Make structural changes (reorder, rename, delete branches)
# 2. Make structural changes (reorder, rename, insert, delete branches)
git branch -m api-roots api-routes

# 3. Re-create the stack with the new structure
Expand Down
14 changes: 9 additions & 5 deletions docs/src/content/docs/guides/modify.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Restructuring Stacks
description: How to use `gh stack modify` to restructure a stack.
---

`gh stack modify` provides an interactive terminal UI for restructuring a stack locally. You can drop, fold, rename, and reorder branches and then apply all your changes at once.
`gh stack modify` provides an interactive terminal UI for restructuring a stack locally. You can drop, fold, insert, rename, and reorder branches and then apply all your changes at once.

![The modify stack terminal UI](../../../assets/screenshots/modify-stack-tui.png)

Expand All @@ -12,6 +12,7 @@ description: How to use `gh stack modify` to restructure a stack.
Use `modify` when you need to:
- **Remove** a branch from the stack
- **Combine** two branches into one
- **Insert** a new branch into the stack
- **Rename** a branch
- **Reorder** branches

Expand Down Expand Up @@ -46,21 +47,25 @@ Absorbs the selected branch's commits into the branch below it (toward trunk) vi

Absorbs the selected branch's commits into the branch above it (away from trunk). Since the branch above already contains the folded branch's commits in its history, this is handled by adjusting what is considered the first unique commit for the branch. The folded branch is removed from the stack.

### Insert below / above (`i` / `I`)

Inserts a new empty branch into the stack at the cursor position. Lowercase `i` inserts below the cursor (toward trunk); uppercase `I` inserts above the cursor (away from trunk). An inline prompt appears to enter the new branch name. The branch is created at apply time, pointing at the parent branch's tip.

### Rename (`r`)

Opens an inline prompt to enter a new name for the branch. The branch is renamed locally and in the stack metadata. On the next `submit`, the new branch name is pushed to GitHub.

### Reorder (`Shift+↑`/`Shift+↓`)

Moves the selected branch up (away from trunk) or down (toward trunk) in the stack. A cascading rebase adjusts all affected branches. Note: reordering and structural changes (drop/fold/rename) cannot be mixed in the same session.
Moves the selected branch up (away from trunk) or down (toward trunk) in the stack. A cascading rebase adjusts all affected branches. Note: reordering and structural changes (drop/fold/insert/rename) cannot be mixed in the same session.

### Undo (`z`)

Reverses the most recent staged action. You can undo multiple times to step back through your changes.

## Applying changes

Press `Ctrl+S` to apply all staged changes. Nothing is modified until you save. The apply phase renames branches, folds/drops branches, and runs a cascading rebase to create a linear commit history with the desired stack state.
Press `Ctrl+S` to apply all staged changes. Nothing is modified until you save. The apply phase renames branches, inserts new branches, folds/drops branches, and runs a cascading rebase to create a linear commit history with the desired stack state.

### Handling conflicts

Expand Down Expand Up @@ -94,8 +99,7 @@ This also works if `modify` was interrupted (e.g., terminal crash). A pre-modify
## Limitations

- Cannot modify merged branches (they are locked)
- Cannot add new branches (use `gh stack add` instead)
- Cannot split a branch into multiple branches
- Cannot move branches between different stacks
- Requires an interactive terminal
- Reordering and structural changes (drop/fold/rename) cannot be mixed in the same session
- Reordering and structural changes (drop/fold/insert/rename) cannot be mixed in the same session
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Commits created by a server-side rebase are **not signed**. If your repository r

## Unstacking

If you want to reorder or reorganize the PRs in a stack, you must first dissolve the stack and then re-create it. You can unstack PRs from the UI.
If you want to reorder or reorganize the PRs in a stack from the UI, you must first dissolve the stack and then re-create it. For CLI users, `gh stack modify` provides an interactive way to [restructure a stack](/gh-stack/guides/modify/) — including reordering, inserting, dropping, and renaming branches — without needing to dissolve it.

### Dissolving the Entire Stack

Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/guides/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ All branches in a stack should be part of the same feature or project. If you ne

## Restructuring a Stack

When you need to change the composition of a stack — remove a branch, combine branches, change the order, or rename a branch — use `gh stack modify`:
When you need to change the composition of a stack — remove a branch, combine branches, insert a new branch, change the order, or rename a branch — use `gh stack modify`:

```sh
# Open the modify TUI
Expand All @@ -309,6 +309,8 @@ gh stack modify
# x → drop a branch
# d → fold down (into branch below)
# u → fold up (into branch above)
# i → insert below
# I → insert above
# Shift+↑/↓ → reorder
# r → rename
# z → undo
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/introduction/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ While the PR UI provides the review and merge experience, the `gh stack` CLI han
- **Creating PRs** — `gh stack submit` pushes branches and creates or updates PRs, linking them as a Stack on GitHub.
- **Navigating the stack** — `gh stack up`, `down`, `top`, and `bottom` let you move between layers without remembering branch names.
- **Syncing everything** — `gh stack sync` fetches, rebases, pushes, and updates PR state in one command.
- **Tearing down stacks** — `gh stack unstack` removes a stack from GitHub and local tracking if you need to restructure it.
- **Restructuring stacks** — `gh stack modify` opens an interactive terminal UI to drop, fold, insert, rename, and reorder branches in a stack.
- **Tearing down stacks** — `gh stack unstack` removes a stack from GitHub and local tracking.
- **Checking out a stack** — `gh stack checkout <pr-number>` pulls down a stack, with all its branches, from GitHub to your local machine.

The CLI is not required to use Stacked PRs — the underlying git operations are standard. But it makes the workflow simpler, and you can create Stacked PRs from the CLI instead of the UI.
Expand Down
6 changes: 4 additions & 2 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,16 @@ The command checks these conditions before opening the TUI:
| Drop | `x` | Remove branch and its commits from stack. Local branch and associated PR are preserved. |
| Fold down | `d` | Absorb commits into branch below (toward trunk). Folded branch removed from stack. |
| Fold up | `u` | Absorb commits into branch above (away from trunk). Folded branch removed from stack. |
| Insert below | `i` | Insert a new empty branch below the cursor (toward trunk). |
| Insert above | `I` | Insert a new empty branch above the cursor (away from trunk). |
| Move down | `Shift+↓` | Reorder branch down (toward trunk) in the stack |
| Move up | `Shift+↑` | Reorder branch up (away from trunk) in the stack |
| Rename | `r` | Rename the branch (opens inline prompt) |
| Undo | `z` | Undo the last staged action |

**Apply phase:**

When you press `Ctrl+S`, the staged changes are applied by renaming branches, folding/dropping branches, and running a cascading rebase to create a linear commit history with the desired stack state.
When you press `Ctrl+S`, the staged changes are applied by renaming branches, inserting new branches, folding/dropping branches, and running a cascading rebase to create a linear commit history with the desired stack state.

If a rebase conflict occurs, you can:
- Resolve conflicts, stage files, and run `gh stack modify --continue`
Expand Down Expand Up @@ -234,7 +236,7 @@ You must have a branch from the stack checked out locally. The command targets t

Deletes the stack on GitHub first, if it exists, then removes it from local tracking. If the remote deletion fails, the local state is left untouched so you can retry. Use `--local` to skip the remote deletion and only remove local tracking.

This is useful when you need to restructure a stack — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init` to re-create the stack with the desired structure — existing branches are adopted automatically.
This is useful when you need to restructure a stack — remove a branch, insert a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init` to re-create the stack with the desired structure — existing branches are adopted automatically.

| Flag | Description |
|------|-------------|
Expand Down
127 changes: 118 additions & 9 deletions internal/modify/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,18 @@ func BuildSnapshot(s *stack.Stack) (Snapshot, error) {
func BuildPlan(nodes []modifyview.ModifyBranchNode) []Action {
var plan []Action

// When computing move detection, skip inserted nodes since they
// shift the indices of existing nodes.
effectiveIdx := 0
for i, n := range nodes {
if n.PendingAction == nil && n.OriginalPosition == i && !n.Removed {
continue
if n.IsInserted {
// Inserted nodes always have a PendingAction — handle below
} else {
if n.PendingAction == nil && n.OriginalPosition == effectiveIdx && !n.Removed {
effectiveIdx++
continue
}
effectiveIdx++
}

if n.Removed {
Expand All @@ -69,10 +78,14 @@ func BuildPlan(nodes []modifyview.ModifyBranchNode) []Action {
if n.PendingAction.Type == modifyview.ActionRename {
action.NewName = n.PendingAction.NewName
}
if n.PendingAction.Type == modifyview.ActionInsertBelow || n.PendingAction.Type == modifyview.ActionInsertAbove {
action.NewName = n.PendingAction.NewName
action.NewPosition = i
}
plan = append(plan, action)
}

if n.OriginalPosition != i && n.PendingAction == nil {
if !n.IsInserted && n.OriginalPosition != i && n.PendingAction == nil {
plan = append(plan, Action{
Type: "move",
Branch: n.Ref.Branch,
Expand Down Expand Up @@ -216,7 +229,103 @@ func ApplyPlan(
}
}

// Step 2: Folds — absorb one branch's commits into an adjacent branch.
// Step 2: Inserts — create new branches and add to stack metadata.
// Process in order so positions are stable. The node's position in the
// non-removed list determines the parent branch.
for _, n := range nodes {
if n.PendingAction == nil {
continue
}
if n.PendingAction.Type != modifyview.ActionInsertBelow && n.PendingAction.Type != modifyview.ActionInsertAbove {
continue
}

newName := n.PendingAction.NewName

// Determine the parent branch: find the position of this node among
// the non-removed, non-merged nodes in the apply-order list, then
// look at the branch just before it (toward trunk).
var parentBranch string
insertPos := -1

// Determine where in s.Branches the new branch should go.
// Walk the non-removed nodes to find the relative position.
nonRemovedPos := 0
for _, other := range nodes {
if other.Removed || other.Ref.IsMerged() {
continue
}
if other.Ref.Branch == newName {
insertPos = nonRemovedPos
break
}
nonRemovedPos++
}

if insertPos <= 0 {
parentBranch = s.Trunk.Branch
} else {
// Find the branch at insertPos-1 among active branches
activeCount := 0
for _, b := range s.Branches {
if b.IsMerged() {
continue
}
if activeCount == insertPos-1 {
parentBranch = b.Branch
break
}
activeCount++
}
if parentBranch == "" {
parentBranch = s.Trunk.Branch
}
}

// Create the git branch at the parent's tip
if err := git.CreateBranch(newName, parentBranch); err != nil {
unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan)
if unwindErr != nil {
return nil, nil, fmt.Errorf("creating branch %s failed (%v) and unwind failed (%v)", newName, err, unwindErr)
}
return nil, nil, fmt.Errorf("creating branch %s from %s: %w", newName, parentBranch, err)
}
Comment thread
skarim marked this conversation as resolved.

// Insert BranchRef into s.Branches at the correct position
newRef := stack.BranchRef{Branch: newName}
targetIdx := len(s.Branches) // default: append at end
if insertPos >= 0 {
// Map the active position back to s.Branches index
activeCount := 0
for j, b := range s.Branches {
if b.IsMerged() {
continue
}
if activeCount == insertPos {
targetIdx = j
break
}
activeCount++
}
}
s.Branches = append(s.Branches, stack.BranchRef{})
copy(s.Branches[targetIdx+1:], s.Branches[targetIdx:])
s.Branches[targetIdx] = newRef

// Check if the branch above the insertion point has a PR —
// its base changes, so we need a submit
if targetIdx < len(s.Branches)-1 {
above := s.Branches[targetIdx+1]
if above.PullRequest != nil {
affectsPRs = true
}
}

result.InsertedBranches = append(result.InsertedBranches, newName)
cfg.Successf("Inserted %s after %s", newName, parentBranch)
}

// Step 3: Folds — absorb one branch's commits into an adjacent branch.
//
// Fold-down: cherry-pick the folded branch's commits onto the target below.
// The target is below in the stack (closer to trunk), so it doesn't
Expand Down Expand Up @@ -347,7 +456,7 @@ func ApplyPlan(
}
}

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

// Step 4: Reorder — build the desired branch order from the remaining nodes
// Step 5: Reorder — build the desired branch order from the remaining nodes
desiredOrder := make([]string, 0)
for _, n := range nodes {
if n.Removed {
Expand Down Expand Up @@ -439,7 +548,7 @@ func ApplyPlan(
s.Branches = newBranches
}

// Step 5: Cascading rebase — rebase each active branch onto its new parent.
// Step 6: Cascading rebase — rebase each active branch onto its new parent.
// Use the original parent tip SHA as the oldBase for --onto, so that only
// the branch's own commits are replayed onto the new parent.
for i, b := range s.Branches {
Expand Down Expand Up @@ -896,9 +1005,9 @@ func Unwind(cfg *config.Config, gitDir string, snapshot Snapshot, stackIndex int
}
}

// Clean up branches created by renames during the partial apply
// Clean up branches created by renames or inserts during the partial apply
for _, action := range plan {
if action.Type == "rename" && action.NewName != "" {
if action.NewName != "" && (action.Type == "rename" || action.Type == "insert_below" || action.Type == "insert_above") {
if !snapshotNames[action.NewName] && git.BranchExists(action.NewName) {
_ = git.DeleteBranch(action.NewName, true)
}
Expand Down
Loading
Loading