Skip to content

Commit 2536e9a

Browse files
committed
view stack
1 parent 8ade8bc commit 2536e9a

6 files changed

Lines changed: 381 additions & 1 deletion

File tree

cmd/root.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,14 @@ func RootCmd() *cobra.Command {
2121
root.SetOut(cfg.Out)
2222
root.SetErr(cfg.Err)
2323

24+
// Local operations
2425
root.AddCommand(InitCmd(cfg))
2526
root.AddCommand(AddCmd(cfg))
2627

28+
// Helper commands
29+
root.AddCommand(ViewCmd(cfg))
30+
31+
// Placeholders for upcoming features
2732
for _, ph := range placeholderCommands {
2833
root.AddCommand(PlaceholderCmd(ph, cfg))
2934
}

cmd/view.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"strings"
9+
"time"
10+
11+
"github.com/cli/go-gh/v2/pkg/browser"
12+
"github.com/github/gh-stack/internal/config"
13+
"github.com/github/gh-stack/internal/git"
14+
"github.com/github/gh-stack/internal/stack"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
type viewOptions struct {
19+
short bool
20+
web bool
21+
}
22+
23+
func ViewCmd(cfg *config.Config) *cobra.Command {
24+
opts := &viewOptions{}
25+
26+
cmd := &cobra.Command{
27+
Use: "view",
28+
Short: "View the current stack",
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
return runView(cfg, opts)
31+
},
32+
}
33+
34+
cmd.Flags().BoolVarP(&opts.short, "short", "s", false, "Show compact output")
35+
cmd.Flags().BoolVarP(&opts.web, "web", "w", false, "Open PRs in the browser")
36+
37+
return cmd
38+
}
39+
40+
func runView(cfg *config.Config, opts *viewOptions) error {
41+
gitDir, err := git.GitDir()
42+
if err != nil {
43+
cfg.Errorf("not a git repository")
44+
return nil
45+
}
46+
47+
sf, err := stack.Load(gitDir)
48+
if err != nil {
49+
cfg.Errorf("failed to load stack state: %s", err)
50+
return nil
51+
}
52+
53+
currentBranch, err := git.CurrentBranch()
54+
if err != nil {
55+
cfg.Errorf("failed to get current branch: %s", err)
56+
return nil
57+
}
58+
59+
s := sf.FindStackForBranch(currentBranch)
60+
if s == nil {
61+
cfg.Errorf("current branch %q is not part of a stack", currentBranch)
62+
cfg.Printf("Checkout an existing stack using %s or create a new stack using %s", cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init"))
63+
return nil
64+
}
65+
66+
if opts.web {
67+
return viewWeb(cfg, s)
68+
}
69+
70+
if opts.short {
71+
return viewShort(cfg, s, currentBranch)
72+
}
73+
74+
return viewFull(cfg, s, currentBranch)
75+
}
76+
77+
func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error {
78+
for i := len(s.Branches) - 1; i >= 0; i-- {
79+
b := s.Branches[i]
80+
if b.Branch == currentBranch {
81+
cfg.Outf("● %s %s\n", cfg.ColorBold(b.Branch), cfg.ColorCyan("(current)"))
82+
} else {
83+
cfg.Outf("○ %s\n", b.Branch)
84+
}
85+
}
86+
cfg.Outf("└ %s\n", s.Trunk.Branch)
87+
return nil
88+
}
89+
90+
func viewFull(cfg *config.Config, s *stack.Stack, currentBranch string) error {
91+
client, clientErr := cfg.GitHubClient()
92+
93+
var repoOwner, repoName string
94+
repo, repoErr := cfg.Repo()
95+
if repoErr == nil {
96+
repoOwner = repo.Owner
97+
repoName = repo.Name
98+
}
99+
100+
var buf bytes.Buffer
101+
102+
for i := len(s.Branches) - 1; i >= 0; i-- {
103+
b := s.Branches[i]
104+
isCurrent := b.Branch == currentBranch
105+
106+
bullet := "○"
107+
if isCurrent {
108+
bullet = "●"
109+
}
110+
111+
prInfo := ""
112+
if clientErr == nil && repoErr == nil {
113+
pr, err := client.FindPRForBranch(b.Branch)
114+
if err == nil && pr != nil {
115+
prInfo = fmt.Sprintf(" https://github.com/%s/%s/pull/%d", repoOwner, repoName, pr.Number)
116+
}
117+
}
118+
119+
branchName := cfg.ColorMagenta(b.Branch)
120+
if isCurrent {
121+
branchName = cfg.ColorCyan(b.Branch + " (current)")
122+
}
123+
124+
fmt.Fprintf(&buf, "%s %s%s\n", bullet, branchName, prInfo)
125+
126+
commits, err := git.Log(b.Branch, 1)
127+
if err == nil && len(commits) > 0 {
128+
c := commits[0]
129+
short := c.SHA
130+
if len(short) > 7 {
131+
short = short[:7]
132+
}
133+
fmt.Fprintf(&buf, "│ %s %s\n", short, cfg.ColorGray("· "+timeAgo(c.Time)))
134+
fmt.Fprintf(&buf, "│ %s\n", cfg.ColorGray(c.Subject))
135+
}
136+
137+
fmt.Fprintf(&buf, "│\n")
138+
}
139+
140+
fmt.Fprintf(&buf, "└ %s\n", s.Trunk.Branch)
141+
142+
return runPager(cfg, buf.String())
143+
}
144+
145+
func runPager(cfg *config.Config, content string) error {
146+
if !cfg.IsInteractive() {
147+
_, err := fmt.Fprint(cfg.Out, content)
148+
return err
149+
}
150+
151+
pagerCmd := os.Getenv("GIT_PAGER")
152+
if pagerCmd == "" {
153+
pagerCmd = os.Getenv("PAGER")
154+
}
155+
if pagerCmd == "" {
156+
pagerCmd = "less"
157+
}
158+
159+
args := strings.Fields(pagerCmd)
160+
if args[0] == "less" {
161+
hasR := false
162+
for _, a := range args[1:] {
163+
if strings.Contains(a, "R") {
164+
hasR = true
165+
break
166+
}
167+
}
168+
if !hasR {
169+
args = append(args, "-R")
170+
}
171+
}
172+
173+
cmd := exec.Command(args[0], args[1:]...)
174+
cmd.Stdout = cfg.Out
175+
cmd.Stderr = cfg.Err
176+
cmd.Stdin = strings.NewReader(content)
177+
178+
return cmd.Run()
179+
}
180+
181+
func timeAgo(t time.Time) string {
182+
d := time.Since(t)
183+
switch {
184+
case d < time.Minute:
185+
secs := int(d.Seconds())
186+
if secs == 1 {
187+
return "1 second ago"
188+
}
189+
return fmt.Sprintf("%d seconds ago", secs)
190+
case d < time.Hour:
191+
mins := int(d.Minutes())
192+
if mins == 1 {
193+
return "1 minute ago"
194+
}
195+
return fmt.Sprintf("%d minutes ago", mins)
196+
case d < 24*time.Hour:
197+
hours := int(d.Hours())
198+
if hours == 1 {
199+
return "1 hour ago"
200+
}
201+
return fmt.Sprintf("%d hours ago", hours)
202+
case d < 30*24*time.Hour:
203+
days := int(d.Hours() / 24)
204+
if days == 1 {
205+
return "1 day ago"
206+
}
207+
return fmt.Sprintf("%d days ago", days)
208+
default:
209+
months := int(d.Hours() / 24 / 30)
210+
if months <= 1 {
211+
return "1 month ago"
212+
}
213+
return fmt.Sprintf("%d months ago", months)
214+
}
215+
}
216+
217+
func viewWeb(cfg *config.Config, s *stack.Stack) error {
218+
client, err := cfg.GitHubClient()
219+
if err != nil {
220+
return err
221+
}
222+
223+
repo, err := cfg.Repo()
224+
if err != nil {
225+
return err
226+
}
227+
228+
b := browser.New("", cfg.Out, cfg.Err)
229+
230+
opened := 0
231+
for _, br := range s.Branches {
232+
pr, err := client.FindPRForBranch(br.Branch)
233+
if err != nil || pr == nil {
234+
continue
235+
}
236+
url := fmt.Sprintf("https://github.com/%s/%s/pull/%d", repo.Owner, repo.Name, pr.Number)
237+
if err := b.Browse(url); err != nil {
238+
cfg.Warningf("failed to open %s: %v\n", url, err)
239+
} else {
240+
opened++
241+
}
242+
}
243+
244+
if opened == 0 {
245+
cfg.Printf("No PRs found to open in browser.\n")
246+
} else {
247+
cfg.Successf("Opened %d PRs in browser\n", opened)
248+
}
249+
250+
return nil
251+
}

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.25.7
55
require (
66
github.com/cli/cli/v2 v2.86.0
77
github.com/cli/go-gh/v2 v2.13.0
8+
github.com/cli/shurcooL-graphql v0.0.4
89
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
910
github.com/spf13/cobra v1.10.2
1011
)
@@ -17,8 +18,11 @@ require (
1718
github.com/charmbracelet/x/ansi v0.10.2 // indirect
1819
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
1920
github.com/charmbracelet/x/term v0.2.1 // indirect
21+
github.com/cli/browser v1.3.0 // indirect
2022
github.com/cli/safeexec v1.0.1 // indirect
2123
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
24+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
25+
github.com/henvic/httpretty v0.1.4 // indirect
2226
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2327
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
2428
github.com/kr/pretty v0.3.1 // indirect
@@ -32,6 +36,7 @@ require (
3236
github.com/rivo/uniseg v0.4.7 // indirect
3337
github.com/spf13/pflag v1.0.10 // indirect
3438
github.com/stretchr/testify v1.11.1 // indirect
39+
github.com/thlib/go-timezone-local v0.0.6 // indirect
3540
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
3641
golang.org/x/sys v0.39.0 // indirect
3742
golang.org/x/term v0.38.0 // indirect

go.sum

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@ github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ
1616
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
1717
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
1818
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
19+
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
20+
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
1921
github.com/cli/cli/v2 v2.86.0 h1:114DaPhDvKNMp8MTLffN119mHe040eNhNgLv3qi3mNA=
2022
github.com/cli/cli/v2 v2.86.0/go.mod h1:cMrBHQOYc0MdNBseT5pUT6uxhvz4gcf010FEO7bWsP8=
2123
github.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys=
2224
github.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM=
2325
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
2426
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
27+
github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY=
28+
github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk=
2529
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2630
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
2731
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
@@ -31,6 +35,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
3135
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3236
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
3337
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
38+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
39+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
40+
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
41+
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
42+
github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU=
43+
github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM=
3444
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
3545
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
3646
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -79,6 +89,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
7989
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
8090
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
8191
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
92+
github.com/thlib/go-timezone-local v0.0.6 h1:Ii3QJ4FhosL/+eCZl6Hsdr4DDU4tfevNoV83yAEo2tU=
93+
github.com/thlib/go-timezone-local v0.0.6/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
8294
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
8395
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
8496
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -97,6 +109,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
97109
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
98110
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
99111
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
112+
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
100113
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
101114
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
102115
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -115,10 +128,14 @@ golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
115128
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
116129
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
117130
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
131+
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
132+
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
118133
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
119134
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
120135
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
121136
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
137+
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
138+
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
122139
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
123140
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
124141
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)