mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
49a753708c
* add insert branch operation to modify TUI
Add `i` (insert below) and `I` (insert above) key bindings to the
interactive modify view, allowing users to insert new empty branches
into an existing stack. This follows Vim-inspired semantics where
lowercase `i` inserts below the cursor and uppercase `I` inserts above.
## TUI behavior
When the user presses `i` or `I`, the TUI enters an insert input mode
(similar to rename mode) where they type a new branch name. The input
is validated against git ref naming rules, local branch uniqueness, and
in-stack name collisions. On confirm, a placeholder node is inserted at
the correct position in the branch list with a green "✚ insert"
annotation badge and green connector styling.
Insert is a structure operation — it works alongside fold, rename, and
drop, but is mutually exclusive with reorder (consistent with existing
mode exclusivity rules). Undo (`z`) removes the inserted node cleanly.
## Apply engine
At apply time (Step 2 in the pipeline, between renames and folds), the
engine creates the new git branch at the parent branch's tip via
`git.CreateBranch` and inserts a `BranchRef` into the stack metadata at
the correct position. If the insertion changes the base of a branch
that has an open PR, `affectsPRs` is set to trigger a required
`gh stack submit` afterward.
## Header shortcut updates
- Combined the fold shortcuts into a single line: `d/u - fold down/up`
- Added insert shortcuts on their own line: `i/I - insert below/above`
- Reordered fold references throughout to list "down" before "up" for
consistency with the insert shortcut ordering
## Files changed
- types.go: ActionInsertBelow/ActionInsertAbove types, IsInserted field,
InsertedBranches in ApplyResult
- model.go: key bindings, insert input mode, undo, mode exclusivity,
annotation, styling, header shortcuts, effective-index tracking to
prevent false reorder detection when inserts shift node positions
- styles.go: green insert badge/branch/connector styles
- status.go: insert counting in pending change summary
- help.go: new "Insert below / above" section, reordered fold heading
- apply.go: BuildPlan and ApplyPlan handle insert actions
- modify.go: updated command description and success summary
- README.md: updated keybindings table
## Test coverage
- 16 new TUI tests: insert below/above, top/bottom edges, undo, mode
exclusivity, merged branch guard, cancel/empty input, duplicate name
validation, pending summary counting, annotation rendering, mixed
operations with drop/fold, apply acceptance
- 4 new apply tests: BuildPlan produces correct insert actions,
ApplyPlan creates branches and updates stack metadata, insert at
stack start uses trunk as parent, affectsPRs triggered when inserting
before a branch with an open PR
* update add error msg to direct users to modify for inserting branches
* docs updates
* fix insert branch bugs in modify TUI
Fix three bugs with the insert branch feature in the modify TUI, and
adjust rename behavior on inserted nodes.
## Bug 1: False "moved" annotations on existing branches
After inserting a branch, all branches below the insertion point
displayed "↕ moved 1 layer down" annotations. This happened because
`nodeAnnotation` and `toNodeData` compared each node's
`OriginalPosition` against its raw array index, which gets shifted
when an inserted node is added to the slice.
Fix: introduce an `effectiveIdx` parameter that counts only
non-inserted nodes, so position comparisons reflect the original
ordering. The View loop computes effective indices by incrementing
only for non-inserted nodes and passes them to the rendering
functions.
## Bug 2: Header branch count inflated by staged inserts
The branch count in the header ("N branches") included inserted
placeholder nodes, making it appear as though the stack had grown
before changes were applied.
Fix: `buildHeaderConfig` now excludes `IsInserted` nodes from the
branch count. The count reflects only the original branches in the
stack.
## Bug 3: Operations allowed on inserted placeholder nodes
Inserted nodes could be folded into other branches, which makes no
sense for a placeholder with no commits. Additionally, the "last
branch" guard counted inserted nodes as active, allowing users to
drop all original branches and bypass the empty-stack check.
Fix:
- `fold()` rejects inserted nodes with a descriptive error message.
- `toggleDrop()` on an inserted node removes it entirely and pops
the original insert action from the undo stack (clean cancellation
rather than a separate undo entry).
- All three "active branch" guards (`toggleDrop`, `fold`, `tryApply`)
now exclude `IsInserted` nodes, ensuring at least one original
branch always remains in the stack.
## Rename on inserted branches
Instead of blocking renames on inserted nodes, pressing `r` now
enters rename mode and updates the insert action's name in place.
The node's `Ref.Branch` and `PendingAction.NewName` are both updated
directly — no separate rename action is created in the undo stack.
This lets users fix a typo without having to drop and re-insert.
## Tests added
- `TestInsertDoesNotShowMovedAnnotation` — verifies no false move
annotations appear on existing branches after an insert
- `TestBranchCountExcludesInserts` — verifies header count stays
stable after insert
- `TestCannotFoldInsertedBranch` — verifies fold is blocked
- `TestCannotRenameInsertedBranch` — verifies rename updates the
insert name in place
- `TestDropInsertedBranchRemovesIt` — verifies drop removes the node
- `TestDropInsertedBranchCanBeUndone` — verifies drop pops the
original insert from the undo stack
- `TestCannotDropAllOriginalBranchesWithInsert` — verifies the
empty-stack guard excludes inserted nodes
* ensure cannot fold into an inserted branch
* rm dead code
* delete inserted branches during abort
2027 lines
58 KiB
Go
2027 lines
58 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 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")
|
|
}
|