Skip to content

Commit 33214db

Browse files
committed
switch cmd to interactively switch to another branch in the stack
1 parent 32de12d commit 33214db

File tree

4 files changed

+366
-0
lines changed

4 files changed

+366
-0
lines changed

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func RootCmd() *cobra.Command {
4848
root.AddCommand(DownCmd(cfg))
4949
root.AddCommand(TopCmd(cfg))
5050
root.AddCommand(BottomCmd(cfg))
51+
root.AddCommand(SwitchCmd(cfg))
5152

5253
// Alias
5354
root.AddCommand(AliasCmd(cfg))

cmd/switch.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/cli/go-gh/v2/pkg/prompter"
7+
"github.com/github/gh-stack/internal/config"
8+
"github.com/github/gh-stack/internal/git"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func SwitchCmd(cfg *config.Config) *cobra.Command {
13+
return &cobra.Command{
14+
Use: "switch",
15+
Short: "Interactively switch to another branch in the stack",
16+
Long: `Show an interactive picker listing all branches in the current
17+
stack and switch to the selected one.
18+
19+
Branches are displayed from top (furthest from trunk) to bottom
20+
(closest to trunk) with their position number.`,
21+
Args: cobra.NoArgs,
22+
RunE: func(cmd *cobra.Command, args []string) error {
23+
return runSwitch(cfg)
24+
},
25+
}
26+
}
27+
28+
func runSwitch(cfg *config.Config) error {
29+
result, err := loadStack(cfg, "")
30+
if err != nil {
31+
return ErrNotInStack
32+
}
33+
s := result.Stack
34+
35+
if len(s.Branches) == 0 {
36+
cfg.Errorf("stack has no branches")
37+
return ErrNotInStack
38+
}
39+
40+
if !cfg.IsInteractive() {
41+
cfg.Errorf("switch requires an interactive terminal")
42+
return ErrSilent
43+
}
44+
45+
// Build options in reverse order (top of stack first) with 1-based numbering.
46+
n := len(s.Branches)
47+
options := make([]string, n)
48+
for i := 0; i < n; i++ {
49+
branchIdx := n - 1 - i
50+
options[i] = fmt.Sprintf("%d. %s", branchIdx+1, s.Branches[branchIdx].Branch)
51+
}
52+
53+
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
54+
selectFn := func(prompt, def string, opts []string) (int, error) {
55+
return p.Select(prompt, def, opts)
56+
}
57+
if cfg.SelectFn != nil {
58+
selectFn = cfg.SelectFn
59+
}
60+
61+
selected, err := selectFn("Select a branch in the stack to switch to:", "", options)
62+
if err != nil {
63+
if isInterruptError(err) {
64+
clearSelectPrompt(cfg, len(options))
65+
printInterrupt(cfg)
66+
return errInterrupt
67+
}
68+
return ErrSilent
69+
}
70+
71+
// Map selection back: index 0 in options = branch at n-1, etc.
72+
branchIdx := n - 1 - selected
73+
targetBranch := s.Branches[branchIdx].Branch
74+
75+
currentBranch := result.CurrentBranch
76+
if targetBranch == currentBranch {
77+
cfg.Infof("Already on %s", targetBranch)
78+
return nil
79+
}
80+
81+
if err := git.CheckoutBranch(targetBranch); err != nil {
82+
cfg.Errorf("failed to checkout %s: %v", targetBranch, err)
83+
return ErrSilent
84+
}
85+
86+
cfg.Successf("Switched to %s", targetBranch)
87+
return nil
88+
}

cmd/switch_test.go

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
package cmd
2+
3+
import (
4+
"io"
5+
"testing"
6+
7+
"github.com/github/gh-stack/internal/config"
8+
"github.com/github/gh-stack/internal/git"
9+
"github.com/github/gh-stack/internal/stack"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestSwitch_SwitchesToSelectedBranch(t *testing.T) {
15+
gitDir := t.TempDir()
16+
var checkedOut string
17+
18+
restore := git.SetOps(&git.MockOps{
19+
GitDirFn: func() (string, error) { return gitDir, nil },
20+
CurrentBranchFn: func() (string, error) { return "b1", nil },
21+
CheckoutBranchFn: func(name string) error {
22+
checkedOut = name
23+
return nil
24+
},
25+
})
26+
defer restore()
27+
28+
writeStackFile(t, gitDir, stack.Stack{
29+
Trunk: stack.BranchRef{Branch: "main"},
30+
Branches: []stack.BranchRef{
31+
{Branch: "b1"},
32+
{Branch: "b2"},
33+
{Branch: "b3"},
34+
},
35+
})
36+
37+
cfg, outR, errR := config.NewTestConfig()
38+
cfg.ForceInteractive = true
39+
40+
// Simulate selecting the first option (index 0) which is "3. b3" (top of stack)
41+
cfg.SelectFn = func(prompt, def string, options []string) (int, error) {
42+
// Verify prompt text
43+
assert.Equal(t, "Select a branch in the stack to switch to:", prompt)
44+
// Verify options are in reverse order with numbering
45+
assert.Equal(t, []string{"3. b3", "2. b2", "1. b1"}, options)
46+
return 0, nil // select "3. b3"
47+
}
48+
49+
err := runSwitch(cfg)
50+
output := collectOutput(cfg, outR, errR)
51+
52+
require.NoError(t, err)
53+
assert.Equal(t, "b3", checkedOut)
54+
assert.Contains(t, output, "Switched to b3")
55+
}
56+
57+
func TestSwitch_SelectMiddleBranch(t *testing.T) {
58+
gitDir := t.TempDir()
59+
var checkedOut string
60+
61+
restore := git.SetOps(&git.MockOps{
62+
GitDirFn: func() (string, error) { return gitDir, nil },
63+
CurrentBranchFn: func() (string, error) { return "b1", nil },
64+
CheckoutBranchFn: func(name string) error {
65+
checkedOut = name
66+
return nil
67+
},
68+
})
69+
defer restore()
70+
71+
writeStackFile(t, gitDir, stack.Stack{
72+
Trunk: stack.BranchRef{Branch: "main"},
73+
Branches: []stack.BranchRef{
74+
{Branch: "b1"},
75+
{Branch: "b2"},
76+
{Branch: "b3"},
77+
},
78+
})
79+
80+
cfg, outR, errR := config.NewTestConfig()
81+
cfg.ForceInteractive = true
82+
83+
// Select index 1 which is "2. b2"
84+
cfg.SelectFn = func(prompt, def string, options []string) (int, error) {
85+
return 1, nil // select "2. b2"
86+
}
87+
88+
err := runSwitch(cfg)
89+
output := collectOutput(cfg, outR, errR)
90+
91+
require.NoError(t, err)
92+
assert.Equal(t, "b2", checkedOut)
93+
assert.Contains(t, output, "Switched to b2")
94+
}
95+
96+
func TestSwitch_AlreadyOnSelectedBranch(t *testing.T) {
97+
gitDir := t.TempDir()
98+
checkoutCalled := false
99+
100+
restore := git.SetOps(&git.MockOps{
101+
GitDirFn: func() (string, error) { return gitDir, nil },
102+
CurrentBranchFn: func() (string, error) { return "b2", nil },
103+
CheckoutBranchFn: func(name string) error {
104+
checkoutCalled = true
105+
return nil
106+
},
107+
})
108+
defer restore()
109+
110+
writeStackFile(t, gitDir, stack.Stack{
111+
Trunk: stack.BranchRef{Branch: "main"},
112+
Branches: []stack.BranchRef{
113+
{Branch: "b1"},
114+
{Branch: "b2"},
115+
{Branch: "b3"},
116+
},
117+
})
118+
119+
cfg, outR, errR := config.NewTestConfig()
120+
cfg.ForceInteractive = true
121+
122+
// Select "2. b2" which is option index 1 — the branch we're already on
123+
cfg.SelectFn = func(prompt, def string, options []string) (int, error) {
124+
return 1, nil // select "2. b2"
125+
}
126+
127+
err := runSwitch(cfg)
128+
output := collectOutput(cfg, outR, errR)
129+
130+
require.NoError(t, err)
131+
assert.False(t, checkoutCalled, "CheckoutBranch should not be called when already on target")
132+
assert.Contains(t, output, "Already on b2")
133+
}
134+
135+
func TestSwitch_NotInStack(t *testing.T) {
136+
gitDir := t.TempDir()
137+
restore := git.SetOps(&git.MockOps{
138+
GitDirFn: func() (string, error) { return gitDir, nil },
139+
CurrentBranchFn: func() (string, error) { return "orphan", nil },
140+
})
141+
defer restore()
142+
143+
// Write a stack that doesn't contain "orphan"
144+
writeStackFile(t, gitDir, stack.Stack{
145+
Trunk: stack.BranchRef{Branch: "main"},
146+
Branches: []stack.BranchRef{{Branch: "b1"}},
147+
})
148+
149+
cfg, _, _ := config.NewTestConfig()
150+
cfg.ForceInteractive = true
151+
152+
err := runSwitch(cfg)
153+
assert.ErrorIs(t, err, ErrNotInStack)
154+
}
155+
156+
func TestSwitch_NonInteractive(t *testing.T) {
157+
gitDir := t.TempDir()
158+
restore := git.SetOps(&git.MockOps{
159+
GitDirFn: func() (string, error) { return gitDir, nil },
160+
CurrentBranchFn: func() (string, error) { return "b1", nil },
161+
})
162+
defer restore()
163+
164+
writeStackFile(t, gitDir, stack.Stack{
165+
Trunk: stack.BranchRef{Branch: "main"},
166+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
167+
})
168+
169+
cfg, outR, errR := config.NewTestConfig()
170+
// ForceInteractive not set — non-interactive mode
171+
172+
err := runSwitch(cfg)
173+
output := collectOutput(cfg, outR, errR)
174+
175+
assert.ErrorIs(t, err, ErrSilent)
176+
assert.Contains(t, output, "switch requires an interactive terminal")
177+
}
178+
179+
func TestSwitch_DisplayOrder(t *testing.T) {
180+
gitDir := t.TempDir()
181+
restore := git.SetOps(&git.MockOps{
182+
GitDirFn: func() (string, error) { return gitDir, nil },
183+
CurrentBranchFn: func() (string, error) { return "first", nil },
184+
CheckoutBranchFn: func(name string) error {
185+
return nil
186+
},
187+
})
188+
defer restore()
189+
190+
writeStackFile(t, gitDir, stack.Stack{
191+
Trunk: stack.BranchRef{Branch: "main"},
192+
Branches: []stack.BranchRef{
193+
{Branch: "first"},
194+
{Branch: "second"},
195+
{Branch: "third"},
196+
{Branch: "fourth"},
197+
{Branch: "fifth"},
198+
},
199+
})
200+
201+
cfg, _, _ := config.NewTestConfig()
202+
cfg.ForceInteractive = true
203+
204+
var capturedOptions []string
205+
cfg.SelectFn = func(prompt, def string, options []string) (int, error) {
206+
capturedOptions = options
207+
return 0, nil // select top
208+
}
209+
210+
err := runSwitch(cfg)
211+
require.NoError(t, err)
212+
213+
expected := []string{
214+
"5. fifth",
215+
"4. fourth",
216+
"3. third",
217+
"2. second",
218+
"1. first",
219+
}
220+
assert.Equal(t, expected, capturedOptions)
221+
}
222+
223+
func TestSwitch_NoBranches(t *testing.T) {
224+
gitDir := t.TempDir()
225+
restore := git.SetOps(&git.MockOps{
226+
GitDirFn: func() (string, error) { return gitDir, nil },
227+
CurrentBranchFn: func() (string, error) { return "main", nil },
228+
})
229+
defer restore()
230+
231+
writeStackFile(t, gitDir, stack.Stack{
232+
Trunk: stack.BranchRef{Branch: "main"},
233+
Branches: []stack.BranchRef{},
234+
})
235+
236+
cfg, _, _ := config.NewTestConfig()
237+
cfg.ForceInteractive = true
238+
239+
err := runSwitch(cfg)
240+
assert.ErrorIs(t, err, ErrNotInStack)
241+
}
242+
243+
func TestSwitch_CmdIntegration(t *testing.T) {
244+
gitDir := t.TempDir()
245+
restore := git.SetOps(&git.MockOps{
246+
GitDirFn: func() (string, error) { return gitDir, nil },
247+
CurrentBranchFn: func() (string, error) { return "b1", nil },
248+
CheckoutBranchFn: func(name string) error {
249+
return nil
250+
},
251+
})
252+
defer restore()
253+
254+
writeStackFile(t, gitDir, stack.Stack{
255+
Trunk: stack.BranchRef{Branch: "main"},
256+
Branches: []stack.BranchRef{
257+
{Branch: "b1"},
258+
{Branch: "b2"},
259+
},
260+
})
261+
262+
cfg, _, _ := config.NewTestConfig()
263+
cfg.ForceInteractive = true
264+
cfg.SelectFn = func(prompt, def string, options []string) (int, error) {
265+
return 0, nil // select top
266+
}
267+
268+
cmd := SwitchCmd(cfg)
269+
cmd.SetOut(io.Discard)
270+
cmd.SetErr(io.Discard)
271+
err := cmd.Execute()
272+
assert.NoError(t, err)
273+
}

internal/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ type Config struct {
3434
// ForceInteractive, when true, makes IsInteractive() return true
3535
// regardless of the terminal state. Used in tests.
3636
ForceInteractive bool
37+
38+
// SelectFn, when non-nil, is called instead of prompting via the
39+
// terminal. Used in tests to simulate interactive selection.
40+
SelectFn func(prompt, defaultValue string, options []string) (int, error)
3741
}
3842

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

0 commit comments

Comments
 (0)