Files
github__gh-stack/internal/modify/apply_test.go
Sameen Karim 0c51c08255 Fix to properly revert after aborting modify (#167)
* Add cherry-pick abort/quit/in-progress git primitives

Introduce IsCherryPickInProgress() (detects .git/CHERRY_PICK_HEAD) and split
the existing cherry-pick reset into two distinct operations:

- CherryPickQuit() runs `git cherry-pick --quit`, clearing the sequencer
  state without touching the index (used to clear stale state before starting
  a fresh cherry-pick).
- CherryPickAbort() now runs `git cherry-pick --abort`, which fully restores
  the working tree and index to the pre-cherry-pick state.

The previous CherryPickAbort() ran --quit, which leaves an unmerged index and
therefore cannot recover a conflicted fold-down. Integration tests cover the
in-progress detection, the full abort restore, and the --quit-leaves-index
behavior.

* 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.

* Persist fold-branch removal when a post-fold cascade rebase conflicts

ContinueApply removes the folded branch from the in-memory stack after a
fold-down cherry-pick is resolved, but a subsequent cascade rebase conflict
only saved the modify state file, not the stack metadata. On the next
--continue the on-disk metadata (folded branch still present) was re-read, and
because ConflictType is now "rebase" the fold-removal block was skipped, so the
final save resurrected the folded branch as a phantom entry pointing at an
orphaned tip.

Persist the stack file alongside the state file on a cascade-rebase conflict,
mirroring ApplyPlan's save-on-conflict, so the fold removal survives recovery.
Adds an end-to-end regression test covering the fold-then-cascade-conflict path
across two --continue calls.
2026-07-15 12:07:42 -04:00

2241 lines
66 KiB
Go

package modify
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/stack"
"github.com/github/gh-stack/internal/tui/modifyview"
"github.com/github/gh-stack/internal/tui/stackview"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// rebaseCall records arguments passed to RebaseOnto.
type rebaseCall struct {
newBase string
oldBase string
branch string
}
// writeTestStackFile writes a stack file to disk and returns the loaded StackFile
// (with correct checksum for later Save calls).
func writeTestStackFile(t *testing.T, dir string, s stack.Stack) *stack.StackFile {
t.Helper()
sf := &stack.StackFile{
SchemaVersion: 1,
Stacks: []stack.Stack{s},
}
data, err := json.MarshalIndent(sf, "", " ")
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack"), data, 0644))
// Reload so the StackFile has the correct loadChecksum for Save.
loaded, err := stack.Load(dir)
require.NoError(t, err)
return loaded
}
// newApplyMock creates a MockOps pre-configured for apply tests.
func newApplyMock(gitDir string, branchSHAs map[string]string) *git.MockOps {
return &git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(name string) bool { return true },
RevParseFn: func(ref string) (string, error) {
if sha, ok := branchSHAs[ref]; ok {
return sha, nil
}
return "sha-" + ref, nil
},
IsAncestorFn: func(a, d string) (bool, error) { return false, nil },
MergeBaseFn: func(a, b string) (string, error) { return "merge-base", nil },
CheckoutBranchFn: func(string) error { return nil },
RebaseOntoFn: func(string, string, string, git.RebaseOpts) error { return nil },
IsRebaseInProgressFn: func() bool { return false },
RenameBranchFn: func(string, string) error { return nil },
LogRangeFn: func(base, head string) ([]git.CommitInfo, error) {
return []git.CommitInfo{{SHA: "commit-1"}, {SHA: "commit-2"}}, nil
},
CherryPickFn: func([]string) error { return nil },
ConflictedFilesFn: func() ([]string, error) { return nil, nil },
ResetHardFn: func(string) error { return nil },
CreateBranchFn: func(string, string) error { return nil },
RebaseAbortFn: func() error { return nil },
}
}
// makeNodes creates ModifyBranchNodes from a stack for testing.
func makeNodes(s *stack.Stack) []modifyview.ModifyBranchNode {
nodes := make([]modifyview.ModifyBranchNode, len(s.Branches))
for i, b := range s.Branches {
nodes[i] = modifyview.ModifyBranchNode{
BranchNode: stackview.BranchNode{
Ref: b,
},
OriginalPosition: i,
}
}
return nodes
}
func noopUpdateBaseSHAs(s *stack.Stack) {}
// ─── BuildSnapshot ───────────────────────────────────────────────────────────
func TestBuildSnapshot(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
branchSHAs := map[string]string{
"A": "sha-aaa",
"B": "sha-bbb",
}
mock := &git.MockOps{
RevParseFn: func(ref string) (string, error) {
if sha, ok := branchSHAs[ref]; ok {
return sha, nil
}
return "sha-" + ref, nil
},
}
restore := git.SetOps(mock)
defer restore()
snap, err := BuildSnapshot(&s)
require.NoError(t, err)
require.Len(t, snap.Branches, 2)
assert.Equal(t, "A", snap.Branches[0].Name)
assert.Equal(t, "sha-aaa", snap.Branches[0].TipSHA)
assert.Equal(t, 0, snap.Branches[0].Position)
assert.Equal(t, "B", snap.Branches[1].Name)
assert.Equal(t, "sha-bbb", snap.Branches[1].TipSHA)
assert.Equal(t, 1, snap.Branches[1].Position)
// Verify stack metadata round-trips through JSON
var restored stack.Stack
require.NoError(t, json.Unmarshal(snap.StackMetadata, &restored))
assert.Equal(t, "main", restored.Trunk.Branch)
assert.Equal(t, "A", restored.Branches[0].Branch)
assert.Equal(t, "B", restored.Branches[1].Branch)
}
// ─── BuildPlan ───────────────────────────────────────────────────────────────
func TestBuildPlan_VariousActions(t *testing.T) {
t.Run("no changes produces empty plan", func(t *testing.T) {
nodes := []modifyview.ModifyBranchNode{
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "A"}},
OriginalPosition: 0,
},
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "B"}},
OriginalPosition: 1,
},
}
plan := BuildPlan(nodes)
assert.Empty(t, plan)
})
t.Run("rename produces rename action", func(t *testing.T) {
nodes := []modifyview.ModifyBranchNode{
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "A"}},
OriginalPosition: 0,
PendingAction: &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-A"},
},
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "B"}},
OriginalPosition: 1,
},
}
plan := BuildPlan(nodes)
require.Len(t, plan, 1)
assert.Equal(t, "rename", plan[0].Type)
assert.Equal(t, "A", plan[0].Branch)
assert.Equal(t, "new-A", plan[0].NewName)
})
t.Run("move produces move action", func(t *testing.T) {
// Original order: A(0), B(1), C(2). Desired: A(0), C(1), B(2)
nodes := []modifyview.ModifyBranchNode{
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "A"}},
OriginalPosition: 0,
},
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "C"}},
OriginalPosition: 2,
},
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "B"}},
OriginalPosition: 1,
},
}
plan := BuildPlan(nodes)
// C moved from 2→1, B moved from 1→2
require.Len(t, plan, 2)
assert.Equal(t, "move", plan[0].Type)
assert.Equal(t, "C", plan[0].Branch)
assert.Equal(t, 1, plan[0].NewPosition)
assert.Equal(t, "move", plan[1].Type)
assert.Equal(t, "B", plan[1].Branch)
assert.Equal(t, 2, plan[1].NewPosition)
})
t.Run("removed nodes with drop action not in plan directly", func(t *testing.T) {
// BuildPlan skips Removed nodes — the drop is recorded by the non-removed
// logic. But nodes with PendingAction and NOT Removed do get recorded.
nodes := []modifyview.ModifyBranchNode{
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "A"}},
OriginalPosition: 0,
Removed: true,
PendingAction: &modifyview.PendingAction{Type: modifyview.ActionDrop},
},
}
plan := BuildPlan(nodes)
// Removed == true, so it's skipped in BuildPlan
assert.Empty(t, plan)
})
}
// ─── ApplyPlan: Drop ─────────────────────────────────────────────────────────
func TestApplyPlan_Drop(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B", PullRequest: &stack.PullRequestRef{Number: 42}},
{Branch: "C"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
"C": "sha-C",
}
var rebaseCalls []rebaseCall
mock := newApplyMock(gitDir, branchSHAs)
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Build nodes: Drop B
nodes := makeNodes(&sf.Stacks[0])
nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop}
nodes[1].Removed = true
result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
assert.Nil(t, conflict)
require.NotNil(t, result)
// B should be removed from stack
assert.Equal(t, 2, len(sf.Stacks[0].Branches))
assert.Equal(t, "A", sf.Stacks[0].Branches[0].Branch)
assert.Equal(t, "C", sf.Stacks[0].Branches[1].Branch)
// B's PR should be in DroppedPRs
require.Len(t, result.DroppedPRs, 1)
assert.Equal(t, "B", result.DroppedPRs[0].Branch)
assert.Equal(t, 42, result.DroppedPRs[0].PRNumber)
// C should be rebased onto A (B's parent), with B's old tip as oldBase
var cRebase *rebaseCall
for _, rc := range rebaseCalls {
if rc.branch == "C" {
cRebase = &rc
break
}
}
require.NotNil(t, cRebase, "C should be rebased")
assert.Equal(t, "A", cRebase.newBase)
}
// ─── ApplyPlan: FoldDown ─────────────────────────────────────────────────────
func TestApplyPlan_FoldDown(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
var cherryPickCalls [][]string
var checkoutCalls []string
mock := newApplyMock(gitDir, branchSHAs)
mock.CheckoutBranchFn = func(name string) error {
checkoutCalls = append(checkoutCalls, name)
return nil
}
mock.CherryPickFn = func(shas []string) error {
cherryPickCalls = append(cherryPickCalls, shas)
return nil
}
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
if base == "A" && head == "B" {
return []git.CommitInfo{
{SHA: "commit-b2"},
{SHA: "commit-b1"},
}, nil
}
return nil, nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
nodes := makeNodes(&sf.Stacks[0])
nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldDown}
nodes[1].Removed = true
result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
assert.Nil(t, conflict)
require.NotNil(t, result)
// CheckoutBranch should be called with "A" (the target below)
assert.Contains(t, checkoutCalls, "A")
// CherryPick should be called with B's commit SHAs (reversed for chronological order)
require.Len(t, cherryPickCalls, 1)
assert.Equal(t, []string{"commit-b1", "commit-b2"}, cherryPickCalls[0])
// B should be removed from stack
assert.Equal(t, 1, len(sf.Stacks[0].Branches))
assert.Equal(t, "A", sf.Stacks[0].Branches[0].Branch)
}
// ─── ApplyPlan: FoldUp ───────────────────────────────────────────────────────
func TestApplyPlan_FoldUp(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
{Branch: "C"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
"C": "sha-C",
}
var cherryPickCalls [][]string
var rebaseCalls []rebaseCall
mock := newApplyMock(gitDir, branchSHAs)
mock.CherryPickFn = func(shas []string) error {
cherryPickCalls = append(cherryPickCalls, shas)
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
nodes := makeNodes(&sf.Stacks[0])
nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldUp}
nodes[1].Removed = true
result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
assert.Nil(t, conflict)
require.NotNil(t, result)
// Fold-up should NOT call CherryPick
assert.Empty(t, cherryPickCalls, "fold-up should not cherry-pick")
// B should be removed from stack
assert.Equal(t, 2, len(sf.Stacks[0].Branches))
assert.Equal(t, "A", sf.Stacks[0].Branches[0].Branch)
assert.Equal(t, "C", sf.Stacks[0].Branches[1].Branch)
// C's rebase should use B's base (A's tip) as oldBase, not B's tip.
// The fold-up adjusts originalParentTips[C] = originalParentTips[B] = sha-A
var cRebase *rebaseCall
for _, rc := range rebaseCalls {
if rc.branch == "C" {
cRebase = &rc
break
}
}
require.NotNil(t, cRebase, "C should be rebased")
assert.Equal(t, "A", cRebase.newBase, "C should rebase onto A (B's parent)")
assert.Equal(t, "sha-A", cRebase.oldBase, "C should use A's tip (B's original parent tip) as oldBase")
}
// ─── ApplyPlan: Rename ───────────────────────────────────────────────────────
func TestApplyPlan_Rename(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
var renameCalls []struct{ oldName, newName string }
mock := newApplyMock(gitDir, branchSHAs)
mock.RenameBranchFn = func(old, new string) error {
renameCalls = append(renameCalls, struct{ oldName, newName string }{old, new})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
nodes := makeNodes(&sf.Stacks[0])
nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-A"}
result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs)
require.NoError(t, err)
assert.Nil(t, conflict)
require.NotNil(t, result)
// RenameBranch called with correct args
require.Len(t, renameCalls, 1)
assert.Equal(t, "A", renameCalls[0].oldName)
assert.Equal(t, "new-A", renameCalls[0].newName)
// In-memory branch name updated
assert.Equal(t, "new-A", sf.Stacks[0].Branches[0].Branch)
// Result tracks rename
require.Len(t, result.RenamedBranches, 1)
assert.Equal(t, "A", result.RenamedBranches[0].OldName)
assert.Equal(t, "new-A", result.RenamedBranches[0].NewName)
}
// ─── ApplyPlan: Reorder ──────────────────────────────────────────────────────
func TestApplyPlan_Reorder(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
{Branch: "C"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
"C": "sha-C",
}
var rebaseCalls []rebaseCall
mock := newApplyMock(gitDir, branchSHAs)
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Desired order: A, C, B (move C between A and B)
nodes := []modifyview.ModifyBranchNode{
{
BranchNode: stackview.BranchNode{Ref: sf.Stacks[0].Branches[0]}, // A
OriginalPosition: 0,
},
{
BranchNode: stackview.BranchNode{Ref: sf.Stacks[0].Branches[2]}, // C
OriginalPosition: 2,
},
{
BranchNode: stackview.BranchNode{Ref: sf.Stacks[0].Branches[1]}, // B
OriginalPosition: 1,
},
}
result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
assert.Nil(t, conflict)
require.NotNil(t, result)
// Verify stack order is now A, C, B
require.Len(t, sf.Stacks[0].Branches, 3)
assert.Equal(t, "A", sf.Stacks[0].Branches[0].Branch)
assert.Equal(t, "C", sf.Stacks[0].Branches[1].Branch)
assert.Equal(t, "B", sf.Stacks[0].Branches[2].Branch)
// Both C and B should be rebased onto their new parents
rebaseMap := make(map[string]rebaseCall)
for _, rc := range rebaseCalls {
rebaseMap[rc.branch] = rc
}
if cCall, ok := rebaseMap["C"]; ok {
assert.Equal(t, "A", cCall.newBase, "C should be rebased onto A")
}
if bCall, ok := rebaseMap["B"]; ok {
assert.Equal(t, "C", bCall.newBase, "B should be rebased onto C")
}
}
// ─── ApplyPlan: Mixed Drop and Fold ─────────────────────────────────────────
func TestApplyPlan_MixedDropAndFold(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
{Branch: "C"},
{Branch: "D"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
"C": "sha-C",
"D": "sha-D",
}
var cherryPickCalls [][]string
var checkoutCalls []string
var rebaseCalls []rebaseCall
mock := newApplyMock(gitDir, branchSHAs)
mock.CheckoutBranchFn = func(name string) error {
checkoutCalls = append(checkoutCalls, name)
return nil
}
mock.CherryPickFn = func(shas []string) error {
cherryPickCalls = append(cherryPickCalls, shas)
return nil
}
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
if head == "C" {
return []git.CommitInfo{{SHA: "c-commit-1"}}, nil
}
return nil, nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Drop B, fold C down into A
nodes := makeNodes(&sf.Stacks[0])
nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop}
nodes[1].Removed = true
nodes[2].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldDown}
nodes[2].Removed = true
result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
assert.Nil(t, conflict)
require.NotNil(t, result)
// B and C should be removed, leaving A and D
branchNames := make([]string, len(sf.Stacks[0].Branches))
for i, b := range sf.Stacks[0].Branches {
branchNames[i] = b.Branch
}
assert.Equal(t, []string{"A", "D"}, branchNames)
// C's commits should have been cherry-picked onto A
require.Len(t, cherryPickCalls, 1)
// D should be rebased onto A
var dRebase *rebaseCall
for _, rc := range rebaseCalls {
if rc.branch == "D" {
dRebase = &rc
break
}
}
require.NotNil(t, dRebase, "D should be rebased")
assert.Equal(t, "A", dRebase.newBase, "D should be rebased onto A")
}
// ─── ApplyPlan: Conflict During Rebase ───────────────────────────────────────
func TestApplyPlan_ConflictDuringRebase(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
mock := newApplyMock(gitDir, branchSHAs)
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
if branch == "B" {
return assert.AnError
}
return nil
}
mock.ConflictedFilesFn = func() ([]string, error) {
return []string{"file.go"}, nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Drop A so B must rebase onto main
nodes := makeNodes(&sf.Stacks[0])
nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop}
nodes[0].Removed = true
_, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
assert.Error(t, err)
require.NotNil(t, conflict)
assert.Equal(t, "B", conflict.Branch)
assert.Contains(t, conflict.ConflictedFiles, "file.go")
// Verify state file written with phase "conflict"
state, loadErr := LoadState(gitDir)
require.NoError(t, loadErr)
require.NotNil(t, state)
assert.Equal(t, "conflict", state.Phase)
assert.Equal(t, "B", state.ConflictBranch)
}
// ─── ApplyPlan: Conflict During CherryPick ───────────────────────────────────
func TestApplyPlan_ConflictDuringCherryPick(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
mock := newApplyMock(gitDir, branchSHAs)
mock.CherryPickFn = func(shas []string) error {
return assert.AnError
}
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
return []git.CommitInfo{{SHA: "commit-1"}}, nil
}
mock.ConflictedFilesFn = func() ([]string, error) {
return []string{"conflict.go"}, nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Fold B down into A
nodes := makeNodes(&sf.Stacks[0])
nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldDown}
nodes[1].Removed = true
_, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
assert.Error(t, err)
require.NotNil(t, conflict)
assert.Equal(t, "B", conflict.Branch)
// Cherry-pick conflicts now save state for --continue recovery
state, loadErr := LoadState(gitDir)
require.NoError(t, loadErr)
require.NotNil(t, state)
assert.Equal(t, PhaseConflict, state.Phase)
assert.Equal(t, "cherry_pick", state.ConflictType)
assert.Equal(t, "B", state.FoldBranch)
assert.Equal(t, "A", state.FoldTarget)
assert.Equal(t, "A", state.OriginalBranch)
assert.Contains(t, state.RemainingBranches, "A")
}
// ─── ContinueApply: Multi-Stack Finds Correct Stack ─────────────────────────
func TestContinueApply_MultiStackFindsCorrectStack(t *testing.T) {
// When multiple stacks share the same trunk, ContinueApply should use
// StackIndex to find the right stack, not just trunk name matching.
gitDir := t.TempDir()
// Stack 0: main <- X (a different stack)
// Stack 1: main <- A <- B <- C (the one being modified)
sf := &stack.StackFile{
SchemaVersion: 1,
Stacks: []stack.Stack{
{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "X"}},
},
{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
{Branch: "C"},
},
},
},
}
data, err := json.MarshalIndent(sf, "", " ")
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(gitDir, "gh-stack"), data, 0644))
// Create a state file pointing at Stack 1 (index 1)
state := &StateFile{
SchemaVersion: 1,
StackName: "main",
StackIndex: 1, // The correct stack is at index 1
Phase: PhaseConflict,
ConflictBranch: "A",
ConflictType: "rebase",
RemainingBranches: []string{"B", "C"},
OriginalRefs: map[string]string{"B": "sha-A", "C": "sha-B"},
}
require.NoError(t, SaveState(gitDir, state))
mock := newApplyMock(gitDir, map[string]string{
"main": "sha-main", "A": "sha-A", "B": "sha-B", "C": "sha-C",
})
mock.IsRebaseInProgressFn = func() bool { return true }
mock.RebaseContinueFn = func(opts git.RebaseOpts) error { return nil }
var rebasedBranches []string
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebasedBranches = append(rebasedBranches, branch)
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
err = ContinueApply(cfg, gitDir, noopUpdateBaseSHAs)
require.NoError(t, err)
// B and C should have been found and processed (not "no longer in stack")
assert.Contains(t, rebasedBranches, "B", "B should be rebased")
assert.Contains(t, rebasedBranches, "C", "C should be rebased")
}
// ─── Unwind ──────────────────────────────────────────────────────────────────
func TestUnwind(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
// Build a snapshot of the original state
branchSHAs := map[string]string{
"A": "sha-A-original",
"B": "sha-B-original",
}
snapshotMock := &git.MockOps{
RevParseFn: func(ref string) (string, error) {
if sha, ok := branchSHAs[ref]; ok {
return sha, nil
}
return "sha-" + ref, nil
},
}
restore := git.SetOps(snapshotMock)
snapshot, err := BuildSnapshot(&s)
require.NoError(t, err)
restore()
// Save a state file
stateFile := &StateFile{
SchemaVersion: 1,
StackName: "main",
StackIndex: 0,
Phase: "applying",
Snapshot: snapshot,
}
require.NoError(t, SaveState(gitDir, stateFile))
// Simulate partial apply: modify the stack
sf.Stacks[0].Branches = []stack.BranchRef{{Branch: "A"}} // B was removed
var resetCalls []struct{ branch, sha string }
var checkoutCalls []string
currentBranch := "A"
mock := &git.MockOps{
IsRebaseInProgressFn: func() bool { return false },
BranchExistsFn: func(name string) bool { return true },
CheckoutBranchFn: func(name string) error {
checkoutCalls = append(checkoutCalls, name)
currentBranch = name
return nil
},
ResetHardFn: func(ref string) error {
resetCalls = append(resetCalls, struct{ branch, sha string }{currentBranch, ref})
return nil
},
CreateBranchFn: func(name, base string) error { return nil },
}
restore = git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
err = Unwind(cfg, gitDir, snapshot, 0, sf, nil)
require.NoError(t, err)
// ResetHard should be called for each branch with snapshot SHAs
resetMap := make(map[string]string)
for _, r := range resetCalls {
resetMap[r.branch] = r.sha
}
assert.Equal(t, "sha-A-original", resetMap["A"])
assert.Equal(t, "sha-B-original", resetMap["B"])
// Stack should be restored to original (2 branches)
assert.Equal(t, 2, len(sf.Stacks[0].Branches))
assert.Equal(t, "A", sf.Stacks[0].Branches[0].Branch)
assert.Equal(t, "B", sf.Stacks[0].Branches[1].Branch)
// State file should be cleared
assert.False(t, StateExists(gitDir))
}
// ─── ApplyPlan: No-op (empty plan) ──────────────────────────────────────────
func TestApplyPlan_NoChanges(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
mock := newApplyMock(gitDir, branchSHAs)
// Make IsAncestor return true and MergeBase match oldBase to skip rebases
mock.IsAncestorFn = func(a, d string) (bool, error) { return true, nil }
mock.MergeBaseFn = func(a, b string) (string, error) {
// Return the parent tip SHA so the "no rebase needed" check passes
if a == "main" && b == "A" {
return branchSHAs["main"], nil
}
if a == "A" && b == "B" {
return branchSHAs["A"], nil
}
return "merge-base", nil
}
var rebaseCalls int
mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error {
rebaseCalls++
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
nodes := makeNodes(&sf.Stacks[0])
result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
assert.Nil(t, conflict)
require.NotNil(t, result)
assert.True(t, result.Success)
assert.Equal(t, 0, rebaseCalls, "no rebase should be needed when nothing changed")
}
// ─── ApplyPlan: Drop with no PR ─────────────────────────────────────────────
func TestApplyPlan_DropNoPR(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
mock := newApplyMock(gitDir, branchSHAs)
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
nodes := makeNodes(&sf.Stacks[0])
nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop}
nodes[0].Removed = true
result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs)
require.NoError(t, err)
assert.Nil(t, conflict)
require.NotNil(t, result)
// No PR means no DroppedPRs entry
assert.Empty(t, result.DroppedPRs)
// A should be removed
assert.Equal(t, 1, len(sf.Stacks[0].Branches))
assert.Equal(t, "B", sf.Stacks[0].Branches[0].Branch)
}
// ─── ContinueApply ──────────────────────────────────────────────────────────
func TestContinueApply(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
{Branch: "C"},
},
}
gitDir := t.TempDir()
_ = writeTestStackFile(t, gitDir, s)
// Write a conflict state file
stateFile := &StateFile{
SchemaVersion: 1,
StackName: "main",
StackIndex: 0,
Phase: "conflict",
ConflictBranch: "B",
RemainingBranches: []string{"C"},
OriginalBranch: "A",
OriginalRefs: map[string]string{
"A": "sha-A",
"B": "sha-B",
"C": "sha-C",
},
}
require.NoError(t, SaveState(gitDir, stateFile))
var rebaseContinueCalled bool
var rebaseCalls []rebaseCall
var checkoutCalls []string
mock := &git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "B", nil },
BranchExistsFn: func(string) bool { return true },
IsRebaseInProgressFn: func() bool { return true },
RebaseContinueFn: func(git.RebaseOpts) error {
rebaseContinueCalled = true
return nil
},
RebaseOntoFn: func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
},
CheckoutBranchFn: func(name string) error {
checkoutCalls = append(checkoutCalls, name)
return nil
},
IsAncestorFn: func(a, d string) (bool, error) { return false, nil },
MergeBaseFn: func(a, b string) (string, error) { return "merge-base", nil },
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
err := ContinueApply(cfg, gitDir, noopUpdateBaseSHAs)
require.NoError(t, err)
assert.True(t, rebaseContinueCalled, "RebaseContinue should be called")
// C should be rebased
require.Len(t, rebaseCalls, 1)
assert.Equal(t, "C", rebaseCalls[0].branch)
assert.Equal(t, "B", rebaseCalls[0].newBase)
assert.Equal(t, "sha-C", rebaseCalls[0].oldBase)
// Should checkout original branch
assert.Contains(t, checkoutCalls, "A")
// State file should be cleared (no remote stack ID)
assert.False(t, StateExists(gitDir))
}
func TestContinueApply_NoStateFile(t *testing.T) {
gitDir := t.TempDir()
mock := &git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
err := ContinueApply(cfg, gitDir, noopUpdateBaseSHAs)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no modify state file found")
}
func TestContinueApply_WrongPhase(t *testing.T) {
gitDir := t.TempDir()
stateFile := &StateFile{
SchemaVersion: 1,
Phase: "applying",
}
require.NoError(t, SaveState(gitDir, stateFile))
mock := &git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
err := ContinueApply(cfg, gitDir, noopUpdateBaseSHAs)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no modify conflict in progress")
}
// ─── Unwind with active rebase ──────────────────────────────────────────────
func TestUnwind_AbortsActiveRebase(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
snapshotMock := &git.MockOps{
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
}
restore := git.SetOps(snapshotMock)
snapshot, err := BuildSnapshot(&s)
require.NoError(t, err)
restore()
require.NoError(t, SaveState(gitDir, &StateFile{
SchemaVersion: 1, Phase: "conflict", Snapshot: snapshot,
}))
var rebaseAbortCalled bool
mock := &git.MockOps{
IsRebaseInProgressFn: func() bool { return true },
RebaseAbortFn: func() error {
rebaseAbortCalled = true
return nil
},
BranchExistsFn: func(string) bool { return true },
CheckoutBranchFn: func(string) error { return nil },
ResetHardFn: func(string) error { return nil },
CreateBranchFn: func(string, string) error { return nil },
}
restore = git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
err = Unwind(cfg, gitDir, snapshot, 0, sf, nil)
require.NoError(t, err)
assert.True(t, rebaseAbortCalled, "RebaseAbort should be called when rebase is in progress")
assert.False(t, StateExists(gitDir))
}
// ─── Unwind with active cherry-pick ─────────────────────────────────────────
// A fold-down conflict leaves an in-progress cherry-pick with an unmerged
// index. Unwind must abort it (git cherry-pick --abort) before restoring
// branches, otherwise the restore checkouts fail on the unmerged index.
func TestUnwind_AbortsActiveCherryPick(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
snapshotMock := &git.MockOps{
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
}
restore := git.SetOps(snapshotMock)
snapshot, err := BuildSnapshot(&s)
require.NoError(t, err)
restore()
require.NoError(t, SaveState(gitDir, &StateFile{
SchemaVersion: 1, Phase: PhaseConflict, ConflictType: "cherry_pick", Snapshot: snapshot,
}))
var cherryPickAbortCalled bool
var rebaseAbortCalled bool
mock := &git.MockOps{
IsRebaseInProgressFn: func() bool { return false },
IsCherryPickInProgressFn: func() bool { return true },
RebaseAbortFn: func() error { rebaseAbortCalled = true; return nil },
CherryPickAbortFn: func() error { cherryPickAbortCalled = true; return nil },
BranchExistsFn: func(string) bool { return true },
CheckoutBranchFn: func(string) error { return nil },
ResetHardFn: func(string) error { return nil },
CreateBranchFn: func(string, string) error { return nil },
}
restore = git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
err = Unwind(cfg, gitDir, snapshot, 0, sf, nil)
require.NoError(t, err)
assert.True(t, cherryPickAbortCalled, "CherryPickAbort should be called when a cherry-pick is in progress")
assert.False(t, rebaseAbortCalled, "RebaseAbort should not be called when no rebase is in progress")
assert.False(t, StateExists(gitDir))
}
// ─── ContinueApply: subsequent conflict after fold-down is a rebase ─────────
// After resolving an initial fold-down (cherry-pick) conflict, the cascading
// rebase over the remaining branches may itself conflict. That conflict is a
// rebase, so ContinueApply must update ConflictType from "cherry_pick" to
// "rebase" — otherwise the next --continue wrongly calls CherryPickContinue
// and fails, stranding the user.
func TestContinueApply_SubsequentConflictBecomesRebase(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
{Branch: "C"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
_ = sf
// State as written when a fold-down of B into A conflicts on cherry-pick.
// B is still present in the stack metadata (it is removed only after the
// cherry-pick succeeds in ContinueApply).
state := &StateFile{
SchemaVersion: 1,
StackName: "main",
StackIndex: 0,
Phase: PhaseConflict,
ConflictType: "cherry_pick",
ConflictBranch: "B",
FoldBranch: "B",
FoldTarget: "A",
RemainingBranches: []string{"A", "C"},
OriginalBranch: "A",
OriginalRefs: map[string]string{"A": "sha-main", "C": "sha-A-old"},
}
require.NoError(t, SaveState(gitDir, state))
mock := newApplyMock(gitDir, map[string]string{
"main": "sha-main", "A": "sha-A", "B": "sha-B", "C": "sha-C",
})
// The user resolved the cherry-pick; --continue finishes it cleanly.
mock.CherryPickContinueFn = func() error { return nil }
// A rebases cleanly onto main; C then conflicts.
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
if branch == "C" {
return assert.AnError
}
return nil
}
mock.ConflictedFilesFn = func() ([]string, error) { return []string{"c.go"}, nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
err := ContinueApply(cfg, gitDir, noopUpdateBaseSHAs)
require.Error(t, err)
got, loadErr := LoadState(gitDir)
require.NoError(t, loadErr)
require.NotNil(t, got)
assert.Equal(t, PhaseConflict, got.Phase)
assert.Equal(t, "rebase", got.ConflictType, "subsequent cascade conflict must be recorded as a rebase")
assert.Equal(t, "C", got.ConflictBranch)
}
// Regression test for the review on PR #167: after an initial fold-down
// (cherry-pick) conflict is resolved, a subsequent cascade rebase conflict must
// persist the fold-branch removal to disk. Otherwise the next --continue
// re-reads stale on-disk metadata and — because ConflictType is now "rebase" —
// skips the fold-removal step, silently resurrecting the folded branch as a
// phantom entry once recovery completes.
func TestContinueApply_FoldThenCascadeConflict_DoesNotResurrectFoldedBranch(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
{Branch: "C"},
},
}
gitDir := t.TempDir()
writeTestStackFile(t, gitDir, s)
// State written by ApplyPlan when the fold-down of B into A conflicts on
// cherry-pick. B is still present in the on-disk metadata at this point.
state := &StateFile{
SchemaVersion: 1,
StackName: "main",
StackIndex: 0,
Phase: PhaseConflict,
ConflictType: "cherry_pick",
ConflictBranch: "B",
FoldBranch: "B",
FoldTarget: "A",
RemainingBranches: []string{"A", "C"},
OriginalBranch: "A",
OriginalRefs: map[string]string{"A": "sha-main", "C": "sha-A-old"},
}
require.NoError(t, SaveState(gitDir, state))
mock := newApplyMock(gitDir, map[string]string{
"main": "sha-main", "A": "sha-A", "B": "sha-B", "C": "sha-C",
})
mock.CherryPickContinueFn = func() error { return nil }
mock.IsRebaseInProgressFn = func() bool { return true }
mock.RebaseContinueFn = func(git.RebaseOpts) error { return nil }
// C conflicts on its first rebase attempt, then succeeds (user resolved it).
cRebases := 0
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
if branch == "C" {
cRebases++
if cRebases == 1 {
return assert.AnError
}
}
return nil
}
mock.ConflictedFilesFn = func() ([]string, error) { return []string{"c.go"}, nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// First --continue: finishes the fold, then conflicts rebasing C.
err := ContinueApply(cfg, gitDir, noopUpdateBaseSHAs)
require.Error(t, err)
// The fold-branch removal must already be persisted on disk, even though
// the cascade hit a conflict.
afterFirst, err := stack.Load(gitDir)
require.NoError(t, err)
assert.Equal(t, -1, afterFirst.Stacks[0].IndexOf("B"),
"folded branch B must not be present on disk after the cascade conflict")
// Second --continue: rebase resolves and recovery completes.
err = ContinueApply(cfg, gitDir, noopUpdateBaseSHAs)
require.NoError(t, err)
final, err := stack.Load(gitDir)
require.NoError(t, err)
names := make([]string, len(final.Stacks[0].Branches))
for i, b := range final.Stacks[0].Branches {
names[i] = b.Branch
}
assert.Equal(t, []string{"A", "C"}, names,
"folded branch B must stay removed after recovery completes")
assert.False(t, StateExists(gitDir), "state should be cleared after successful recovery")
}
// ─── Unwind restores renamed branch ─────────────────────────────────────────
func TestUnwind_RestoresRenamedBranch(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
snapshotMock := &git.MockOps{
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
}
restore := git.SetOps(snapshotMock)
snapshot, err := BuildSnapshot(&s)
require.NoError(t, err)
restore()
// Simulate: A was renamed to new-A, so A no longer exists
var createdBranches []struct{ name, sha string }
mock := &git.MockOps{
IsRebaseInProgressFn: func() bool { return false },
BranchExistsFn: func(name string) bool {
return name != "A" // A was renamed away
},
CreateBranchFn: func(name, sha string) error {
createdBranches = append(createdBranches, struct{ name, sha string }{name, sha})
return nil
},
CheckoutBranchFn: func(string) error { return nil },
ResetHardFn: func(string) error { return nil },
}
restore = git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
err = Unwind(cfg, gitDir, snapshot, 0, sf, nil)
require.NoError(t, err)
// A should be recreated via CreateBranch
require.Len(t, createdBranches, 1)
assert.Equal(t, "A", createdBranches[0].name)
assert.Equal(t, "sha-A", createdBranches[0].sha)
}
// ─── ApplyPlan: State file transitions for remote stack ─────────────────────
func TestApplyPlan_PendingSubmitForRemoteStack(t *testing.T) {
s := stack.Stack{
ID: "remote-stack-123",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A", PullRequest: &stack.PullRequestRef{Number: 1}},
{Branch: "B", PullRequest: &stack.PullRequestRef{Number: 2}},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
mock := newApplyMock(gitDir, branchSHAs)
mock.IsAncestorFn = func(a, d string) (bool, error) { return false, nil }
mock.MergeBaseFn = func(a, b string) (string, error) {
if a == "main" && b == "A" {
return branchSHAs["main"], nil
}
if a == "A" && b == "B" {
return branchSHAs["A"], nil
}
return "merge-base", nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Reverse nodes so position differs → triggers rebase of PR branches
nodes := makeNodes(&sf.Stacks[0])
nodes[0], nodes[1] = nodes[1], nodes[0]
result, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
// Remote stack with PR branches affected should transition to "pending_submit"
state, loadErr := LoadState(gitDir)
require.NoError(t, loadErr)
require.NotNil(t, state)
assert.Equal(t, "pending_submit", state.Phase)
assert.True(t, result.NeedsSubmit, "NeedsSubmit should be true when PR branches are affected")
}
func TestApplyPlan_ClearsStateForLocalStack(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
}
mock := newApplyMock(gitDir, branchSHAs)
mock.IsAncestorFn = func(a, d string) (bool, error) { return true, nil }
mock.MergeBaseFn = func(a, b string) (string, error) {
return branchSHAs["main"], nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
nodes := makeNodes(&sf.Stacks[0])
_, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
// Local stack (no ID) should clear the state file
assert.False(t, StateExists(gitDir))
}
func TestApplyPlan_ClearsStateForRemoteStackWithNoPRBranches(t *testing.T) {
// Remote stack (has ID) but branches have no PRs — local-only modify
s := stack.Stack{
ID: "remote-stack-456",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
mock := newApplyMock(gitDir, branchSHAs)
mock.IsAncestorFn = func(a, d string) (bool, error) { return true, nil }
mock.MergeBaseFn = func(a, b string) (string, error) {
if a == "main" && b == "A" {
return branchSHAs["main"], nil
}
if a == "A" && b == "B" {
return branchSHAs["A"], nil
}
return "merge-base", nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
nodes := makeNodes(&sf.Stacks[0])
result, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
// Remote stack but no PR branches affected → state should be cleared
assert.False(t, StateExists(gitDir), "state file should be cleared when no PR branches are affected")
assert.False(t, result.NeedsSubmit, "NeedsSubmit should be false when no PR branches are affected")
}
func TestApplyPlan_PendingSubmitOnlyWhenPRBranchesAffected(t *testing.T) {
// Stack with one PR branch (A) and one local branch (B).
// Only rename the local branch B — PRs should not be affected.
s := stack.Stack{
ID: "remote-stack-789",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A", PullRequest: &stack.PullRequestRef{Number: 1}},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
mock := newApplyMock(gitDir, branchSHAs)
mock.IsAncestorFn = func(a, d string) (bool, error) { return true, nil }
mock.MergeBaseFn = func(a, b string) (string, error) {
if a == "main" && b == "A" {
return branchSHAs["main"], nil
}
if a == "A" && b == "B" || a == "A" && b == "B-renamed" {
return branchSHAs["A"], nil
}
return "merge-base", nil
}
mock.RenameBranchFn = func(old, newName string) error { return nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
nodes := makeNodes(&sf.Stacks[0])
// Rename only the non-PR branch B
nodes[1].PendingAction = &modifyview.PendingAction{
Type: modifyview.ActionRename,
NewName: "B-renamed",
}
result, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
// Only non-PR branch was renamed — should clear state, not pending submit
assert.False(t, StateExists(gitDir), "state file should be cleared when only non-PR branches are renamed")
assert.False(t, result.NeedsSubmit, "NeedsSubmit should be false when only non-PR branches are affected")
}
// ─── resolveCheckoutBranch ──────────────────────────────────────────────────
func TestResolveCheckoutBranch_StillInStack(t *testing.T) {
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "B"}},
}
snapshot := Snapshot{
Branches: []BranchSnapshot{{Name: "A", Position: 0}, {Name: "B", Position: 1}},
}
result := resolveCheckoutBranch("A", nil, snapshot, s)
assert.Equal(t, "A", result)
}
func TestResolveCheckoutBranch_Renamed(t *testing.T) {
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "new-A"}, {Branch: "B"}},
}
snapshot := Snapshot{
Branches: []BranchSnapshot{{Name: "A", Position: 0}, {Name: "B", Position: 1}},
}
plan := []Action{{Type: "rename", Branch: "A", NewName: "new-A"}}
result := resolveCheckoutBranch("A", plan, snapshot, s)
assert.Equal(t, "new-A", result)
}
func TestResolveCheckoutBranch_FoldDown(t *testing.T) {
// B is folded down into A. After fold, stack has [A, C].
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "C"}},
}
snapshot := Snapshot{
Branches: []BranchSnapshot{
{Name: "A", Position: 0},
{Name: "B", Position: 1},
{Name: "C", Position: 2},
},
}
plan := []Action{{Type: "fold_down", Branch: "B"}}
result := resolveCheckoutBranch("B", plan, snapshot, s)
assert.Equal(t, "A", result)
}
func TestResolveCheckoutBranch_FoldUp(t *testing.T) {
// B is folded up into C. After fold, stack has [A, C].
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "C"}},
}
snapshot := Snapshot{
Branches: []BranchSnapshot{
{Name: "A", Position: 0},
{Name: "B", Position: 1},
{Name: "C", Position: 2},
},
}
plan := []Action{{Type: "fold_up", Branch: "B"}}
result := resolveCheckoutBranch("B", plan, snapshot, s)
assert.Equal(t, "C", result)
}
func TestResolveCheckoutBranch_Dropped_HasAbove(t *testing.T) {
// B is dropped. Stack has [A, C]. Should pick C (above B).
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "C"}},
}
snapshot := Snapshot{
Branches: []BranchSnapshot{
{Name: "A", Position: 0},
{Name: "B", Position: 1},
{Name: "C", Position: 2},
},
}
plan := []Action{{Type: "drop", Branch: "B"}}
result := resolveCheckoutBranch("B", plan, snapshot, s)
assert.Equal(t, "C", result)
}
func TestResolveCheckoutBranch_Dropped_TopBranch(t *testing.T) {
// C (topmost) is dropped. Stack has [A, B]. Should pick B (below C).
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "B"}},
}
snapshot := Snapshot{
Branches: []BranchSnapshot{
{Name: "A", Position: 0},
{Name: "B", Position: 1},
{Name: "C", Position: 2},
},
}
plan := []Action{{Type: "drop", Branch: "C"}}
result := resolveCheckoutBranch("C", plan, snapshot, s)
assert.Equal(t, "B", result)
}
func TestResolveCheckoutBranch_Dropped_MultipleDropped(t *testing.T) {
// B and C both dropped. Stack has [A, D]. Original on B → should pick D (nearest above).
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "D"}},
}
snapshot := Snapshot{
Branches: []BranchSnapshot{
{Name: "A", Position: 0},
{Name: "B", Position: 1},
{Name: "C", Position: 2},
{Name: "D", Position: 3},
},
}
plan := []Action{
{Type: "drop", Branch: "B"},
{Type: "drop", Branch: "C"},
}
result := resolveCheckoutBranch("B", plan, snapshot, s)
assert.Equal(t, "D", result)
}
func TestResolveCheckoutBranch_Fallback_EmptyStack(t *testing.T) {
// All branches removed — falls back to original (no crash).
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{},
}
snapshot := Snapshot{
Branches: []BranchSnapshot{{Name: "A", Position: 0}},
}
plan := []Action{{Type: "drop", Branch: "A"}}
result := resolveCheckoutBranch("A", plan, snapshot, s)
// No surviving branches → returns original as last resort
assert.Equal(t, "A", result)
}
func TestResolveCheckoutBranch_Fallback_TopBranch(t *testing.T) {
// Original branch not in plan and not in stack → fallback to topmost.
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "X"}, {Branch: "Y"}},
}
snapshot := Snapshot{
Branches: []BranchSnapshot{{Name: "A", Position: 0}},
}
result := resolveCheckoutBranch("A", nil, snapshot, s)
assert.Equal(t, "Y", result)
}
func TestResolveCheckoutBranch_FoldDown_TargetRenamed(t *testing.T) {
// B is folded down into A, and A is renamed to new-A in the same operation.
// After apply, stack has [new-A, C].
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "new-A"}, {Branch: "C"}},
}
snapshot := Snapshot{
Branches: []BranchSnapshot{
{Name: "A", Position: 0},
{Name: "B", Position: 1},
{Name: "C", Position: 2},
},
}
plan := []Action{
{Type: "rename", Branch: "A", NewName: "new-A"},
{Type: "fold_down", Branch: "B"},
}
result := resolveCheckoutBranch("B", plan, snapshot, s)
assert.Equal(t, "new-A", result)
}
func TestResolveCheckoutBranch_Dropped_NeighborRenamed(t *testing.T) {
// B is dropped, and C (above) is renamed to new-C in the same operation.
// After apply, stack has [A, new-C].
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "new-C"}},
}
snapshot := Snapshot{
Branches: []BranchSnapshot{
{Name: "A", Position: 0},
{Name: "B", Position: 1},
{Name: "C", Position: 2},
},
}
plan := []Action{
{Type: "rename", Branch: "C", NewName: "new-C"},
{Type: "drop", Branch: "B"},
}
result := resolveCheckoutBranch("B", plan, snapshot, s)
assert.Equal(t, "new-C", result)
}
// ─── ApplyPlan: Checkout behavior after drop ────────────────────────────────
func TestApplyPlan_Drop_ChecksOutNearestBranch(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
{Branch: "C"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
"C": "sha-C",
}
var lastCheckout string
mock := newApplyMock(gitDir, branchSHAs)
mock.CheckoutBranchFn = func(name string) error {
lastCheckout = name
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Drop B, user was on B
nodes := makeNodes(&sf.Stacks[0])
nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop}
nodes[1].Removed = true
_, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs)
require.NoError(t, err)
// Should check out C (branch above B), not B
assert.Equal(t, "C", lastCheckout)
}
func TestApplyPlan_FoldDown_ChecksOutTarget(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
var lastCheckout string
mock := newApplyMock(gitDir, branchSHAs)
mock.CheckoutBranchFn = func(name string) error {
lastCheckout = name
return nil
}
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
return []git.CommitInfo{{SHA: "commit-1"}}, nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Fold B down into A, user was on B
nodes := makeNodes(&sf.Stacks[0])
nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldDown}
nodes[1].Removed = true
_, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs)
require.NoError(t, err)
// Should check out A (fold target), not B
assert.Equal(t, "A", lastCheckout)
}
func TestApplyPlan_Rename_ChecksOutNewName(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
var lastCheckout string
mock := newApplyMock(gitDir, branchSHAs)
mock.CheckoutBranchFn = func(name string) error {
lastCheckout = name
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Rename A to new-A, user was on A
nodes := makeNodes(&sf.Stacks[0])
nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-A"}
_, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
// Should check out new-A, not A
assert.Equal(t, "new-A", lastCheckout)
}
// ─── BuildPlan: Insert ──────────────────────────────────────────────────────
func TestBuildPlan_Insert(t *testing.T) {
t.Run("insert below produces insert_below action", func(t *testing.T) {
nodes := []modifyview.ModifyBranchNode{
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "A"}},
OriginalPosition: 0,
},
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "new-branch"}},
OriginalPosition: -1,
IsInserted: true,
PendingAction: &modifyview.PendingAction{Type: modifyview.ActionInsertBelow, NewName: "new-branch"},
},
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "B"}},
OriginalPosition: 1,
},
}
plan := BuildPlan(nodes)
require.Len(t, plan, 1)
assert.Equal(t, "insert_below", plan[0].Type)
assert.Equal(t, "new-branch", plan[0].Branch)
assert.Equal(t, "new-branch", plan[0].NewName)
assert.Equal(t, 1, plan[0].NewPosition)
})
t.Run("insert above produces insert_above action", func(t *testing.T) {
nodes := []modifyview.ModifyBranchNode{
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "new-branch"}},
OriginalPosition: -1,
IsInserted: true,
PendingAction: &modifyview.PendingAction{Type: modifyview.ActionInsertAbove, NewName: "new-branch"},
},
{
BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "A"}},
OriginalPosition: 0,
},
}
plan := BuildPlan(nodes)
require.Len(t, plan, 1)
assert.Equal(t, "insert_above", plan[0].Type)
assert.Equal(t, "new-branch", plan[0].NewName)
assert.Equal(t, 0, plan[0].NewPosition)
})
}
// ─── ApplyPlan: Insert ──────────────────────────────────────────────────────
func TestApplyPlan_Insert(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
var createCalls []struct{ name, base string }
mock := newApplyMock(gitDir, branchSHAs)
mock.CreateBranchFn = func(name, base string) error {
createCalls = append(createCalls, struct{ name, base string }{name, base})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Insert "new-branch" between A and B (at position 1 in stack order)
nodes := makeNodes(&sf.Stacks[0])
insertNode := modifyview.ModifyBranchNode{
BranchNode: stackview.BranchNode{
Ref: stack.BranchRef{Branch: "new-branch"},
IsLinear: true,
},
PendingAction: &modifyview.PendingAction{Type: modifyview.ActionInsertBelow, NewName: "new-branch"},
OriginalPosition: -1,
IsInserted: true,
}
// Insert between A(0) and B(1)
allNodes := []modifyview.ModifyBranchNode{nodes[0], insertNode, nodes[1]}
result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, allNodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
assert.Nil(t, conflict)
require.NotNil(t, result)
// Branch should have been created
require.Len(t, createCalls, 1)
assert.Equal(t, "new-branch", createCalls[0].name)
assert.Equal(t, "A", createCalls[0].base)
// Stack should now have 3 branches: A, new-branch, B
require.Len(t, sf.Stacks[0].Branches, 3)
assert.Equal(t, "A", sf.Stacks[0].Branches[0].Branch)
assert.Equal(t, "new-branch", sf.Stacks[0].Branches[1].Branch)
assert.Equal(t, "B", sf.Stacks[0].Branches[2].Branch)
// new-branch should be in InsertedBranches
require.Len(t, result.InsertedBranches, 1)
assert.Equal(t, "new-branch", result.InsertedBranches[0])
}
func TestApplyPlan_InsertAtStart(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B"},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
var createCalls []struct{ name, base string }
mock := newApplyMock(gitDir, branchSHAs)
mock.CreateBranchFn = func(name, base string) error {
createCalls = append(createCalls, struct{ name, base string }{name, base})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Insert "new-branch" at the start (before A)
nodes := makeNodes(&sf.Stacks[0])
insertNode := modifyview.ModifyBranchNode{
BranchNode: stackview.BranchNode{
Ref: stack.BranchRef{Branch: "new-branch"},
IsLinear: true,
},
PendingAction: &modifyview.PendingAction{Type: modifyview.ActionInsertAbove, NewName: "new-branch"},
OriginalPosition: -1,
IsInserted: true,
}
allNodes := []modifyview.ModifyBranchNode{insertNode, nodes[0], nodes[1]}
result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, allNodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
assert.Nil(t, conflict)
require.NotNil(t, result)
// Branch should be created from trunk
require.Len(t, createCalls, 1)
assert.Equal(t, "new-branch", createCalls[0].name)
assert.Equal(t, "main", createCalls[0].base)
// Stack should now have 3 branches: new-branch, A, B
require.Len(t, sf.Stacks[0].Branches, 3)
assert.Equal(t, "new-branch", sf.Stacks[0].Branches[0].Branch)
assert.Equal(t, "A", sf.Stacks[0].Branches[1].Branch)
assert.Equal(t, "B", sf.Stacks[0].Branches[2].Branch)
}
func TestApplyPlan_InsertAffectsPRs(t *testing.T) {
s := stack.Stack{
ID: "test-id",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "A"},
{Branch: "B", PullRequest: &stack.PullRequestRef{Number: 42}},
},
}
gitDir := t.TempDir()
sf := writeTestStackFile(t, gitDir, s)
branchSHAs := map[string]string{
"main": "sha-main",
"A": "sha-A",
"B": "sha-B",
}
mock := newApplyMock(gitDir, branchSHAs)
mock.CreateBranchFn = func(name, base string) error { return nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
defer cfg.Out.Close()
defer cfg.Err.Close()
// Insert between A and B — B has a PR, so base changes → affectsPRs
nodes := makeNodes(&sf.Stacks[0])
insertNode := modifyview.ModifyBranchNode{
BranchNode: stackview.BranchNode{
Ref: stack.BranchRef{Branch: "new-branch"},
IsLinear: true,
},
PendingAction: &modifyview.PendingAction{Type: modifyview.ActionInsertBelow, NewName: "new-branch"},
OriginalPosition: -1,
IsInserted: true,
}
allNodes := []modifyview.ModifyBranchNode{nodes[0], insertNode, nodes[1]}
result, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, allNodes, "A", noopUpdateBaseSHAs)
require.NoError(t, err)
require.NotNil(t, result)
// Should need submit because insertion changes the base of a branch with PR
assert.True(t, result.NeedsSubmit, "inserting before a branch with a PR should trigger NeedsSubmit")
}