Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ gh stack up [n] # Move up n branches (default 1)
gh stack down [n] # Move down n branches (default 1)
gh stack top # Jump to the top of the stack
gh stack bottom # Jump to the bottom of the stack
gh stack switch # Interactively pick a branch to switch to
```

Navigation commands clamp to the bounds of the stack — moving up from the top or down from the bottom is a no-op with a message. If you're on the trunk branch, `up` moves to the first stack branch.
Expand All @@ -430,6 +431,7 @@ gh stack up 3 # move up three layers
gh stack down
gh stack top
gh stack bottom
gh stack switch # shows an interactive picker
```

### `gh stack feedback`
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func RootCmd() *cobra.Command {
root.AddCommand(DownCmd(cfg))
root.AddCommand(TopCmd(cfg))
root.AddCommand(BottomCmd(cfg))
root.AddCommand(SwitchCmd(cfg))

// Alias
root.AddCommand(AliasCmd(cfg))
Expand Down
96 changes: 96 additions & 0 deletions cmd/switch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package cmd

import (
"fmt"

"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/spf13/cobra"
)

func SwitchCmd(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "switch",
Short: "Interactively switch to another branch in the stack",
Long: `Show an interactive picker listing all branches in the current
stack and switch to the selected one.

Branches are displayed from top (furthest from trunk) to bottom
(closest to trunk) with their position number.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runSwitch(cfg)
},
}
}

func runSwitch(cfg *config.Config) error {
result, err := loadStack(cfg, "")
if err != nil {
return ErrNotInStack
}
s := result.Stack

if len(s.Branches) == 0 {
cfg.Errorf("stack has no branches")
return ErrNotInStack
}

if !cfg.IsInteractive() {
cfg.Errorf("switch requires an interactive terminal")
return ErrSilent
}

// Build options in reverse order (top of stack first) with 1-based numbering.
n := len(s.Branches)
options := make([]string, n)
for i := 0; i < n; i++ {
branchIdx := n - 1 - i
options[i] = fmt.Sprintf("%d. %s", branchIdx+1, s.Branches[branchIdx].Branch)
}

var selectFn func(prompt, def string, opts []string) (int, error)
if cfg.SelectFn != nil {
selectFn = cfg.SelectFn
} else {
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
selectFn = func(prompt, def string, opts []string) (int, error) {
return p.Select(prompt, def, opts)
}
}

selected, err := selectFn("Select a branch in the stack to switch to:", "", options)
if err != nil {
if isInterruptError(err) {
clearSelectPrompt(cfg, len(options))
printInterrupt(cfg)
return errInterrupt
}
Comment thread
skarim marked this conversation as resolved.
cfg.Errorf("failed to select branch: %v", err)
return ErrSilent
}

if selected < 0 || selected >= n {
cfg.Errorf("invalid selection")
return ErrSilent
}

// Map selection back: index 0 in options = branch at n-1, etc.
branchIdx := n - 1 - selected
targetBranch := s.Branches[branchIdx].Branch
Comment thread
skarim marked this conversation as resolved.

currentBranch := result.CurrentBranch
if targetBranch == currentBranch {
cfg.Infof("Already on %s", targetBranch)
return nil
}

if err := git.CheckoutBranch(targetBranch); err != nil {
cfg.Errorf("failed to checkout %s: %v", targetBranch, err)
return ErrSilent
}

cfg.Successf("Switched to %s", targetBranch)
return nil
}
273 changes: 273 additions & 0 deletions cmd/switch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
package cmd

import (
"io"
"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"
)

func TestSwitch_SwitchesToSelectedBranch(t *testing.T) {
gitDir := t.TempDir()
var checkedOut string

restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
})
defer restore()

writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
})

cfg, outR, errR := config.NewTestConfig()
cfg.ForceInteractive = true

// Simulate selecting the first option (index 0) which is "3. b3" (top of stack)
cfg.SelectFn = func(prompt, def string, options []string) (int, error) {
// Verify prompt text
assert.Equal(t, "Select a branch in the stack to switch to:", prompt)
// Verify options are in reverse order with numbering
assert.Equal(t, []string{"3. b3", "2. b2", "1. b1"}, options)
return 0, nil // select "3. b3"
}

err := runSwitch(cfg)
output := collectOutput(cfg, outR, errR)

require.NoError(t, err)
assert.Equal(t, "b3", checkedOut)
assert.Contains(t, output, "Switched to b3")
}

func TestSwitch_SelectMiddleBranch(t *testing.T) {
gitDir := t.TempDir()
var checkedOut string

restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
})
defer restore()

writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
})

cfg, outR, errR := config.NewTestConfig()
cfg.ForceInteractive = true

// Select index 1 which is "2. b2"
cfg.SelectFn = func(prompt, def string, options []string) (int, error) {
return 1, nil // select "2. b2"
}

err := runSwitch(cfg)
output := collectOutput(cfg, outR, errR)

require.NoError(t, err)
assert.Equal(t, "b2", checkedOut)
assert.Contains(t, output, "Switched to b2")
}

func TestSwitch_AlreadyOnSelectedBranch(t *testing.T) {
gitDir := t.TempDir()
checkoutCalled := false

restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "b2", nil },
CheckoutBranchFn: func(name string) error {
checkoutCalled = true
return nil
},
})
defer restore()

writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
})

cfg, outR, errR := config.NewTestConfig()
cfg.ForceInteractive = true

// Select "2. b2" which is option index 1 — the branch we're already on
cfg.SelectFn = func(prompt, def string, options []string) (int, error) {
return 1, nil // select "2. b2"
}

err := runSwitch(cfg)
output := collectOutput(cfg, outR, errR)

require.NoError(t, err)
assert.False(t, checkoutCalled, "CheckoutBranch should not be called when already on target")
assert.Contains(t, output, "Already on b2")
}

func TestSwitch_NotInStack(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "orphan", nil },
})
defer restore()

// Write a stack that doesn't contain "orphan"
writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "b1"}},
})

cfg, _, _ := config.NewTestConfig()
cfg.ForceInteractive = true

err := runSwitch(cfg)
assert.ErrorIs(t, err, ErrNotInStack)
}

func TestSwitch_NonInteractive(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
})
defer restore()

writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
})

cfg, outR, errR := config.NewTestConfig()
// ForceInteractive not set — non-interactive mode

err := runSwitch(cfg)
output := collectOutput(cfg, outR, errR)

assert.ErrorIs(t, err, ErrSilent)
assert.Contains(t, output, "switch requires an interactive terminal")
}

func TestSwitch_DisplayOrder(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "first", nil },
CheckoutBranchFn: func(name string) error {
return nil
},
})
defer restore()

writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "first"},
{Branch: "second"},
{Branch: "third"},
{Branch: "fourth"},
{Branch: "fifth"},
},
})

cfg, _, _ := config.NewTestConfig()
cfg.ForceInteractive = true

var capturedOptions []string
cfg.SelectFn = func(prompt, def string, options []string) (int, error) {
capturedOptions = options
return 0, nil // select top
}

err := runSwitch(cfg)
require.NoError(t, err)

expected := []string{
"5. fifth",
"4. fourth",
"3. third",
"2. second",
"1. first",
}
assert.Equal(t, expected, capturedOptions)
}

func TestSwitch_NoBranches(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()

writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{},
})

cfg, _, _ := config.NewTestConfig()
cfg.ForceInteractive = true

err := runSwitch(cfg)
assert.ErrorIs(t, err, ErrNotInStack)
}

func TestSwitch_CmdIntegration(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
CheckoutBranchFn: func(name string) error {
return nil
},
})
defer restore()

writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
},
})

cfg, _, _ := config.NewTestConfig()
cfg.ForceInteractive = true
cfg.SelectFn = func(prompt, def string, options []string) (int, error) {
return 0, nil // select top
}

cmd := SwitchCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
assert.NoError(t, err)
}
Loading