Commit b862e4a67c

b862e4a67ceed7aae3f59f0c6ee7e546ba45921f

parent: 02a6794816

Verified · cmc ci/build: success ci/test: failure

cmc <hello@cleberg.net> · 2026-09-24 03:23 UTC

gitbay: page views, diffs and logs at a terminal

Ref #254
cmd/gitbay/main.go +1 −1
@@ -290,7 +290,7 @@ func runPass(o passOpts, args []string) int {
290290 stdin = r
291291 }
292292 }
293 return runSSH(t, append(o.server, args...), stdin)
293 return runSSHPaged(t, append(o.server, args...), stdin, pages(o.server, args))
294294}
295295
296296func isEmptyReader(r io.Reader) bool {
cmd/gitbay/ssh.go +63 −1
@@ -12,6 +12,7 @@ import (
1212 "slices"
1313 "strconv"
1414 "strings"
15 "syscall"
1516
1617 "gitbay.org/gitbay/internal/cliconfig"
1718 "gitbay.org/gitbay/internal/protocol"
@@ -139,9 +140,40 @@ func stripNoColor(args []string) ([]string, bool) {
139140 return out, found
140141}
141142
143// pagerArgv is the pager to run long output through: GITBAY_PAGER, then
144// PAGER, then less. An empty GITBAY_PAGER turns paging off.
145func pagerArgv(env func(string) (string, bool)) []string {
146 if v, ok := env("GITBAY_PAGER"); ok {
147 return strings.Fields(v)
148 }
149 if v, ok := env("PAGER"); ok && v != "" {
150 return strings.Fields(v)
151 }
152 return []string{"less"}
153}
154
155// pages reports whether a command's output goes through the pager at a
156// terminal: views, diffs and logs, never a follow or JSON.
157func pages(server, args []string) bool {
158 if len(server) == 0 || slices.Contains(args, "--json") || slices.Contains(args, "--follow") {
159 return false
160 }
161 switch server[len(server)-1] {
162 case "show", "diff", "log":
163 return true
164 }
165 return false
166}
167
142168// runSSH executes the server command over the system ssh binary, wiring
143// stdio through. It returns the remote exit code.
169// stdio through, with no pager. It returns the remote exit code.
144170func runSSH(t target, serverArgv []string, stdin io.Reader) int {
171 return runSSHPaged(t, serverArgv, stdin, false)
172}
173
174// runSSHPaged is runSSH with output optionally run through the pager when
175// stdout is a terminal and page is true. It returns the remote exit code.
176func runSSHPaged(t target, serverArgv []string, stdin io.Reader, page bool) int {
145177 args := sshArgs(t.inst)
146178
147179 fd := int(os.Stdout.Fd())
@@ -164,11 +196,41 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int {
164196 cmd.Stdin = stdin
165197 cmd.Stdout = os.Stdout
166198 cmd.Stderr = os.Stderr
199
200 var pager *exec.Cmd
201 var pw io.WriteCloser
202 if page && isTTY {
203 if argv := pagerArgv(os.LookupEnv); len(argv) > 0 {
204 pager = exec.Command(toolpath.Look(argv[0]), argv[1:]...)
205 pager.Stdout, pager.Stderr = os.Stdout, os.Stderr
206 if _, ok := os.LookupEnv("LESS"); !ok {
207 pager.Env = append(os.Environ(), "LESS=FRX")
208 }
209 if w, err := pager.StdinPipe(); err == nil && pager.Start() == nil {
210 pw = w
211 cmd.Stdout = pw
212 } else {
213 pager = nil
214 }
215 }
216 }
217
167218 err := cmd.Run()
219 if pager != nil {
220 pw.Close()
221 pager.Wait()
222 }
168223 if err == nil {
169224 return 0
170225 }
171226 if ee, ok := err.(*exec.ExitError); ok {
227 if pager != nil {
228 if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() && ws.Signal() == syscall.SIGPIPE {
229 // The user quit the pager before ssh finished writing;
230 // that is not a failure of the command itself.
231 return 0
232 }
233 }
172234 code := ee.ExitCode()
173235 if code == 255 { // ssh-level failure (connection, auth, host key)
174236 fmt.Fprintln(os.Stderr, "gitbay: ssh could not connect or authenticate; if this worked a moment ago,"+
cmd/gitbay/term_test.go +42 −1
@@ -1,6 +1,9 @@
11package main
22
3import "testing"
3import (
4 "strings"
5 "testing"
6)
47
58func TestTermValue(t *testing.T) {
69 env := func(m map[string]string) func(string) string {
@@ -35,3 +38,41 @@ func TestStripNoColor(t *testing.T) {
3538 t.Errorf("got %v %v", args, ok)
3639 }
3740}
41
42func TestPagerArgv(t *testing.T) {
43 env := func(m map[string]string) func(string) (string, bool) {
44 return func(k string) (string, bool) { v, ok := m[k]; return v, ok }
45 }
46 cases := []struct {
47 env map[string]string
48 want string
49 }{
50 {nil, "less"},
51 {map[string]string{"PAGER": "more -s"}, "more -s"},
52 {map[string]string{"PAGER": "more", "GITBAY_PAGER": "bat -p"}, "bat -p"},
53 {map[string]string{"PAGER": "more", "GITBAY_PAGER": ""}, ""},
54 }
55 for _, c := range cases {
56 if got := strings.Join(pagerArgv(env(c.env)), " "); got != c.want {
57 t.Errorf("%v: got %q want %q", c.env, got, c.want)
58 }
59 }
60}
61
62func TestPages(t *testing.T) {
63 yes := [][]string{{"issue", "show"}, {"mr", "diff"}, {"build", "log"}, {"repo", "log"}}
64 for _, s := range yes {
65 if !pages(s, nil) {
66 t.Errorf("%v should page", s)
67 }
68 }
69 if pages([]string{"build", "log"}, []string{"--follow"}) {
70 t.Error("build log --follow must not page")
71 }
72 if pages([]string{"issue", "show"}, []string{"--json"}) {
73 t.Error("--json must not page")
74 }
75 if pages([]string{"issue", "list"}, nil) {
76 t.Error("list must not page")
77 }
78}