From 875e88535e91003cffa952d28c7e6be4151f4a1e Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 17 Apr 2026 04:10:41 -0400 Subject: [PATCH 1/4] switch cmd to interactively switch to another branch in the stack --- cmd/root.go | 1 + cmd/switch.go | 88 ++++++++++++ cmd/switch_test.go | 273 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 4 + 4 files changed, 366 insertions(+) create mode 100644 cmd/switch.go create mode 100644 cmd/switch_test.go diff --git a/cmd/root.go b/cmd/root.go index b29a23d..6e17b7c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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)) diff --git a/cmd/switch.go b/cmd/switch.go new file mode 100644 index 0000000..6c26c44 --- /dev/null +++ b/cmd/switch.go @@ -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 + } + + 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 + } + return ErrSilent + } + + // Map selection back: index 0 in options = branch at n-1, etc. + branchIdx := n - 1 - selected + targetBranch := s.Branches[branchIdx].Branch + + 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 +} diff --git a/cmd/switch_test.go b/cmd/switch_test.go new file mode 100644 index 0000000..3b97588 --- /dev/null +++ b/cmd/switch_test.go @@ -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) +} diff --git a/internal/config/config.go b/internal/config/config.go index f254e56..e706dd0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. From 62d6c82d1ecabd17a7da27b54bbcd8e3e5cc1f96 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 17 Apr 2026 04:14:39 -0400 Subject: [PATCH 2/4] add switch to docs --- README.md | 2 ++ docs/src/content/docs/reference/cli.md | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index da524af..da2d4eb 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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` diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index f54dee9..ba213f5 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -410,6 +410,30 @@ gh stack bottom Checks out the branch closest to the trunk. +### `gh stack switch` + +Interactively switch to another branch in the stack. + +```sh +gh stack switch +``` + +Shows an interactive picker listing all branches in the current stack, ordered from top (furthest from trunk) to bottom (closest to trunk) with their position number. Select a branch to check it out. + +Requires an interactive terminal. + +**Examples:** + +```sh +gh stack switch +# → Select a branch in the stack to switch to +# 5. frontend +# 4. api-endpoints +# 3. auth-layer +# 2. db-schema +# 1. config-setup +``` + --- ## Utilities From 16211845d5733db1e341efad7df4c936b93d7696 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 17 Apr 2026 04:31:16 -0400 Subject: [PATCH 3/4] addressing review comments --- cmd/switch.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/switch.go b/cmd/switch.go index 6c26c44..3379527 100644 --- a/cmd/switch.go +++ b/cmd/switch.go @@ -50,12 +50,14 @@ func runSwitch(cfg *config.Config) error { 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) - } + 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) @@ -65,6 +67,12 @@ func runSwitch(cfg *config.Config) error { printInterrupt(cfg) return errInterrupt } + cfg.Errorf("failed to select branch: %v", err) + return ErrSilent + } + + if selected < 0 || selected >= n { + cfg.Errorf("invalid selection") return ErrSilent } From 5bbf9e0a31ca60ce6b03c6da84a66f091ebc6bfa Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 17 Apr 2026 04:33:26 -0400 Subject: [PATCH 4/4] bump skill file version --- skills/gh-stack/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 0bff246..0717dde 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -7,7 +7,7 @@ description: > branch chains, or incremental code review workflows. metadata: author: github - version: "0.0.1" + version: "0.0.2" --- # gh-stack