Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
switch cmd to interactively switch to another branch in the stack
  • Loading branch information
skarim committed Apr 18, 2026
commit fcf8c8b2656e903a5a96a32978fee7249f16897a
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
88 changes: 88 additions & 0 deletions cmd/switch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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)
}

p := prompter.New(cfg.In, cfg.Out, cfg.Err)
selectFn := func(prompt, def string, opts []string) (int, error) {
return p.Select(prompt, def, opts)
}
if cfg.SelectFn != nil {
selectFn = cfg.SelectFn
Comment thread
skarim marked this conversation as resolved.
Outdated
}

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.
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)
}
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ type Config struct {
// ForceInteractive, when true, makes IsInteractive() return true
// regardless of the terminal state. Used in tests.
ForceInteractive bool

// SelectFn, when non-nil, is called instead of prompting via the
// terminal. Used in tests to simulate interactive selection.
SelectFn func(prompt, defaultValue string, options []string) (int, error)
}

// New creates a new Config with terminal-aware output and color support.
Expand Down