How the `gh stack modify` Command Restructures Git Stacks: A Deep Dive into the Interactive Workflow
The gh stack modify command restructures Git stacks through a ten-stage pipeline that converts TUI interactions into concrete Git operations, including renames, inserts, folds, drops, and reorders, followed by a cascading rebase with full snapshot-based recovery capabilities.
The gh stack modify command in the github/gh-stack repository provides an interactive terminal interface for reshaping stacked pull requests. It allows developers to drop, fold, insert, reorder, and rename branches within a stack while maintaining data integrity through deterministic execution and rollback capabilities.
Pre-Flight Checks: Validating the Repository State
Before launching the interactive interface, checkModifyPreconditions in [cmd/modify.go](https://github.com/github/gh-stack/blob/main/cmd/modify.go#L74-L98) validates the environment. This function ensures the terminal is interactive, no rebase is in progress, the working tree is clean, and no existing modify session is active. It also verifies the presence of a trunk branch, confirms PRs are synced, and checks stack linearity to prevent operations on corrupted metadata.
Loading and Preparing the Interactive UI
Once preconditions pass, the command loads the current stack state using loadStack and retrieves PR details. The raw stack data transforms into visual components through stackview.LoadBranchNodes, which creates stackview.BranchNode objects. The command reverses this list so the top of the visual stack corresponds to index 0, then wraps each node into modifyview.ModifyBranchNode structures that carry UI-specific state including OriginalPosition, Removed, and PendingAction fields ([cmd/modify.go](https://github.com/github/gh-stack/blob/main/cmd/modify.go#L85-L100)).
func runModify(cfg *config.Config) error {
result, _ := checkModifyPreconditions(cfg)
viewNodes := stackview.LoadBranchNodes(cfg, result.Stack, result.CurrentBranch, result.PRDetails)
// reverse for visual order
reversed := make([]stackview.BranchNode, len(viewNodes))
for i, n := range viewNodes { reversed[len(viewNodes)-1-i] = n }
// wrap for modify UI
modifyNodes := make([]modifyview.ModifyBranchNode, len(reversed))
for i, n := range reversed {
modifyNodes[i] = modifyview.ModifyBranchNode{BranchNode: n, OriginalPosition: i}
}
// start Bubble Tea UI
m := modifyview.New(modifyNodes, result.Stack.Trunk, Version)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseAllMotion())
final, _ := p.Run()
// ...
}
Capturing User Intent in the Bubble Tea TUI
The command instantiates a modifyview.Model with the prepared nodes and executes it via tea.NewProgram ([cmd/modify.go](https://github.com/github/gh-stack/blob/main/cmd/modify.go#L104-L112)). Users manipulate branches within the terminal UI; when they press Ctrl+S, the model returns an ApplyResult containing the staged actions. This result captures every rename, insertion, deletion, and reorder operation requested by the user.
Building the Execution Plan
modify.BuildPlan in [internal/modify/apply.go](https://github.com/github/gh-stack/blob/main/internal/modify/apply.go#L50-L99) processes the final ModifyBranchNode slice and generates a concrete slice of modify.Action structs. Each struct describes a specific operation—rename, insert, move, fold, or drop—with precise source and target parameters. This plan serves as the authoritative blueprint for all subsequent Git mutations.
Creating Safety Snapshots and State Files
Before mutating the repository, modify.BuildSnapshot captures the complete pre-modify state including all branch names, tip SHAs, and the full stack JSON. This Snapshot enables full unwinding if operations fail. The command then persists a StateFile to .git/gh-stack.modify with phase PhaseApplying, storing the snapshot, execution plan, stack index, and timestamps ([internal/modify/apply.go](https://github.com/github/gh-stack/blob/main/internal/modify/apply.go#L36-L47)). This state file powers the --abort and --continue recovery workflows.
Executing the Five-Phase Restructuring Pipeline
The ApplyPlan function executes modifications through five distinct phases, each handling a specific restructuring operation.
Phase 1: Branch Renames
The git.RenameBranch function updates both Git refs and the in-memory stack.Stack structure ([internal/modify/apply.go](https://github.com/github/gh-stack/blob/main/internal/modify/apply.go#L90-L103)). This ensures the stack metadata remains synchronized with the actual repository state.
Phase 2: Inserting New Branches
For insertions, the command calls git.CreateBranch to generate new branches from the appropriate parent tip, then inserts them into the s.Branches slice at calculated positions ([internal/modify/apply.go](https://github.com/github/gh-stack/blob/main/internal/modify/apply.go#L124-L150).
Phase 3: Folding Branches
Folding operates in two directions:
- Fold-down: Commits are cherry-picked onto the lower branch. If conflicts occur, the system saves a
PhaseConflictstate to enable resumption ([internal/modify/apply.go](https://github.com/github/gh-stack/blob/main/internal/modify/apply.go#L161-L203)). - Fold-up: Only the
originalParentTipsmap is adjusted; the subsequent cascading rebase replays both sets of commits onto the new parent ([internal/modify/apply.go](https://github.com/github/gh-stack/blob/main/internal/modify/apply.go#L221-L236)).
Phase 4: Dropping Branches
Dropped branches are removed from the stack metadata. If branches have open PRs, they are recorded for later user cleanup rather than immediately deleted ([internal/modify/apply.go](https://github.com/github/gh-stack/blob/main/internal/modify/apply.go#L260-L283).
Phase 5: Reordering the Stack
The reorder phase builds the desired order from remaining nodes, compares it against the current order, and reconstructs the s.Branches slice to match the user's visual arrangement ([internal/modify/apply.go](https://github.com/github/gh-stack/blob/main/internal/modify/apply.go#L306-L349).
Cascading Rebase and Conflict Handling
After structural changes, each active branch undergoes a cascading rebase using git.RebaseOnto. The rebase uses the original parent tip SHA stored in originalParentTips as the old base, ensuring only the branch's own commits replay onto its new parent ([internal/modify/apply.go](https://github.com/github/gh-stack/blob/main/internal/modify/apply.go#L368-L420).
If conflicts arise, the system transitions the state file to PhaseConflict, recording the conflicted branch, remaining branches, and PR status. Users can resolve conflicts and run gh stack modify --continue to resume.
func ApplyPlan(cfg *config.Config, gitDir string, s *stack.Stack,
sf *stack.StackFile, nodes []modifyview.ModifyBranchNode,
currentBranch string, updateBaseSHAs func(*stack.Stack)) (*modifyview.ApplyResult, *modifyview.ConflictInfo, error) {
// snapshot for unwind
snapshot, _ := BuildSnapshot(s)
// acquire lock
lock, _ := stack.Lock(gitDir); defer lock.Unlock()
// build concrete actions
plan := BuildPlan(nodes)
// write state file (PhaseApplying)
state := &StateFile{Phase: PhaseApplying, Snapshot: snapshot, Plan: plan, …}
SaveState(gitDir, state)
// …handle renames, inserts, folds, drops, reorder…
// cascading rebase
for i, b := range s.Branches {
if b.IsMerged() { continue }
newBase := s.ActiveBaseBranch(b.Branch)
oldBase := originalParentTips[b.Branch]
if err := git.RebaseOnto(newBase, oldBase, b.Branch, git.RebaseOpts{}); err != nil {
// on conflict, persist PhaseConflict and bail
state.Phase = PhaseConflict
state.ConflictBranch = b.Branch
SaveState(gitDir, state)
return nil, &modifyview.ConflictInfo{Branch: b.Branch}, err
}
}
// final success handling …
}
Finalization and Recovery Paths
Upon successful completion, resolveCheckoutBranch selects the best branch to check out, and updateBaseSHAs refreshes base SHAs in the stack metadata. If any PRs were modified, the state file transitions to PhasePendingSubmit, prompting users to run gh stack submit. Otherwise, the state file clears ([internal/modify/apply.go](https://github.com/github/gh-stack/blob/main/internal/modify/apply.go#L496-L514)). The modified stack saves atomically using stack.SaveWithLock.
For recovery:
- Abort:
runModifyAbortreads the state file and invokesmodify.UnwindFromStateFileto restore branch tips from the snapshot, delete temporary branches, and revert the stack JSON ([internal/modify/unwind.go](https://github.com/github/gh-stack/blob/main/internal/modify/unwind.go)). - Continue:
runModifyContinueloads the state, completes in-progress cherry-picks or rebases, then resumes the cascading rebase for remaining branches viaContinueApply.
Summary
- The
gh stack modifycommand validates repository state throughcheckModifyPreconditionsbefore launching the TUI. - User interactions convert to concrete
modify.Actionstructs viaBuildPlanafter the Bubble Tea interface returns. - A pre-modify snapshot and
StateFileenable safe recovery through--abortand--continueworkflows. - Execution follows five phases: rename, insert, fold (up/down), drop, and reorder.
- A cascading rebase using
originalParentTipsensures clean commit replay onto new parents. - Conflicts trigger
PhaseConflictstates that preserve the exact position in the operation sequence for resumption.
Frequently Asked Questions
What happens if a conflict occurs during gh stack modify?
When git.RebaseOnto encounters a conflict, the command immediately writes a PhaseConflict state to .git/gh-stack.modify, recording the conflicted branch and remaining operations. The user resolves the conflict manually, then runs gh stack modify --continue to resume the cascading rebase from the exact failure point.
How does gh stack modify ensure I can undo changes?
Before executing any Git operations, the command calls BuildSnapshot to capture all branch names, tip SHAs, and the full stack JSON. This snapshot persists in the StateFile. If you run gh stack modify --abort, the UnwindFromStateFile function restores branch tips from this snapshot and reverts the stack metadata to its pre-modify state.
Can I rename branches while reordering them in the same session?
Yes. The ModifyBranchNode structure tracks OriginalPosition, Removed, and PendingAction independently. The BuildPlan function processes all modifications simultaneously, generating separate Action structs for renames and reorder operations that execute in the correct sequence during the five-phase pipeline.
What is the difference between folding up and folding down?
Fold-down cherry-picks commits from the upper branch onto the lower branch immediately, creating a new merge base. Fold-up only adjusts the originalParentTips mapping; both sets of commits replay during the subsequent cascading rebase onto the new parent, effectively merging the lower branch into the upper one's history.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →