Skip to content
Merged
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
51 changes: 51 additions & 0 deletions cmd/trunk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cmd

import (
"errors"

"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 {
if errors.Is(err, errInterrupt) {
return ErrSilent
}
return ErrNotInStack
}
Comment thread
skarim marked this conversation as resolved.
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
}
214 changes: 214 additions & 0 deletions cmd/trunk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package cmd

import (
"fmt"
"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"
)

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.ErrorIs(t, err, ErrNotInStack)
}

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)
assert.ErrorContains(t, err, "checkout failed")
}
Comment thread
skarim marked this conversation as resolved.

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()

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
2 changes: 1 addition & 1 deletion skills/gh-stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: >
branch chains, or incremental code review workflows.
metadata:
author: github
version: "0.0.4"
version: "0.0.5"
---

# gh-stack
Expand Down
Loading