Files
github__gh-stack/internal/tui/stackview/model_test.go
Sameen Karim 7a268fc380 modify command (#72)
* git primitives for modify cmd

* extract reusable TUI parts

* modify cmd

* recreate stack after modify

* add checks to prevent other modifications while modify is applying

* modify continue for resuming after resolving conflicts

* fix bug with duplicate stack entries after modifying

* reuse conflict resolution help msg from rebase

* additional confirmation before overwriting stack on remote

* fix recreate order of operations

Co-authored-by: Copilot <copilot@github.com>

* move base commit instead of cherry picking for fold up

* check to ensure we aren't left with zero branches

* unify and dedupe across view and modify tui

* more detailed help instructions

Co-authored-by: Copilot <copilot@github.com>

* only recommend submit if stack exists on remote

Co-authored-by: Copilot <copilot@github.com>

* tests for modify tui, apply modifications, submit modifications

* refactor submit for regular and pending modifications

* rename recover to abort

Co-authored-by: Copilot <copilot@github.com>

* docs for modify cmd

* tui styling updates

* updated tui screenshot

* addressing review comments

* Fix 4 bugs from code review

Bug 1: Move RevParseMap error check before using originalRefs.
The error from git.RevParseMap() was deferred past iteration of
originalRefs, which could panic on a nil map.

Bug 2: Differentiate cherry-pick vs rebase conflicts in modify.
Cherry-pick conflicts don't save state as 'conflict' phase, so
--continue won't work. Now prints --abort-only instructions for
cherry-pick conflicts.

Bug 3: Unwind now cleans up branches created by renames.
After restoring snapshot branches, Unwind deletes renamed branch
names that don't belong to the original snapshot.

Bug 4: Simplify push message in submit command.
Changed from 'Pushing N branches to remote...' to 'Pushing to
remote...' since individual branches may fail.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix 7 nit issues from code review

11: Add named constants for phase strings (PhaseApplying, PhaseConflict,
PhasePendingSubmit) in state.go; replace remaining raw literals in
state.go CheckStateGuard.

14: Fix bottomLines comment mismatch — listed 3 items but value is 2.

15: Extract magic number 88 to MinWidthForArt constant in header.go.

16: Remove unused stackview import anchor in model.go — the import
is used via types.go where BranchNode is embedded.

17: Simplify CheckStackLinearity parent resolution — ActiveBaseBranch
already handles skipping merged branches.

18: Fix rename undo matching any rename — add NewName check so only
the specific rename being undone is matched.

20: Add TestUndoRename and TestUndoRename_DoesNotAffectOtherRenames
to validate rename undo behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Make cherry-pick conflicts recoverable via --continue

Previously, cherry-pick conflicts during fold-down operations could only
be resolved with --abort. Now they save full conflict state (phase,
conflict type, fold branch/target, remaining branches) to the state file,
enabling recovery via 'gh stack modify --continue'.

Changes:
- Add ConflictType field to StateFile (rebase or cherry_pick)
- Add FoldBranch/FoldTarget fields for cherry-pick context
- Add CherryPickContinue to git package (cherry-pick --continue)
- Save cherry-pick conflict state in ApplyPlan with remaining branches
- ContinueApply handles both rebase and cherry-pick conflicts
- Unified conflict messaging in cmd/modify.go (both types show --continue)
- Updated test to verify cherry-pick conflict state is saved correctly

* Apply suggestions from code review

Co-authored-by: Luke Ghenco <lukeghenco@github.com>
Co-authored-by: Sameen Karim <skarim@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Luke Ghenco <lukeghenco@github.com>
2026-05-04 22:34:42 -04:00

344 lines
9.1 KiB
Go

package stackview
import (
"fmt"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/github/gh-stack/internal/git"
ghapi "github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/stack"
"github.com/stretchr/testify/assert"
)
func makeNodes(branches ...string) []BranchNode {
nodes := make([]BranchNode, len(branches))
for i, b := range branches {
nodes[i] = BranchNode{
Ref: stack.BranchRef{Branch: b},
}
}
return nodes
}
func keyMsg(k string) tea.KeyMsg {
switch k {
case "up":
return tea.KeyMsg(tea.Key{Type: tea.KeyUp})
case "down":
return tea.KeyMsg(tea.Key{Type: tea.KeyDown})
case "enter":
return tea.KeyMsg(tea.Key{Type: tea.KeyEnter})
case "esc":
return tea.KeyMsg(tea.Key{Type: tea.KeyEscape})
case "ctrl+c":
return tea.KeyMsg(tea.Key{Type: tea.KeyCtrlC})
default:
// Single rune key like 'c', 'f', 'q', 'o'
return tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune(k)})
}
}
var testTrunk = stack.BranchRef{Branch: "main"}
func TestNew_CursorAtCurrentBranch(t *testing.T) {
nodes := makeNodes("b1", "b2", "b3")
nodes[1].IsCurrent = true
m := New(nodes, testTrunk, "0.0.1")
assert.Equal(t, 1, m.cursor)
}
func TestNew_CursorAtZeroWhenNoCurrent(t *testing.T) {
nodes := makeNodes("b1", "b2", "b3")
m := New(nodes, testTrunk, "0.0.1")
assert.Equal(t, 0, m.cursor)
}
func TestUpdate_KeyboardNavigation(t *testing.T) {
nodes := makeNodes("b1", "b2", "b3")
m := New(nodes, testTrunk, "0.0.1")
assert.Equal(t, 0, m.cursor)
// Down
updated, _ := m.Update(keyMsg("down"))
m = updated.(Model)
assert.Equal(t, 1, m.cursor)
// Down again
updated, _ = m.Update(keyMsg("down"))
m = updated.(Model)
assert.Equal(t, 2, m.cursor)
// Down at bottom — should clamp
updated, _ = m.Update(keyMsg("down"))
m = updated.(Model)
assert.Equal(t, 2, m.cursor, "cursor should clamp at bottom")
// Up
updated, _ = m.Update(keyMsg("up"))
m = updated.(Model)
assert.Equal(t, 1, m.cursor)
// Up
updated, _ = m.Update(keyMsg("up"))
m = updated.(Model)
assert.Equal(t, 0, m.cursor)
// Up at top — should clamp
updated, _ = m.Update(keyMsg("up"))
m = updated.(Model)
assert.Equal(t, 0, m.cursor, "cursor should clamp at top")
}
func TestUpdate_ToggleCommits(t *testing.T) {
nodes := makeNodes("b1", "b2")
nodes[0].Commits = []git.CommitInfo{{SHA: "abc", Subject: "test"}}
m := New(nodes, testTrunk, "0.0.1")
assert.False(t, m.nodes[0].CommitsExpanded)
updated, _ := m.Update(keyMsg("c"))
m = updated.(Model)
assert.True(t, m.nodes[0].CommitsExpanded)
// Toggle back
updated, _ = m.Update(keyMsg("c"))
m = updated.(Model)
assert.False(t, m.nodes[0].CommitsExpanded)
}
func TestUpdate_ToggleFiles(t *testing.T) {
nodes := makeNodes("b1", "b2")
m := New(nodes, testTrunk, "0.0.1")
assert.False(t, m.nodes[0].FilesExpanded)
updated, _ := m.Update(keyMsg("f"))
m = updated.(Model)
assert.True(t, m.nodes[0].FilesExpanded)
// Toggle back
updated, _ = m.Update(keyMsg("f"))
m = updated.(Model)
assert.False(t, m.nodes[0].FilesExpanded)
}
func TestUpdate_Quit(t *testing.T) {
nodes := makeNodes("b1")
m := New(nodes, testTrunk, "0.0.1")
quitKeys := []string{"q", "esc", "ctrl+c"}
for _, k := range quitKeys {
t.Run(k, func(t *testing.T) {
_, cmd := m.Update(keyMsg(k))
assert.NotNil(t, cmd, "key %q should produce a quit command", k)
})
}
}
func TestUpdate_CheckoutOnEnter(t *testing.T) {
nodes := makeNodes("b1", "b2")
nodes[0].IsCurrent = true
nodes[1].PR = &ghapi.PRDetails{Number: 42, URL: "https://github.com/pr/42"}
m := New(nodes, testTrunk, "0.0.1")
// Move to b2 (non-current)
updated, _ := m.Update(keyMsg("down"))
m = updated.(Model)
assert.Equal(t, 1, m.cursor)
// Press enter on non-current node
updated, cmd := m.Update(keyMsg("enter"))
m = updated.(Model)
assert.Equal(t, "b2", m.CheckoutBranch())
assert.NotNil(t, cmd, "enter on non-current should produce quit command")
}
func TestUpdate_EnterOnCurrentDoesNothing(t *testing.T) {
nodes := makeNodes("b1", "b2")
nodes[0].IsCurrent = true
m := New(nodes, testTrunk, "0.0.1")
assert.Equal(t, 0, m.cursor)
// Press enter on current node
updated, cmd := m.Update(keyMsg("enter"))
m = updated.(Model)
assert.Equal(t, "", m.CheckoutBranch(), "enter on current branch should not set checkout")
assert.Nil(t, cmd, "enter on current branch should not quit")
}
func TestView_HeaderShownWhenTallEnough(t *testing.T) {
nodes := makeNodes("b1", "b2")
m := New(nodes, testTrunk, "0.0.1")
// Simulate a tall and wide terminal
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
view := m.View()
assert.Contains(t, view, "┌")
assert.Contains(t, view, "┘")
assert.Contains(t, view, "View Stack")
assert.Contains(t, view, "v0.0.1")
assert.Contains(t, view, "Base: main")
assert.Contains(t, view, "2 branches")
assert.Contains(t, view, "↑")
assert.Contains(t, view, "quit")
}
func TestView_HeaderHiddenWhenShort(t *testing.T) {
nodes := makeNodes("b1")
m := New(nodes, testTrunk, "0.0.1")
// Simulate a short terminal (below minHeightForHeader)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
m = updated.(Model)
view := m.View()
// Should NOT contain header box
assert.NotContains(t, view, "┌")
assert.NotContains(t, view, "View Stack")
// Should NOT contain help bar either (hints are only in header)
assert.NotContains(t, view, "commits")
}
func TestView_HeaderHiddenWhenNarrow(t *testing.T) {
nodes := makeNodes("b1")
m := New(nodes, testTrunk, "0.0.1")
// Tall but too narrow for header (below minWidthForHeader)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 35, Height: 40})
m = updated.(Model)
view := m.View()
assert.NotContains(t, view, "┌")
assert.NotContains(t, view, "View Stack")
}
func TestView_HeaderShortcutsAlwaysVisible(t *testing.T) {
nodes := makeNodes("b1", "b2")
m := New(nodes, testTrunk, "0.0.1")
// Even at medium width, shortcuts should still be visible
updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 40})
m = updated.(Model)
view := m.View()
assert.Contains(t, view, "┌", "header should be shown")
assert.Contains(t, view, "checkout", "shortcuts should always be visible")
}
func TestView_HeaderShowsMergedCount(t *testing.T) {
nodes := makeNodes("b1", "b2", "b3")
nodes[0].Ref.PullRequest = &stack.PullRequestRef{Merged: true}
m := New(nodes, testTrunk, "0.0.1")
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
view := m.View()
assert.Contains(t, view, "3 branches (1 merged)")
}
func TestView_HeaderShowsQueuedCount(t *testing.T) {
nodes := makeNodes("b1", "b2", "b3")
nodes[1].Ref.Queued = true
nodes[1].Ref.PullRequest = &stack.PullRequestRef{Number: 10}
m := New(nodes, testTrunk, "0.0.1")
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
view := m.View()
assert.Contains(t, view, "3 branches (1 queued)")
}
func TestView_QueuedPRShowsQueuedLabel(t *testing.T) {
nodes := makeNodes("b1")
nodes[0].PR = &ghapi.PRDetails{Number: 99, IsQueued: true}
m := New(nodes, testTrunk, "0.0.1")
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
m = updated.(Model)
view := m.View()
assert.Contains(t, view, "QUEUED")
assert.Contains(t, view, "#99")
}
func TestView_BranchProgressIcon(t *testing.T) {
tests := []struct {
name string
merged []int // indices of merged branches
total int
wantIcon string
}{
{"none merged", nil, 3, "○"},
{"some merged", []int{0}, 3, "◐"},
{"all merged", []int{0, 1, 2}, 3, "●"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
names := make([]string, tt.total)
for i := range names {
names[i] = fmt.Sprintf("b%d", i)
}
nodes := makeNodes(names...)
for _, idx := range tt.merged {
nodes[idx].Ref.PullRequest = &stack.PullRequestRef{Merged: true}
}
m := New(nodes, testTrunk, "0.0.1")
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
view := m.View()
assert.Contains(t, view, tt.wantIcon)
})
}
}
func TestMouseClick_HeaderAreaIgnored(t *testing.T) {
nodes := makeNodes("b1", "b2")
m := New(nodes, testTrunk, "0.0.1")
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
// Click inside the header area (row 5 is inside the 12-line header)
updated, _ = m.Update(tea.MouseMsg{
Action: tea.MouseActionPress,
Button: tea.MouseButtonLeft,
X: 10,
Y: 5,
})
result := updated.(Model)
assert.Equal(t, 0, result.cursor, "clicking in header should not change cursor")
}
func TestScrollClamp_CannotScrollPastContent(t *testing.T) {
nodes := makeNodes("b1", "b2")
m := New(nodes, testTrunk, "0.0.1")
// Tall terminal with plenty of room for content
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 40})
m = updated.(Model)
// Scroll down many times — should not scroll past content
for i := 0; i < 50; i++ {
updated, _ = m.Update(tea.MouseMsg{
Action: tea.MouseActionPress,
Button: tea.MouseButtonWheelDown,
})
m = updated.(Model)
}
// scrollOffset should be clamped (content fits in view, so offset stays 0)
view := m.View()
assert.Contains(t, view, "b1", "content should still be visible after excessive scrolling")
}