diff --git a/README.md b/README.md index bd7489f..270c3c6 100644 --- a/README.md +++ b/README.md @@ -475,6 +475,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 trunk # Jump to the trunk branch gh stack switch # Interactively pick a branch to switch to ``` @@ -488,6 +489,7 @@ gh stack up 3 # move up three layers gh stack down gh stack top gh stack bottom +gh stack trunk # jump to the trunk branch (e.g., main) gh stack switch # shows an interactive picker ``` diff --git a/cmd/root.go b/cmd/root.go index 65bfe71..0c9e07e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -128,6 +128,10 @@ locally, then push to GitHub to create your stack of PRs.`, bottomCmd.GroupID = "nav" root.AddCommand(bottomCmd) + trunkCmd := TrunkCmd(cfg) + trunkCmd.GroupID = "nav" + root.AddCommand(trunkCmd) + // Utility commands aliasCmd := AliasCmd(cfg) aliasCmd.GroupID = "utils" diff --git a/cmd/trunk.go b/cmd/trunk.go new file mode 100644 index 0000000..22f18c4 --- /dev/null +++ b/cmd/trunk.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/spf13/cobra" +) + +func TrunkCmd(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "trunk", + Short: "Check out the trunk branch of the stack", + Long: `Check out the trunk branch of the current stack. + +The trunk is the base branch that the stack is built on (e.g., main or develop). +You must be on a branch that is part of a stack.`, + Example: ` # Jump to the trunk branch + $ gh stack trunk`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runTrunk(cfg) + }, + } +} + +func runTrunk(cfg *config.Config) error { + result, err := loadStack(cfg, "") + if err != nil { + return ErrNotInStack + } + s := result.Stack + currentBranch := result.CurrentBranch + trunk := s.Trunk.Branch + + if currentBranch == trunk { + cfg.Printf("Already on trunk branch %s", trunk) + return nil + } + + if err := git.CheckoutBranch(trunk); err != nil { + return err + } + + cfg.Successf("Switched to %s", trunk) + return nil +} diff --git a/cmd/trunk_test.go b/cmd/trunk_test.go new file mode 100644 index 0000000..4302060 --- /dev/null +++ b/cmd/trunk_test.go @@ -0,0 +1,217 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "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" +) + +func TestTrunk_FromMiddleBranch(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b2", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := TrunkCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"main"}, checkedOut) +} + +func TestTrunk_AlreadyOnTrunk(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cmd := TrunkCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + output := readCfgOutput(cfg, outR, errR) + + assert.NoError(t, err) + assert.Empty(t, checkedOut, "should not checkout any branch") + assert.Contains(t, output, "Already on trunk branch main") +} + +func TestTrunk_FromTopOfStack(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b3", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := TrunkCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"main"}, checkedOut) +} + +func TestTrunk_NotInStack(t *testing.T) { + tmpDir := t.TempDir() + // No stack file written — empty git dir + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "some-branch", nil }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := TrunkCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.Error(t, err) +} + +func TestTrunk_CheckoutFailure(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + return fmt.Errorf("checkout failed: uncommitted changes") + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := TrunkCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.Error(t, err) +} + +func TestTrunk_CustomTrunkBranch(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "develop"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := TrunkCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"develop"}, checkedOut) +} + +func TestTrunk_RejectsArgs(t *testing.T) { + // Ensure trunk does not accept arguments + tmpDir := t.TempDir() + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + } + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + } + restore := git.SetOps(mock) + defer restore() + + // Suppress cobra's automatic os.Exit on error for test + _ = os.Stderr + + cfg, _, _ := config.NewTestConfig() + cmd := TrunkCmd(cfg) + cmd.SetArgs([]string{"unexpected-arg"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.Error(t, err, "should reject positional arguments") +} diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index ca0a143..3990f69 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -505,6 +505,16 @@ gh stack bottom Checks out the branch closest to the trunk. +### `gh stack trunk` + +Jump to the trunk branch. + +```sh +gh stack trunk +``` + +Checks out the trunk branch of the current stack (e.g., `main`). You must be on a branch that is part of a stack. + --- ## Utilities