-
Notifications
You must be signed in to change notification settings - Fork 7
switch command #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+401
−1
Open
switch command #51
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| 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 | ||
|
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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.