Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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
```

Expand Down
4 changes: 4 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 46 additions & 0 deletions cmd/trunk.go
Original file line number Diff line number Diff line change
@@ -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
}
217 changes: 217 additions & 0 deletions cmd/trunk_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
10 changes: 10 additions & 0 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading