Skip to content

Commit 878545a

Browse files
committed
Add gh stack trunk navigation command
Add a new navigation command that checks out the trunk branch of the current stack. The command is stack-aware: it requires the user to be on a branch that is part of a stack, loads the stack metadata, and checks out `s.Trunk.Branch`. If the user is already on the trunk branch, it prints a message and exits without calling git checkout. New files: - cmd/trunk.go: TrunkCmd (cobra command) + runTrunk implementation - cmd/trunk_test.go: 7 test cases covering happy path, already on trunk, from top of stack, not in a stack, checkout failure, custom trunk branch name, and positional argument rejection Modified files: - cmd/root.go: register TrunkCmd in the "nav" command group - README.md: add `gh stack trunk` to the Navigation section - docs/src/content/docs/reference/cli.md: add `gh stack trunk` reference section
1 parent 64f0b96 commit 878545a

5 files changed

Lines changed: 279 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,7 @@ gh stack up [n] # Move up n branches (default 1)
475475
gh stack down [n] # Move down n branches (default 1)
476476
gh stack top # Jump to the top of the stack
477477
gh stack bottom # Jump to the bottom of the stack
478+
gh stack trunk # Jump to the trunk branch
478479
gh stack switch # Interactively pick a branch to switch to
479480
```
480481

@@ -488,6 +489,7 @@ gh stack up 3 # move up three layers
488489
gh stack down
489490
gh stack top
490491
gh stack bottom
492+
gh stack trunk # jump to the trunk branch (e.g., main)
491493
gh stack switch # shows an interactive picker
492494
```
493495

cmd/root.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@ locally, then push to GitHub to create your stack of PRs.`,
128128
bottomCmd.GroupID = "nav"
129129
root.AddCommand(bottomCmd)
130130

131+
trunkCmd := TrunkCmd(cfg)
132+
trunkCmd.GroupID = "nav"
133+
root.AddCommand(trunkCmd)
134+
131135
// Utility commands
132136
aliasCmd := AliasCmd(cfg)
133137
aliasCmd.GroupID = "utils"

cmd/trunk.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package cmd
2+
3+
import (
4+
"github.com/github/gh-stack/internal/config"
5+
"github.com/github/gh-stack/internal/git"
6+
"github.com/spf13/cobra"
7+
)
8+
9+
func TrunkCmd(cfg *config.Config) *cobra.Command {
10+
return &cobra.Command{
11+
Use: "trunk",
12+
Short: "Check out the trunk branch of the stack",
13+
Long: `Check out the trunk branch of the current stack.
14+
15+
The trunk is the base branch that the stack is built on (e.g., main or develop).
16+
You must be on a branch that is part of a stack.`,
17+
Example: ` # Jump to the trunk branch
18+
$ gh stack trunk`,
19+
Args: cobra.NoArgs,
20+
RunE: func(cmd *cobra.Command, args []string) error {
21+
return runTrunk(cfg)
22+
},
23+
}
24+
}
25+
26+
func runTrunk(cfg *config.Config) error {
27+
result, err := loadStack(cfg, "")
28+
if err != nil {
29+
return ErrNotInStack
30+
}
31+
s := result.Stack
32+
currentBranch := result.CurrentBranch
33+
trunk := s.Trunk.Branch
34+
35+
if currentBranch == trunk {
36+
cfg.Printf("Already on trunk branch %s", trunk)
37+
return nil
38+
}
39+
40+
if err := git.CheckoutBranch(trunk); err != nil {
41+
return err
42+
}
43+
44+
cfg.Successf("Switched to %s", trunk)
45+
return nil
46+
}

cmd/trunk_test.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"testing"
8+
9+
"github.com/github/gh-stack/internal/config"
10+
"github.com/github/gh-stack/internal/git"
11+
"github.com/github/gh-stack/internal/stack"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func TestTrunk_FromMiddleBranch(t *testing.T) {
16+
s := stack.Stack{
17+
Trunk: stack.BranchRef{Branch: "main"},
18+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}},
19+
}
20+
21+
var checkedOut []string
22+
tmpDir := t.TempDir()
23+
writeStackFile(t, tmpDir, s)
24+
25+
mock := &git.MockOps{
26+
GitDirFn: func() (string, error) { return tmpDir, nil },
27+
CurrentBranchFn: func() (string, error) { return "b2", nil },
28+
CheckoutBranchFn: func(name string) error {
29+
checkedOut = append(checkedOut, name)
30+
return nil
31+
},
32+
}
33+
restore := git.SetOps(mock)
34+
defer restore()
35+
36+
cfg, _, _ := config.NewTestConfig()
37+
cmd := TrunkCmd(cfg)
38+
cmd.SetOut(io.Discard)
39+
cmd.SetErr(io.Discard)
40+
err := cmd.Execute()
41+
42+
assert.NoError(t, err)
43+
assert.Equal(t, []string{"main"}, checkedOut)
44+
}
45+
46+
func TestTrunk_AlreadyOnTrunk(t *testing.T) {
47+
s := stack.Stack{
48+
Trunk: stack.BranchRef{Branch: "main"},
49+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
50+
}
51+
52+
var checkedOut []string
53+
tmpDir := t.TempDir()
54+
writeStackFile(t, tmpDir, s)
55+
56+
mock := &git.MockOps{
57+
GitDirFn: func() (string, error) { return tmpDir, nil },
58+
CurrentBranchFn: func() (string, error) { return "main", nil },
59+
CheckoutBranchFn: func(name string) error {
60+
checkedOut = append(checkedOut, name)
61+
return nil
62+
},
63+
}
64+
restore := git.SetOps(mock)
65+
defer restore()
66+
67+
cfg, outR, errR := config.NewTestConfig()
68+
cmd := TrunkCmd(cfg)
69+
cmd.SetOut(io.Discard)
70+
cmd.SetErr(io.Discard)
71+
err := cmd.Execute()
72+
73+
output := readCfgOutput(cfg, outR, errR)
74+
75+
assert.NoError(t, err)
76+
assert.Empty(t, checkedOut, "should not checkout any branch")
77+
assert.Contains(t, output, "Already on trunk branch main")
78+
}
79+
80+
func TestTrunk_FromTopOfStack(t *testing.T) {
81+
s := stack.Stack{
82+
Trunk: stack.BranchRef{Branch: "main"},
83+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}},
84+
}
85+
86+
var checkedOut []string
87+
tmpDir := t.TempDir()
88+
writeStackFile(t, tmpDir, s)
89+
90+
mock := &git.MockOps{
91+
GitDirFn: func() (string, error) { return tmpDir, nil },
92+
CurrentBranchFn: func() (string, error) { return "b3", nil },
93+
CheckoutBranchFn: func(name string) error {
94+
checkedOut = append(checkedOut, name)
95+
return nil
96+
},
97+
}
98+
restore := git.SetOps(mock)
99+
defer restore()
100+
101+
cfg, _, _ := config.NewTestConfig()
102+
cmd := TrunkCmd(cfg)
103+
cmd.SetOut(io.Discard)
104+
cmd.SetErr(io.Discard)
105+
err := cmd.Execute()
106+
107+
assert.NoError(t, err)
108+
assert.Equal(t, []string{"main"}, checkedOut)
109+
}
110+
111+
func TestTrunk_NotInStack(t *testing.T) {
112+
tmpDir := t.TempDir()
113+
// No stack file written — empty git dir
114+
115+
mock := &git.MockOps{
116+
GitDirFn: func() (string, error) { return tmpDir, nil },
117+
CurrentBranchFn: func() (string, error) { return "some-branch", nil },
118+
}
119+
restore := git.SetOps(mock)
120+
defer restore()
121+
122+
cfg, _, _ := config.NewTestConfig()
123+
cmd := TrunkCmd(cfg)
124+
cmd.SetOut(io.Discard)
125+
cmd.SetErr(io.Discard)
126+
err := cmd.Execute()
127+
128+
assert.Error(t, err)
129+
}
130+
131+
func TestTrunk_CheckoutFailure(t *testing.T) {
132+
s := stack.Stack{
133+
Trunk: stack.BranchRef{Branch: "main"},
134+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
135+
}
136+
137+
tmpDir := t.TempDir()
138+
writeStackFile(t, tmpDir, s)
139+
140+
mock := &git.MockOps{
141+
GitDirFn: func() (string, error) { return tmpDir, nil },
142+
CurrentBranchFn: func() (string, error) { return "b1", nil },
143+
CheckoutBranchFn: func(name string) error {
144+
return fmt.Errorf("checkout failed: uncommitted changes")
145+
},
146+
}
147+
restore := git.SetOps(mock)
148+
defer restore()
149+
150+
cfg, _, _ := config.NewTestConfig()
151+
cmd := TrunkCmd(cfg)
152+
cmd.SetOut(io.Discard)
153+
cmd.SetErr(io.Discard)
154+
err := cmd.Execute()
155+
156+
assert.Error(t, err)
157+
}
158+
159+
func TestTrunk_CustomTrunkBranch(t *testing.T) {
160+
s := stack.Stack{
161+
Trunk: stack.BranchRef{Branch: "develop"},
162+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
163+
}
164+
165+
var checkedOut []string
166+
tmpDir := t.TempDir()
167+
writeStackFile(t, tmpDir, s)
168+
169+
mock := &git.MockOps{
170+
GitDirFn: func() (string, error) { return tmpDir, nil },
171+
CurrentBranchFn: func() (string, error) { return "b1", nil },
172+
CheckoutBranchFn: func(name string) error {
173+
checkedOut = append(checkedOut, name)
174+
return nil
175+
},
176+
}
177+
restore := git.SetOps(mock)
178+
defer restore()
179+
180+
cfg, _, _ := config.NewTestConfig()
181+
cmd := TrunkCmd(cfg)
182+
cmd.SetOut(io.Discard)
183+
cmd.SetErr(io.Discard)
184+
err := cmd.Execute()
185+
186+
assert.NoError(t, err)
187+
assert.Equal(t, []string{"develop"}, checkedOut)
188+
}
189+
190+
func TestTrunk_RejectsArgs(t *testing.T) {
191+
// Ensure trunk does not accept arguments
192+
tmpDir := t.TempDir()
193+
s := stack.Stack{
194+
Trunk: stack.BranchRef{Branch: "main"},
195+
Branches: []stack.BranchRef{{Branch: "b1"}},
196+
}
197+
writeStackFile(t, tmpDir, s)
198+
199+
mock := &git.MockOps{
200+
GitDirFn: func() (string, error) { return tmpDir, nil },
201+
CurrentBranchFn: func() (string, error) { return "b1", nil },
202+
}
203+
restore := git.SetOps(mock)
204+
defer restore()
205+
206+
// Suppress cobra's automatic os.Exit on error for test
207+
_ = os.Stderr
208+
209+
cfg, _, _ := config.NewTestConfig()
210+
cmd := TrunkCmd(cfg)
211+
cmd.SetArgs([]string{"unexpected-arg"})
212+
cmd.SetOut(io.Discard)
213+
cmd.SetErr(io.Discard)
214+
err := cmd.Execute()
215+
216+
assert.Error(t, err, "should reject positional arguments")
217+
}

docs/src/content/docs/reference/cli.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,16 @@ gh stack bottom
505505

506506
Checks out the branch closest to the trunk.
507507

508+
### `gh stack trunk`
509+
510+
Jump to the trunk branch.
511+
512+
```sh
513+
gh stack trunk
514+
```
515+
516+
Checks out the trunk branch of the current stack (e.g., `main`). You must be on a branch that is part of a stack.
517+
508518
---
509519

510520
## Utilities

0 commit comments

Comments
 (0)