Skip to content

Commit 6eda231

Browse files
authored
switch command (#51)
* switch cmd to interactively switch to another branch in the stack * add switch to docs * addressing review comments * bump skill file version * default to current branch
1 parent e5653e7 commit 6eda231

7 files changed

Lines changed: 404 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ gh stack up [n] # Move up n branches (default 1)
418418
gh stack down [n] # Move down n branches (default 1)
419419
gh stack top # Jump to the top of the stack
420420
gh stack bottom # Jump to the bottom of the stack
421+
gh stack switch # Interactively pick a branch to switch to
421422
```
422423

423424
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.
@@ -430,6 +431,7 @@ gh stack up 3 # move up three layers
430431
gh stack down
431432
gh stack top
432433
gh stack bottom
434+
gh stack switch # shows an interactive picker
433435
```
434436

435437
### `gh stack feedback`

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: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
currentBranch := result.CurrentBranch
49+
var defaultOpt string
50+
for i := 0; i < n; i++ {
51+
branchIdx := n - 1 - i
52+
options[i] = fmt.Sprintf("%d. %s", branchIdx+1, s.Branches[branchIdx].Branch)
53+
if s.Branches[branchIdx].Branch == currentBranch {
54+
defaultOpt = options[i]
55+
}
56+
}
57+
58+
var selectFn func(prompt, def string, opts []string) (int, error)
59+
if cfg.SelectFn != nil {
60+
selectFn = cfg.SelectFn
61+
} else {
62+
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
63+
selectFn = func(prompt, def string, opts []string) (int, error) {
64+
return p.Select(prompt, def, opts)
65+
}
66+
}
67+
68+
selected, err := selectFn("Select a branch in the stack to switch to:", defaultOpt, options)
69+
if err != nil {
70+
if isInterruptError(err) {
71+
clearSelectPrompt(cfg, len(options))
72+
printInterrupt(cfg)
73+
return errInterrupt
74+
}
75+
cfg.Errorf("failed to select branch: %v", err)
76+
return ErrSilent
77+
}
78+
79+
if selected < 0 || selected >= n {
80+
cfg.Errorf("invalid selection")
81+
return ErrSilent
82+
}
83+
84+
// Map selection back: index 0 in options = branch at n-1, etc.
85+
branchIdx := n - 1 - selected
86+
targetBranch := s.Branches[branchIdx].Branch
87+
if targetBranch == currentBranch {
88+
cfg.Infof("Already on %s", targetBranch)
89+
return nil
90+
}
91+
92+
if err := git.CheckoutBranch(targetBranch); err != nil {
93+
cfg.Errorf("failed to checkout %s: %v", targetBranch, err)
94+
return ErrSilent
95+
}
96+
97+
cfg.Successf("Switched to %s", targetBranch)
98+
return nil
99+
}

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+
}

0 commit comments

Comments
 (0)