Files
github__gh-stack/internal/modify/apply_test.go
Sameen Karim a5cae7bbe0 Rebase stacks onto the latest remote trunk (#330)
* Rebase stacks onto the latest remote trunk

Fetch the configured trunk explicitly before sync or rebase and use that
fetched ref whenever the local trunk cannot be safely updated, while
preserving local-only and locally-ahead trunks. Fail instead of reporting
success when the fetch or rebase never starts, carry the resolved trunk
through conflict recovery, and verify the resulting ancestry before sync
pushes or either command reports success.

* Restore stacks after incomplete cascade rebases

Roll back branches already rewritten when a later rebase cannot start or
final ancestry verification fails, preventing retries from replaying stale
history. Preserve retryable modify state without repeating completed work,
and add regression coverage for remote-qualified trunk normalization.
2026-07-29 13:32:06 -04:00

2318 lines
68 KiB
Go

package modify
import (
"encoding/json"
"errors"
"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")
}
func TestContinueApply_RebaseStartErrorPersistsRetryState(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 := &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",
})
cherryPickContinues := 0
mock.CherryPickContinueFn = func() error {
cherryPickContinues++
return nil
}
cRebases := 0
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
if branch == "C" {
cRebases++
if cRebases == 1 {
return &git.RebaseStartError{Err: errors.New("branch is checked out elsewhere")}
}
}
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.Error(t, err)
retryState, err := LoadState(gitDir)
require.NoError(t, err)
require.NotNil(t, retryState)
assert.Equal(t, "rebase_start", retryState.ConflictType)
assert.Equal(t, "C", retryState.ConflictBranch)
assert.Empty(t, retryState.RemainingBranches)
afterFirst, err := stack.Load(gitDir)
require.NoError(t, err)
assert.Equal(t, -1, afterFirst.Stacks[0].IndexOf("B"),
"completed fold metadata must be saved before waiting to retry the rebase")
err = ContinueApply(cfg, gitDir, noopUpdateBaseSHAs)
require.NoError(t, err)
assert.Equal(t, 1, cherryPickContinues, "retry must not repeat the completed cherry-pick")
assert.Equal(t, 2, cRebases, "retry must restart the refused rebase")
assert.False(t, StateExists(gitDir))
}
// ─── 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")
}