Commit 4ea42433c4

4ea42433c44c9f6e9bb21773bee9f2b87f876ce4

parent: 4837660d33

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

cmc <hello@cleberg.net> · 2026-10-01 20:59 UTC

control: coloured diffs, repo commit view, build log steps

At a terminal, Term.diff colours repo commit, repo diff, mr diff and
mr range-diff and passes them through termSafe. repo commit is a view
with signature state, signer, checks and a link; piped, it prints the
message body once instead of repeating the subject. build log drops
tool SGR codes, makes the rest safe, and marks step lines, the failed
step red.

Closes #313

Layout: unified · split

.gitbay/wiki/Users.org +3
@@ -1022,6 +1022,9 @@ then prints:
1022 =krz/gitbay=, SHAs) in cyan. A marker piped output keeps as its own 1022 =krz/gitbay=, SHAs) in cyan. A marker piped output keeps as its own
1023 trailing cell (=[archived]=, =primary=) joins the state at a terminal: 1023 trailing cell (=[archived]=, =primary=) joins the state at a terminal:
1024 =public, archived=; 1024 =public, archived=;
1025- diffs coloured (file headers bold, hunks cyan, added green, removed
1026 red), and =build log= with its step lines bold, the failed one red;
1027 both made safe like any user text, the log's own colour codes dropped;
1025- ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as 1028- ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as
1026 =53m43s=, where piped output keeps RFC3339, bytes and seconds; 1029 =53m43s=, where piped output keeps RFC3339, bytes and seconds;
1027- =show= views with a title line, aligned fields, the body rendered 1030- =show= views with a title line, aligned fields, the body rendered
CHANGELOG.org +6
@@ -27,6 +27,12 @@ anything beyond "replace the binary and restart" is needed.
27 except that =profile show='s section labels are plural (=links:=, 27 except that =profile show='s section labels are plural (=links:=,
28 =orgs:=, =members:=, =repos:=) and an unknown flag's message names the 28 =orgs:=, =members:=, =repos:=) and an unknown flag's message names the
29 nearest flag. The server ignores =--term= options it does not know. 29 nearest flag. The server ignores =--term= options it does not know.
30- Diffs at a terminal are coloured (=repo commit=, =repo diff=, =mr diff=,
31 =mr range-diff=) and made safe like other user text. =repo commit= is a
32 view with the signature state, signer, checks and a link; piped, it no
33 longer prints the subject twice. =build log= at a terminal drops the
34 tools' own colour codes, makes the rest safe, and marks step lines,
35 the failed step in red. (#313)
30 36
31* v1.40.1 — 2026-09-29 37* v1.40.1 — 2026-09-29
32 38
internal/control/build.go +10 −3
@@ -299,10 +299,14 @@ func runBuildLog(c *Ctx, args []string) int {
299 if err != nil { 299 if err != nil {
300 return c.fail(protocol.ExitFailure, "%v", err) 300 return c.fail(protocol.ExitFailure, "%v", err)
301 } 301 }
302 var steps []string
303 json.Unmarshal([]byte(b.Steps), &steps)
304 sections := SplitBuildLog(string(log), steps)
305 failed := ""
306 if at := FailedSection(sections, b.Status, b.FailedStep); at >= 0 {
307 failed = sections[at].Step
308 }
302 if f.Has("--step") { 309 if f.Has("--step") {
303 var steps []string
304 json.Unmarshal([]byte(b.Steps), &steps)
305 sections := SplitBuildLog(string(log), steps)
306 at := -1 310 at := -1
307 if want := f.Value("--step"); want == "failed" { 311 if want := f.Value("--step"); want == "failed" {
308 if at = FailedSection(sections, b.Status, b.FailedStep); at < 0 { 312 if at = FailedSection(sections, b.Status, b.FailedStep); at < 0 {
@@ -327,6 +331,9 @@ func runBuildLog(c *Ctx, args []string) int {
327 if tail > 0 { 331 if tail > 0 {
328 log = tailLines(log, tail) 332 log = tailLines(log, tail)
329 } 333 }
334 if c.Term.Cols > 0 {
335 log = []byte(c.Term.buildLog(string(log), failed))
336 }
330 c.Stdout.Write(log) 337 c.Stdout.Write(log)
331 return protocol.ExitOK 338 return protocol.ExitOK
332} 339}
internal/control/mr.go +2 −2
@@ -970,7 +970,7 @@ func runMRDiff(c *Ctx, args []string) int {
970 if err != nil { 970 if err != nil {
971 return c.fail(protocol.ExitFailure, "%v", err) 971 return c.fail(protocol.ExitFailure, "%v", err)
972 } 972 }
973 fmt.Fprint(c.Stdout, patch) 973 fmt.Fprint(c.Stdout, c.Term.diff(patch))
974 if truncated { 974 if truncated {
975 fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB; fetch the branch for the rest") 975 fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB; fetch the branch for the rest")
976 } 976 }
@@ -2121,7 +2121,7 @@ func runMRRangeDiff(c *Ctx, args []string) int {
2121 return c.fail(protocol.ExitFailure, 2121 return c.fail(protocol.ExitFailure,
2122 "%v (the objects for an older revision may have been garbage-collected)", err) 2122 "%v (the objects for an older revision may have been garbage-collected)", err)
2123 } 2123 }
2124 fmt.Fprint(c.Stdout, patch) 2124 fmt.Fprint(c.Stdout, c.Term.diff(patch))
2125 if truncated { 2125 if truncated {
2126 fmt.Fprintln(c.Stderr, "range-diff truncated at 4 MiB") 2126 fmt.Fprintln(c.Stderr, "range-diff truncated at 4 MiB")
2127 } 2127 }
internal/control/repo.go +1 −1
@@ -1311,7 +1311,7 @@ func runRepoDiff(c *Ctx, args []string) int {
1311 if c.JSON { 1311 if c.JSON {
1312 return c.emit(map[string]any{"base": base, "head": head, "merge_base": mergeBase, "patch": patch, "truncated": truncated}, nil) 1312 return c.emit(map[string]any{"base": base, "head": head, "merge_base": mergeBase, "patch": patch, "truncated": truncated}, nil)
1313 } 1313 }
1314 fmt.Fprint(c.Stdout, patch) 1314 fmt.Fprint(c.Stdout, c.Term.diff(patch))
1315 if truncated { 1315 if truncated {
1316 fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB") 1316 fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB")
1317 } 1317 }
internal/control/sig.go +35 −5
@@ -324,12 +324,42 @@ func runRepoCommit(c *Ctx, args []string) int {
324 for _, st := range statuses { 324 for _, st := range statuses {
325 d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL}) 325 d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL})
326 } 326 }
327 // Message carries the subject paragraph too; the views print it once.
328 body := ""
329 if _, rest, ok := strings.Cut(d.Message, "\n\n"); ok {
330 body = strings.TrimRight(rest, "\n")
331 }
327 return c.emit(d, func(w io.Writer) { 332 return c.emit(d, func(w io.Writer) {
328 fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate: %s\n\n %s\n", 333 if c.Term.Cols == 0 {
329 d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject) 334 fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate: %s\n\n %s\n",
330 if d.Message != "" { 335 d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject)
331 fmt.Fprintf(w, "\n%s\n", d.Message) 336 if body != "" {
337 fmt.Fprintf(w, "\n%s\n", body)
338 }
339 fmt.Fprintf(w, "\n%s", d.Diff)
340 return
341 }
342 v := c.view(w)
343 v.title(d.SHA[:10], d.Subject, d.Signature.State)
344 signer := d.Signature.Signer
345 if signer != "" && d.Signature.Fingerprint != "" {
346 signer += ", key " + d.Signature.Fingerprint
347 }
348 v.fields(
349 "author", fmt.Sprintf("%s <%s>, %s", d.AuthorName, d.AuthorEmail, c.when(d.Date)),
350 "committer", d.CommitterEmail,
351 "signer", signer,
352 "url", c.siteURL(repo.OwnerName, repo.Name, "commit", d.SHA[:12]),
353 )
354 v.text(body)
355 if len(d.Checks) > 0 {
356 v.section("checks")
357 tb := c.table(w, "CHECK", "STATE")
358 for _, ch := range d.Checks {
359 tb.row(cText(ch.Context), cState(ch.State))
360 }
361 tb.flush()
332 } 362 }
333 fmt.Fprintf(w, "\n%s", d.Diff) 363 fmt.Fprintf(w, "\n%s", c.Term.diff(d.Diff))
334 }) 364 })
335} 365}
internal/control/term.go +104
@@ -2,6 +2,7 @@ package control
2 2
3import ( 3import (
4 "fmt" 4 "fmt"
5 "regexp"
5 "strconv" 6 "strconv"
6 "strings" 7 "strings"
7 "time" 8 "time"
@@ -191,6 +192,109 @@ func wrapUsage(u string, width int, indent string) string {
191 return b.String() 192 return b.String()
192} 193}
193 194
195// diff is a unified diff, a git stat block, or range-diff output as a
196// terminal shows it: made safe, and with colour file headers bold, hunk
197// headers cyan, added lines green and removed lines red. Plain output
198// and a terminal without colour get it unpainted.
199func (t Term) diff(patch string) string {
200 if t.Cols == 0 {
201 return patch
202 }
203 patch = termSafe(patch)
204 if !t.Color {
205 return patch
206 }
207 lines := strings.Split(patch, "\n")
208 inDiff := false
209 for i, l := range lines {
210 switch {
211 case strings.HasPrefix(l, "diff --git "):
212 inDiff = true
213 lines[i] = t.paint(sgrBold, l)
214 case !inDiff:
215 lines[i] = t.paintPreamble(l)
216 case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "+++ "),
217 strings.HasPrefix(l, "index "), strings.HasPrefix(l, "new file mode"),
218 strings.HasPrefix(l, "deleted file mode"), strings.HasPrefix(l, "old mode"),
219 strings.HasPrefix(l, "new mode"), strings.HasPrefix(l, "similarity index"),
220 strings.HasPrefix(l, "rename from"), strings.HasPrefix(l, "rename to"),
221 strings.HasPrefix(l, "Binary files"):
222 lines[i] = t.paint(sgrBold, l)
223 default:
224 lines[i] = t.paintDiffLine(l)
225 }
226 }
227 return strings.Join(lines, "\n")
228}
229
230// paintPreamble colours what comes before the first file in a diff:
231// a stat line's +/- bar, or a range-diff line (a commit pair, bold, or
232// an indented line of the diff between the two patches).
233func (t Term) paintPreamble(l string) string {
234 if rest, ok := strings.CutPrefix(l, " "); ok {
235 return " " + t.paintDiffLine(rest)
236 }
237 if path, bar, ok := strings.Cut(l, " | "); ok && strings.HasPrefix(l, " ") {
238 n := strings.TrimRight(bar, "+-")
239 plus := strings.Count(bar[len(n):], "+")
240 return path + " | " + n + t.paint(sgrGreen, strings.Repeat("+", plus)) +
241 t.paint(sgrRed, bar[len(n)+plus:])
242 }
243 if rangePair(l) {
244 return t.paint(sgrBold, l)
245 }
246 return l
247}
248
249// paintDiffLine colours one line of a hunk by its first byte.
250func (t Term) paintDiffLine(l string) string {
251 switch {
252 case strings.HasPrefix(l, "@@"):
253 if end := strings.Index(l[2:], "@@"); end >= 0 {
254 return t.paint(sgrCyan, l[:end+4]) + l[end+4:]
255 }
256 return t.paint(sgrCyan, l)
257 case strings.HasPrefix(l, "+"):
258 return t.paint(sgrGreen, l)
259 case strings.HasPrefix(l, "-"):
260 return t.paint(sgrRed, l)
261 }
262 return l
263}
264
265// rangePair reports whether l is a range-diff commit pair line:
266// "1: abc1234 = 1: def5678 subject", either side possibly "-: -------".
267func rangePair(l string) bool {
268 f := strings.Fields(l)
269 return len(f) >= 5 && strings.HasSuffix(f[0], ":") && strings.HasSuffix(f[3], ":") &&
270 strings.ContainsAny(f[2], "=!<>") && len(f[2]) == 1
271}
272
273// toolSGR matches the colour sequences build tools print.
274var toolSGR = regexp.MustCompile("\x1b\\[[0-9;]*m")
275
276// buildLog is a build log as a terminal shows it: the tools' own colour
277// dropped and the rest made safe, since a repository's build writes
278// it; step lines ("$ make test") bold, the failed step's in red.
279func (t Term) buildLog(log, failed string) string {
280 log = termSafe(toolSGR.ReplaceAllString(log, ""))
281 if !t.Color {
282 return log
283 }
284 lines := strings.Split(log, "\n")
285 for i, l := range lines {
286 step, ok := strings.CutPrefix(l, "$ ")
287 switch {
288 case !ok:
289 case failed != "" && step == failed:
290 lines[i] = t.paint(sgrBold+sgrRed, l)
291 default:
292 lines[i] = t.paint(sgrBold, l)
293 }
294 }
295 return strings.Join(lines, "\n")
296}
297
194// cells is the width of s in terminal cells: SGR sequences and 298// cells is the width of s in terminal cells: SGR sequences and
195// combining marks take none, East Asian wide and fullwidth runes two. 299// combining marks take none, East Asian wide and fullwidth runes two.
196func cells(s string) int { 300func cells(s string) int {
internal/control/term_test.go +62
@@ -1,6 +1,7 @@
1package control 1package control
2 2
3import ( 3import (
4 "strings"
4 "testing" 5 "testing"
5 "time" 6 "time"
6) 7)
@@ -137,3 +138,64 @@ func TestFailureAtTerminal(t *testing.T) {
137 t.Errorf("bare usage = %q", got) 138 t.Errorf("bare usage = %q", got)
138 } 139 }
139} 140}
141
142func TestDiffPaint(t *testing.T) {
143 patch := " go.mod | 2 +-\n 1 file changed\n\ndiff --git a/go.mod b/go.mod\nindex a..b 100644\n--- a/go.mod\n+++ b/go.mod\n@@ -1,2 +1,2 @@ require (\n same\n-old\n+new\n"
144 if got := (Term{}).diff(patch); got != patch {
145 t.Errorf("plain changed:\n%s", got)
146 }
147 if got := (Term{Cols: 80}).diff(patch); got != patch {
148 t.Errorf("no colour changed:\n%s", got)
149 }
150 got := Term{Cols: 80, Color: true}.diff(patch)
151 for _, want := range []string{
152 " go.mod | 2 " + sgrGreen + "+" + sgrReset + sgrRed + "-" + sgrReset,
153 sgrBold + "diff --git a/go.mod b/go.mod" + sgrReset,
154 sgrBold + "--- a/go.mod" + sgrReset,
155 sgrBold + "+++ b/go.mod" + sgrReset,
156 sgrCyan + "@@ -1,2 +1,2 @@" + sgrReset + " require (",
157 "\n same\n",
158 sgrRed + "-old" + sgrReset,
159 sgrGreen + "+new" + sgrReset,
160 } {
161 if !strings.Contains(got, want) {
162 t.Errorf("missing %q in\n%q", want, got)
163 }
164 }
165 if stripSGR(got) != patch {
166 t.Errorf("colour changed the text:\n%s", stripSGR(got))
167 }
168}
169
170func TestDiffPaintRangeDiff(t *testing.T) {
171 rd := "1: abc1234 ! 1: def5678 subject\n @@ f.go\n -old\n +new\n"
172 got := Term{Cols: 80, Color: true}.diff(rd)
173 for _, want := range []string{
174 sgrBold + "1: abc1234 ! 1: def5678 subject" + sgrReset,
175 " " + sgrRed + "-old" + sgrReset,
176 " " + sgrGreen + "+new" + sgrReset,
177 } {
178 if !strings.Contains(got, want) {
179 t.Errorf("missing %q in\n%q", want, got)
180 }
181 }
182}
183
184func TestDiffIsSafe(t *testing.T) {
185 got := Term{Cols: 80}.diff("+\x1b]52;c;aGk=\x07\n")
186 if strings.ContainsRune(got, 0x1b) {
187 t.Errorf("escape reached the terminal: %q", got)
188 }
189}
190
191func TestBuildLog(t *testing.T) {
192 log := "$ git clone x\n\x1b[32mok\x1b[0m\n$ make test\nFAIL\n"
193 got := Term{Cols: 80, Color: true}.buildLog(log, "make test")
194 want := sgrBold + "$ git clone x" + sgrReset + "\nok\n" + sgrBold + sgrRed + "$ make test" + sgrReset + "\nFAIL\n"
195 if got != want {
196 t.Errorf("buildLog:\n%q\nwant\n%q", got, want)
197 }
198 if got := (Term{Cols: 80}).buildLog("a\x1b]52;c;aGk=\x07b\n", ""); strings.ContainsRune(got, 0x1b) {
199 t.Errorf("escape reached the terminal: %q", got)
200 }
201}
internal/control/view.go +17
@@ -172,6 +172,23 @@ func (v *view) body(src, format string) {
172 } 172 }
173} 173}
174 174
175// text writes src as it is, indented: a commit message, which has no
176// markup to render.
177func (v *view) text(src string) {
178 src = strings.TrimRight(v.c.Term.safe(src), "\n")
179 if strings.TrimSpace(src) == "" {
180 return
181 }
182 v.sep()
183 for _, line := range strings.Split(src, "\n") {
184 if line == "" {
185 io.WriteString(v.w, "\n")
186 continue
187 }
188 io.WriteString(v.w, " "+line+"\n")
189 }
190}
191
175// event is one line for a system comment: its text without link 192// event is one line for a system comment: its text without link
176// targets, the time at the right edge at a terminal. 193// targets, the time at the right edge at a terminal.
177func (v *view) event(text, format, ts string) { 194func (v *view) event(text, format, ts string) {