Commit fd7099acea

fd7099acea619c81fc854cd955bedf2fae1f3bdb

parent: 9594a7a725

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 01:22 UTC

gitbay: send GITBAY_TERM at a terminal; --no-color; drop client-side column padding

Ref #254
cmd/gitbay/main.go +1
@@ -17,6 +17,7 @@ import (
1717)
1818
1919func main() {
20 os.Args, noColor = stripNoColor(os.Args)
2021 if err := newRoot().Execute(); err != nil {
2122 fmt.Fprintln(os.Stderr, "gitbay:", err)
2223 os.Exit(protocol.ExitUsage)
cmd/gitbay/ssh.go +43 −41
@@ -1,8 +1,6 @@
11package main
22
33import (
4 "text/tabwriter"
5
64 "encoding/json"
75 "fmt"
86 "golang.org/x/term"
@@ -11,6 +9,7 @@ import (
119 "os/exec"
1210 "path/filepath"
1311 "regexp"
12 "slices"
1413 "strconv"
1514 "strings"
1615
@@ -109,10 +108,52 @@ func sshArgs(inst cliconfig.Instance) []string {
109108 return append(args, inst.SSHOptions...)
110109}
111110
111// noColor is --no-color, stripped from argv in main.
112var noColor bool
113
114// termValue is GITBAY_TERM for this invocation: the terminal's width,
115// and whether colour is wanted. Empty when stdout is not a terminal,
116// so piped output stays the rows stock ssh prints.
117func termValue(isTerminal bool, cols int, env func(string) string) string {
118 if !isTerminal || cols < 40 {
119 return ""
120 }
121 v := strconv.Itoa(cols)
122 if !noColor && env("NO_COLOR") == "" && env("TERM") != "dumb" {
123 v += ",color"
124 }
125 return v
126}
127
128// stripNoColor removes --no-color wherever it appears.
129func stripNoColor(args []string) ([]string, bool) {
130 out := args[:0:0]
131 found := false
132 for _, a := range args {
133 if a == "--no-color" {
134 found = true
135 continue
136 }
137 out = append(out, a)
138 }
139 return out, found
140}
141
112142// runSSH executes the server command over the system ssh binary, wiring
113143// stdio through. It returns the remote exit code.
114144func runSSH(t target, serverArgv []string, stdin io.Reader) int {
115145 args := sshArgs(t.inst)
146
147 fd := int(os.Stdout.Fd())
148 cols := 0
149 isTTY := term.IsTerminal(fd)
150 if isTTY {
151 cols, _, _ = term.GetSize(fd)
152 }
153 if v := termValue(isTTY, cols, os.Getenv); v != "" && !slices.Contains(serverArgv, "--json") {
154 serverArgv = append([]string{"--term=" + v}, serverArgv...)
155 }
156
116157 quoted := make([]string, len(serverArgv))
117158 for i, a := range serverArgv {
118159 quoted[i] = shellQuote(a)
@@ -123,15 +164,7 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int {
123164 cmd.Stdin = stdin
124165 cmd.Stdout = os.Stdout
125166 cmd.Stderr = os.Stderr
126 var tw *tabwriter.Writer
127 if alignColumns(serverArgv) {
128 tw = tabwriter.NewWriter(os.Stdout, 0, 8, 2, ' ', 0)
129 cmd.Stdout = tw
130 }
131167 err := cmd.Run()
132 if tw != nil {
133 tw.Flush()
134 }
135168 if err == nil {
136169 return 0
137170 }
@@ -204,34 +237,3 @@ func withRepo(t target, args []string) ([]string, error) {
204237 }
205238 return append([]string{t.repo}, args...), nil
206239}
207
208// listVerbs are the server commands whose plain output is one row per
209// item with tab-separated columns.
210var listVerbs = map[string]bool{
211 "list": true, "runners": true, "deliveries": true, "refs": true,
212 "revisions": true, "threads": true, "bookmarks": true, "jobs": true,
213 "prune": true,
214}
215
216// alignColumns reports whether a command's rows should be padded into
217// columns: a list command, printed for a person at a terminal. Piped
218// output keeps the server's tabs so cut and awk see the same bytes stock
219// ssh prints, and --json is never touched.
220func alignColumns(serverArgv []string) bool {
221 verb := ""
222 for _, a := range serverArgv {
223 if strings.HasPrefix(a, "-") {
224 break
225 }
226 if a == "--json" {
227 return false
228 }
229 verb = a
230 }
231 for _, a := range serverArgv {
232 if a == "--json" {
233 return false
234 }
235 }
236 return listVerbs[verb] && term.IsTerminal(int(os.Stdout.Fd()))
237}
cmd/gitbay/term_test.go added +37
@@ -0,0 +1,37 @@
1package main
2
3import "testing"
4
5func TestTermValue(t *testing.T) {
6 env := func(m map[string]string) func(string) string {
7 return func(k string) string { return m[k] }
8 }
9 cases := []struct {
10 tty bool
11 cols int
12 env map[string]string
13 noColor bool
14 want string
15 }{
16 {true, 120, nil, false, "120,color"},
17 {false, 120, nil, false, ""},
18 {true, 30, nil, false, ""},
19 {true, 120, map[string]string{"NO_COLOR": "1"}, false, "120"},
20 {true, 120, map[string]string{"TERM": "dumb"}, false, "120"},
21 {true, 120, nil, true, "120"},
22 }
23 for _, c := range cases {
24 noColor = c.noColor
25 if got := termValue(c.tty, c.cols, env(c.env)); got != c.want {
26 t.Errorf("%+v: got %q", c, got)
27 }
28 }
29 noColor = false
30}
31
32func TestStripNoColor(t *testing.T) {
33 args, ok := stripNoColor([]string{"gitbay", "issue", "list", "--no-color", "--state", "all"})
34 if !ok || len(args) != 5 || args[3] != "--state" {
35 t.Errorf("got %v %v", args, ok)
36 }
37}