mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
7a268fc380
* git primitives for modify cmd * extract reusable TUI parts * modify cmd * recreate stack after modify * add checks to prevent other modifications while modify is applying * modify continue for resuming after resolving conflicts * fix bug with duplicate stack entries after modifying * reuse conflict resolution help msg from rebase * additional confirmation before overwriting stack on remote * fix recreate order of operations Co-authored-by: Copilot <copilot@github.com> * move base commit instead of cherry picking for fold up * check to ensure we aren't left with zero branches * unify and dedupe across view and modify tui * more detailed help instructions Co-authored-by: Copilot <copilot@github.com> * only recommend submit if stack exists on remote Co-authored-by: Copilot <copilot@github.com> * tests for modify tui, apply modifications, submit modifications * refactor submit for regular and pending modifications * rename recover to abort Co-authored-by: Copilot <copilot@github.com> * docs for modify cmd * tui styling updates * updated tui screenshot * addressing review comments * Fix 4 bugs from code review Bug 1: Move RevParseMap error check before using originalRefs. The error from git.RevParseMap() was deferred past iteration of originalRefs, which could panic on a nil map. Bug 2: Differentiate cherry-pick vs rebase conflicts in modify. Cherry-pick conflicts don't save state as 'conflict' phase, so --continue won't work. Now prints --abort-only instructions for cherry-pick conflicts. Bug 3: Unwind now cleans up branches created by renames. After restoring snapshot branches, Unwind deletes renamed branch names that don't belong to the original snapshot. Bug 4: Simplify push message in submit command. Changed from 'Pushing N branches to remote...' to 'Pushing to remote...' since individual branches may fail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix 7 nit issues from code review 11: Add named constants for phase strings (PhaseApplying, PhaseConflict, PhasePendingSubmit) in state.go; replace remaining raw literals in state.go CheckStateGuard. 14: Fix bottomLines comment mismatch — listed 3 items but value is 2. 15: Extract magic number 88 to MinWidthForArt constant in header.go. 16: Remove unused stackview import anchor in model.go — the import is used via types.go where BranchNode is embedded. 17: Simplify CheckStackLinearity parent resolution — ActiveBaseBranch already handles skipping merged branches. 18: Fix rename undo matching any rename — add NewName check so only the specific rename being undone is matched. 20: Add TestUndoRename and TestUndoRename_DoesNotAffectOtherRenames to validate rename undo behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make cherry-pick conflicts recoverable via --continue Previously, cherry-pick conflicts during fold-down operations could only be resolved with --abort. Now they save full conflict state (phase, conflict type, fold branch/target, remaining branches) to the state file, enabling recovery via 'gh stack modify --continue'. Changes: - Add ConflictType field to StateFile (rebase or cherry_pick) - Add FoldBranch/FoldTarget fields for cherry-pick context - Add CherryPickContinue to git package (cherry-pick --continue) - Save cherry-pick conflict state in ApplyPlan with remaining branches - ContinueApply handles both rebase and cherry-pick conflicts - Unified conflict messaging in cmd/modify.go (both types show --continue) - Updated test to verify cherry-pick conflict state is saved correctly * Apply suggestions from code review Co-authored-by: Luke Ghenco <lukeghenco@github.com> Co-authored-by: Sameen Karim <skarim@github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Luke Ghenco <lukeghenco@github.com>
333 lines
9.0 KiB
Go
333 lines
9.0 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
|
|
• Reorder branches
|
|
• Rename branches
|
|
|
|
All changes are staged in the TUI and applied together when you press Ctrl+S.
|
|
After applying, run 'gh stack submit' to push changes and recreate the stack on GitHub.`,
|
|
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)
|
|
|
|
// 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, s.ID != "")
|
|
|
|
return nil
|
|
}
|
|
|
|
// printModifySuccess prints a summary of what was applied.
|
|
func printModifySuccess(cfg *config.Config, result *modifyview.ApplyResult, hasRemoteStack bool) {
|
|
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 _, 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 hasRemoteStack {
|
|
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
|
|
}
|
|
|
|
// Sync PR state and check merge queue
|
|
syncStackPRs(cfg, s)
|
|
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
|
|
}
|
|
}
|