Skip to content

Commit 65b738f

Browse files
committed
Fix modify --abort leaving a broken stack after a conflict
When `gh stack modify` hit a rebase or cherry-pick conflict it saved state with phase "conflict" and told the user to run `gh stack modify --abort` to restore. But runModifyAbort had no case for PhaseConflict, so it fell into the default branch that merely printed "unexpected modify state phase" and deleted the state file without unwinding. The in-flight rebase/cherry-pick stayed active, branches were left partially rewritten, and the deleted state file also made --continue impossible: the stack was stuck in limbo. Fixes: - runModifyAbort now unwinds on PhaseConflict (same recovery as PhaseApplying), aborting the in-progress operation, resetting branch tips to their pre-modify SHAs, restoring stack metadata, and clearing state. - Unwind now also aborts an in-progress cherry-pick (fold-down conflicts), not just a rebase. Without this the restore checkouts would fail on the unmerged cherry-pick index. - ContinueApply now records a subsequent cascade-rebase conflict as ConflictType "rebase" instead of leaving a stale "cherry_pick", so the next --continue calls RebaseContinue rather than failing in CherryPickContinue. Adds coverage for the conflict-phase abort, pending-submit no-op abort, Unwind aborting an active cherry-pick, and the cherry-pick to rebase ConflictType transition.
1 parent 9547e8c commit 65b738f

4 files changed

Lines changed: 299 additions & 28 deletions

File tree

cmd/modify.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,12 @@ func runModifyAbort(cfg *config.Config) error {
219219
}
220220

221221
switch state.Phase {
222-
case modify.PhaseApplying:
223-
cfg.Printf("A modify session was interrupted during the apply phase")
222+
case modify.PhaseApplying, modify.PhaseConflict:
223+
if state.Phase == modify.PhaseConflict {
224+
cfg.Printf("Aborting the modify and discarding in-progress conflict resolution")
225+
} else {
226+
cfg.Printf("A modify session was interrupted during the apply phase")
227+
}
224228
cfg.Printf("Restoring stack to pre-modify state...")
225229
if err := modify.UnwindFromStateFile(cfg, gitDir); err != nil {
226230
cfg.Errorf("recovery failed: %s", err)

cmd/modify_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,3 +771,132 @@ func TestCheckModifyStateGuard_UnknownPhase(t *testing.T) {
771771
err := modify.CheckStateGuard(gitDir)
772772
assert.NoError(t, err, "guard only blocks on 'applying' phase")
773773
}
774+
775+
// ---------------------------------------------------------------------------
776+
// 6. runModifyAbort recovery
777+
// ---------------------------------------------------------------------------
778+
779+
// Regression test: aborting a modify that stopped at a conflict must actually
780+
// unwind the stack (abort the in-flight rebase, reset branch tips to their
781+
// pre-modify SHAs, restore metadata, clear state) — not fall into a default
782+
// branch that merely deletes the state file and strands the user.
783+
func TestRunModifyAbort_ConflictPhase_Unwinds(t *testing.T) {
784+
s := stack.Stack{
785+
Trunk: stack.BranchRef{Branch: "main"},
786+
Branches: []stack.BranchRef{
787+
{Branch: "A"},
788+
{Branch: "B"},
789+
},
790+
}
791+
792+
tmpDir := t.TempDir()
793+
writeStackFile(t, tmpDir, s)
794+
795+
meta, err := json.Marshal(s)
796+
require.NoError(t, err)
797+
798+
snapshot := modify.Snapshot{
799+
Branches: []modify.BranchSnapshot{
800+
{Name: "A", TipSHA: "sha-A-original", Position: 0},
801+
{Name: "B", TipSHA: "sha-B-original", Position: 1},
802+
},
803+
StackMetadata: meta,
804+
}
805+
806+
// The state ApplyPlan persists when a cascade rebase conflicts.
807+
state := &modify.StateFile{
808+
SchemaVersion: 1,
809+
StackName: "main",
810+
StackIndex: 0,
811+
Phase: modify.PhaseConflict,
812+
ConflictBranch: "B",
813+
ConflictType: "rebase",
814+
Snapshot: snapshot,
815+
}
816+
require.NoError(t, modify.SaveState(tmpDir, state))
817+
818+
var rebaseAborted bool
819+
var resetCalls []struct{ branch, sha string }
820+
current := ""
821+
mock := &git.MockOps{
822+
GitDirFn: func() (string, error) { return tmpDir, nil },
823+
IsRebaseInProgressFn: func() bool { return true },
824+
IsCherryPickInProgressFn: func() bool { return false },
825+
RebaseAbortFn: func() error { rebaseAborted = true; return nil },
826+
BranchExistsFn: func(string) bool { return true },
827+
CheckoutBranchFn: func(name string) error { current = name; return nil },
828+
ResetHardFn: func(sha string) error {
829+
resetCalls = append(resetCalls, struct{ branch, sha string }{current, sha})
830+
return nil
831+
},
832+
CreateBranchFn: func(string, string) error { return nil },
833+
}
834+
restore := git.SetOps(mock)
835+
defer restore()
836+
837+
cfg, _, errR := config.NewTestConfig()
838+
839+
err = runModifyAbort(cfg)
840+
841+
cfg.Out.Close()
842+
cfg.Err.Close()
843+
out, _ := io.ReadAll(errR)
844+
output := string(out)
845+
846+
require.NoError(t, err)
847+
848+
// The in-flight rebase must have been aborted.
849+
assert.True(t, rebaseAborted, "in-progress rebase should be aborted during recovery")
850+
851+
// The state file must be cleared because recovery ran (not left dangling,
852+
// and not deleted-without-unwind).
853+
assert.False(t, modify.StateExists(tmpDir), "state file should be cleared after a successful abort")
854+
855+
// Branch tips must be reset to their pre-modify snapshot SHAs.
856+
resetMap := map[string]string{}
857+
for _, r := range resetCalls {
858+
resetMap[r.branch] = r.sha
859+
}
860+
assert.Equal(t, "sha-A-original", resetMap["A"])
861+
assert.Equal(t, "sha-B-original", resetMap["B"])
862+
863+
// It must not fall into the old default branch.
864+
assert.NotContains(t, output, "unexpected modify state phase")
865+
assert.Contains(t, output, "Restoring stack to pre-modify state")
866+
}
867+
868+
// PendingSubmit abort is a no-op that guides the user to submit; it must not
869+
// try to unwind (the local changes already succeeded).
870+
func TestRunModifyAbort_PendingSubmit_NoUnwind(t *testing.T) {
871+
tmpDir := t.TempDir()
872+
873+
state := &modify.StateFile{
874+
SchemaVersion: 1,
875+
StackName: "main",
876+
StackIndex: 0,
877+
Phase: modify.PhasePendingSubmit,
878+
}
879+
require.NoError(t, modify.SaveState(tmpDir, state))
880+
881+
var resetCalled bool
882+
mock := &git.MockOps{
883+
GitDirFn: func() (string, error) { return tmpDir, nil },
884+
ResetHardFn: func(string) error { resetCalled = true; return nil },
885+
}
886+
restore := git.SetOps(mock)
887+
defer restore()
888+
889+
cfg, _, errR := config.NewTestConfig()
890+
891+
err := runModifyAbort(cfg)
892+
893+
cfg.Out.Close()
894+
cfg.Err.Close()
895+
out, _ := io.ReadAll(errR)
896+
output := string(out)
897+
898+
require.NoError(t, err)
899+
assert.False(t, resetCalled, "pending-submit abort must not unwind branches")
900+
assert.True(t, modify.StateExists(tmpDir), "pending-submit state should be preserved")
901+
assert.Contains(t, output, "gh stack submit")
902+
}

internal/modify/apply.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ func ApplyPlan(
397397
shas[len(commits)-1-i] = c.SHA
398398
}
399399

400-
git.CherryPickAbort()
400+
git.CherryPickQuit()
401401

402402
if err := git.CherryPick(shas); err != nil {
403403
conflict := &modifyview.ConflictInfo{Branch: foldBranch}
@@ -906,6 +906,12 @@ func ContinueApply(
906906
}
907907
}
908908
state.ConflictBranch = branchName
909+
// These remaining branches are always rebased via RebaseOnto, so
910+
// the in-progress operation is a rebase. Update ConflictType in
911+
// case the original conflict was a cherry-pick (fold-down) — a
912+
// stale "cherry_pick" here would make the next --continue call
913+
// CherryPickContinue and fail.
914+
state.ConflictType = "rebase"
909915
state.RemainingBranches = remaining
910916
state.AffectsPRs = affectsPRs
911917
_ = SaveState(gitDir, state)
@@ -977,10 +983,16 @@ func ContinueApply(
977983
// Unwind restores the stack to its pre-modify state using the snapshot.
978984
// stackIndex is the index of the stack in sf.Stacks at modify start time.
979985
func Unwind(cfg *config.Config, gitDir string, snapshot Snapshot, stackIndex int, sf *stack.StackFile, plan []Action) error {
980-
// Abort any in-progress rebase
986+
// Abort any in-progress rebase or cherry-pick so the working tree and
987+
// index are clean before we restore branch tips. A fold-down conflict
988+
// leaves an in-progress cherry-pick with an unmerged index; without
989+
// aborting it first, the restore checkouts below would fail.
981990
if git.IsRebaseInProgress() {
982991
_ = git.RebaseAbort()
983992
}
993+
if git.IsCherryPickInProgress() {
994+
_ = git.CherryPickAbort()
995+
}
984996

985997
// Restore branch tips
986998
snapshotNames := make(map[string]bool, len(snapshot.Branches))

0 commit comments

Comments
 (0)