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
594 lines
19 KiB
Go
594 lines
19 KiB
Go
package cmd
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/github/gh-stack/internal/config"
|
|
"github.com/github/gh-stack/internal/git"
|
|
"github.com/github/gh-stack/internal/stack"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// saveStack is a helper to pre-create a stack file for add tests.
|
|
func saveStack(t *testing.T, gitDir string, s stack.Stack) {
|
|
t.Helper()
|
|
sf := &stack.StackFile{
|
|
SchemaVersion: 1,
|
|
Stacks: []stack.Stack{s},
|
|
}
|
|
require.NoError(t, stack.Save(gitDir, sf), "saving seed stack")
|
|
}
|
|
|
|
func TestAdd_CreatesNewBranch(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
})
|
|
|
|
var createdBranch, checkedOut string
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
CreateBranchFn: func(name, base string) error {
|
|
createdBranch = name
|
|
return nil
|
|
},
|
|
CheckoutBranchFn: func(name string) error {
|
|
checkedOut = name
|
|
return nil
|
|
},
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
runAdd(cfg, &addOptions{}, []string{"newbranch"})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NotContains(t, output, "\u2717", "unexpected error")
|
|
assert.Equal(t, "newbranch", createdBranch, "CreateBranch")
|
|
assert.Equal(t, "newbranch", checkedOut, "CheckoutBranch")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err, "loading stack")
|
|
names := sf.Stacks[0].BranchNames()
|
|
assert.Equal(t, "newbranch", names[len(names)-1], "top branch")
|
|
}
|
|
|
|
func TestAdd_OnlyAllowedOnTopOfStack(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1"},
|
|
{Branch: "b2"},
|
|
{Branch: "b3"},
|
|
},
|
|
})
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
runAdd(cfg, &addOptions{}, []string{"newbranch"})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.Contains(t, output, "top of the stack")
|
|
assert.Contains(t, output, "gh stack modify")
|
|
}
|
|
|
|
func TestAdd_MutuallyExclusiveFlags(t *testing.T) {
|
|
restore := git.SetOps(&git.MockOps{})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
runAdd(cfg, &addOptions{stageAll: true, stageTracked: true, message: "msg"}, []string{"branch"})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.Contains(t, output, "mutually exclusive")
|
|
}
|
|
|
|
func TestAdd_StagingWithoutMessageUsesEditor(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
})
|
|
|
|
interactiveCalled := false
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
RevParseMultiFn: func(refs []string) ([]string, error) {
|
|
return []string{"aaa", "bbb"}, nil
|
|
},
|
|
RevParseFn: func(ref string) (string, error) { return "abc", nil },
|
|
CreateBranchFn: func(name, base string) error { return nil },
|
|
CheckoutBranchFn: func(name string) error { return nil },
|
|
StageAllFn: func() error { return nil },
|
|
HasStagedChangesFn: func() bool { return true },
|
|
CommitInteractiveFn: func() (string, error) {
|
|
interactiveCalled = true
|
|
return "def1234567890", nil
|
|
},
|
|
})
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
runAdd(cfg, &addOptions{stageAll: true}, []string{"new-branch"})
|
|
|
|
assert.True(t, interactiveCalled, "expected CommitInteractive to be called when -m is omitted")
|
|
}
|
|
|
|
func TestAdd_EmptyBranchCommitsInPlace(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
})
|
|
|
|
createBranchCalled := false
|
|
commitCalled := false
|
|
stageAllCalled := false
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
RevParseMultiFn: func(refs []string) ([]string, error) {
|
|
// Return same SHA for parent and current branch — branch has no unique commits
|
|
return []string{"aaa111", "aaa111"}, nil
|
|
},
|
|
StageAllFn: func() error {
|
|
stageAllCalled = true
|
|
return nil
|
|
},
|
|
HasStagedChangesFn: func() bool { return true },
|
|
CommitFn: func(msg string) (string, error) {
|
|
commitCalled = true
|
|
return "abc1234567890", nil
|
|
},
|
|
CreateBranchFn: func(name, base string) error {
|
|
createBranchCalled = true
|
|
return nil
|
|
},
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
runAdd(cfg, &addOptions{stageAll: true, message: "Auth middleware"}, nil)
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NotContains(t, output, "\u2717", "unexpected error")
|
|
assert.True(t, stageAllCalled, "expected StageAll to be called")
|
|
assert.True(t, commitCalled, "expected Commit to be called")
|
|
assert.False(t, createBranchCalled, "CreateBranch should NOT be called for empty branch commit-in-place")
|
|
}
|
|
|
|
func TestAdd_BranchWithCommitsCreatesNew(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
})
|
|
|
|
createCalled := false
|
|
checkoutCalled := false
|
|
commitCalled := false
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
RevParseMultiFn: func(refs []string) ([]string, error) {
|
|
// Parent and current branch point to different commits (branch has commits)
|
|
return []string{"aaa", "bbb"}, nil
|
|
},
|
|
CreateBranchFn: func(name, base string) error {
|
|
createCalled = true
|
|
return nil
|
|
},
|
|
CheckoutBranchFn: func(name string) error {
|
|
checkoutCalled = true
|
|
return nil
|
|
},
|
|
HasStagedChangesFn: func() bool { return true },
|
|
CommitFn: func(msg string) (string, error) {
|
|
commitCalled = true
|
|
return "def1234567890", nil
|
|
},
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
runAdd(cfg, &addOptions{stageAll: true, message: "API routes"}, nil)
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NotContains(t, output, "\u2717", "unexpected error")
|
|
assert.True(t, createCalled, "expected CreateBranch to be called")
|
|
assert.True(t, checkoutCalled, "expected CheckoutBranch to be called")
|
|
assert.True(t, commitCalled, "expected Commit to be called on the new branch")
|
|
}
|
|
|
|
func TestAdd_PrefixAppliedWithSlash(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Prefix: "feat",
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "feat/01"}},
|
|
})
|
|
|
|
var createdBranch string
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
|
|
CreateBranchFn: func(name, base string) error {
|
|
createdBranch = name
|
|
return nil
|
|
},
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
runAdd(cfg, &addOptions{}, []string{"mybranch"})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NotContains(t, output, "\u2717", "unexpected error")
|
|
assert.Equal(t, "feat/mybranch", createdBranch)
|
|
}
|
|
|
|
func TestAdd_NumberedNaming(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Prefix: "feat",
|
|
Numbered: true,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "feat/01"}},
|
|
})
|
|
|
|
var createdBranch string
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
|
|
RevParseMultiFn: func(refs []string) ([]string, error) {
|
|
return []string{"aaa", "bbb"}, nil
|
|
},
|
|
CreateBranchFn: func(name, base string) error {
|
|
createdBranch = name
|
|
return nil
|
|
},
|
|
HasStagedChangesFn: func() bool { return true },
|
|
CommitFn: func(msg string) (string, error) {
|
|
return "def1234567890", nil
|
|
},
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
runAdd(cfg, &addOptions{stageAll: true, message: "next feature"}, nil)
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NotContains(t, output, "\u2717", "unexpected error")
|
|
assert.Equal(t, "feat/02", createdBranch)
|
|
}
|
|
|
|
func TestAdd_FullyMergedStackBlocked(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}},
|
|
},
|
|
})
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b2", nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
runAdd(cfg, &addOptions{}, []string{"newbranch"})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.Contains(t, output, "All branches in this stack have been merged")
|
|
}
|
|
|
|
func TestAdd_NothingToCommit(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
})
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
RevParseMultiFn: func(refs []string) ([]string, error) {
|
|
return []string{"aaa", "aaa"}, nil // same SHA = empty branch
|
|
},
|
|
StageAllFn: func() error { return nil },
|
|
HasStagedChangesFn: func() bool { return false },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
runAdd(cfg, &addOptions{stageAll: true, message: "msg"}, nil)
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.Contains(t, output, "no changes to commit")
|
|
}
|
|
|
|
func TestAdd_PromptPrefillsPrefix(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Prefix: "feat",
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "feat/01"}},
|
|
})
|
|
|
|
var createdBranch string
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
|
|
CreateBranchFn: func(name, base string) error {
|
|
createdBranch = name
|
|
return nil
|
|
},
|
|
CheckoutBranchFn: func(name string) error { return nil },
|
|
RevParseFn: func(ref string) (string, error) { return "abc", nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
|
|
var gotPrompt, gotDefault string
|
|
cfg.InputFn = func(prompt, defaultValue string) (string, error) {
|
|
gotPrompt = prompt
|
|
gotDefault = defaultValue
|
|
return "feat/my-branch", nil
|
|
}
|
|
|
|
err := runAdd(cfg, &addOptions{}, nil)
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
require.NotContains(t, output, "\u2717", "unexpected error")
|
|
assert.Contains(t, gotPrompt, ":", "prompt should end with a colon")
|
|
assert.Equal(t, "feat/", gotDefault, "prompt should pre-fill prefix/")
|
|
assert.Equal(t, "feat/my-branch", createdBranch, "full input should be used as branch name")
|
|
}
|
|
|
|
func TestAdd_PromptNoPrefixEmptyDefault(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
})
|
|
|
|
var createdBranch string
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
CreateBranchFn: func(name, base string) error {
|
|
createdBranch = name
|
|
return nil
|
|
},
|
|
CheckoutBranchFn: func(name string) error { return nil },
|
|
RevParseFn: func(ref string) (string, error) { return "abc", nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
|
|
var gotDefault string
|
|
cfg.InputFn = func(prompt, defaultValue string) (string, error) {
|
|
gotDefault = defaultValue
|
|
return "my-branch", nil
|
|
}
|
|
|
|
err := runAdd(cfg, &addOptions{}, nil)
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
require.NotContains(t, output, "\u2717", "unexpected error")
|
|
assert.Equal(t, "", gotDefault, "prompt should have empty default when no prefix")
|
|
assert.Equal(t, "my-branch", createdBranch, "input should be used as-is")
|
|
}
|
|
|
|
func TestAdd_PromptUserModifiesPrefix(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Prefix: "feat",
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "feat/01"}},
|
|
})
|
|
|
|
var createdBranch string
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
|
|
CreateBranchFn: func(name, base string) error {
|
|
createdBranch = name
|
|
return nil
|
|
},
|
|
CheckoutBranchFn: func(name string) error { return nil },
|
|
RevParseFn: func(ref string) (string, error) { return "abc", nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
|
|
cfg.InputFn = func(prompt, defaultValue string) (string, error) {
|
|
// Simulate user changing the prefix entirely
|
|
return "custom/other-name", nil
|
|
}
|
|
|
|
err := runAdd(cfg, &addOptions{}, nil)
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
require.NotContains(t, output, "\u2717", "unexpected error")
|
|
assert.Equal(t, "custom/other-name", createdBranch, "user-modified input should be used verbatim")
|
|
}
|
|
|
|
func TestAdd_FromTrunk(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
})
|
|
|
|
var createdBranch string
|
|
var checkedOut string
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "main", nil },
|
|
CreateBranchFn: func(name, base string) error {
|
|
createdBranch = name
|
|
return nil
|
|
},
|
|
CheckoutBranchFn: func(name string) error {
|
|
checkedOut = name
|
|
return nil
|
|
},
|
|
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
err := runAdd(cfg, &addOptions{}, []string{"newbranch"})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
// When on trunk, idx < 0 so the middle-of-stack check passes.
|
|
// Add should succeed and create the new branch.
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "newbranch", createdBranch)
|
|
assert.Equal(t, "newbranch", checkedOut)
|
|
assert.NotContains(t, output, "\u2717")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
names := sf.Stacks[0].BranchNames()
|
|
assert.Equal(t, "newbranch", names[len(names)-1], "new branch should be appended to stack")
|
|
}
|
|
|
|
func TestAdd_AdoptsExistingBranch(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
})
|
|
|
|
createBranchCalled := false
|
|
var checkedOut string
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
BranchExistsFn: func(name string) bool { return name == "existing-branch" },
|
|
CreateBranchFn: func(name, base string) error {
|
|
createBranchCalled = true
|
|
return nil
|
|
},
|
|
CheckoutBranchFn: func(name string) error {
|
|
checkedOut = name
|
|
return nil
|
|
},
|
|
RevParseFn: func(ref string) (string, error) { return "abc", nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
err := runAdd(cfg, &addOptions{}, []string{"existing-branch"})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
require.NotContains(t, output, "\u2717", "unexpected error")
|
|
assert.False(t, createBranchCalled, "CreateBranch should NOT be called for existing branch")
|
|
assert.Equal(t, "existing-branch", checkedOut, "should checkout the existing branch")
|
|
assert.Contains(t, output, "Adopted")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
names := sf.Stacks[0].BranchNames()
|
|
assert.Equal(t, "existing-branch", names[len(names)-1], "adopted branch appended to stack")
|
|
}
|
|
|
|
func TestAdd_RejectsExistingBranchInStack(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
// Two stacks: the current one and another that owns "taken-branch"
|
|
sf := &stack.StackFile{
|
|
SchemaVersion: 1,
|
|
Stacks: []stack.Stack{
|
|
{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
},
|
|
{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "taken-branch"}},
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, stack.Save(gitDir, sf), "saving seed stacks")
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
BranchExistsFn: func(name string) bool { return name == "taken-branch" },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
err := runAdd(cfg, &addOptions{}, []string{"taken-branch"})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.ErrorIs(t, err, ErrInvalidArgs)
|
|
assert.Contains(t, output, "already exists in the stack")
|
|
}
|
|
|
|
func TestAdd_AdoptsExistingBranchWithCommit(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
saveStack(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
})
|
|
|
|
createBranchCalled := false
|
|
commitCalled := false
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
BranchExistsFn: func(name string) bool { return name == "existing-branch" },
|
|
RevParseMultiFn: func(refs []string) ([]string, error) {
|
|
return []string{"aaa", "bbb"}, nil // different SHAs = branch has commits
|
|
},
|
|
CreateBranchFn: func(name, base string) error {
|
|
createBranchCalled = true
|
|
return nil
|
|
},
|
|
CheckoutBranchFn: func(name string) error { return nil },
|
|
RevParseFn: func(ref string) (string, error) { return "abc", nil },
|
|
StageAllFn: func() error { return nil },
|
|
HasStagedChangesFn: func() bool { return true },
|
|
CommitFn: func(msg string) (string, error) {
|
|
commitCalled = true
|
|
return "def1234567890", nil
|
|
},
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
err := runAdd(cfg, &addOptions{stageAll: true, message: "new commit"}, []string{"existing-branch"})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
require.NotContains(t, output, "\u2717", "unexpected error")
|
|
assert.False(t, createBranchCalled, "CreateBranch should NOT be called")
|
|
assert.True(t, commitCalled, "Commit should be called on the adopted branch")
|
|
assert.Contains(t, output, "Adopted")
|
|
}
|