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:
10221022 =krz/gitbay=, SHAs) in cyan. A marker piped output keeps as its own
10231023 trailing cell (=[archived]=, =primary=) joins the state at a terminal:
10241024 =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;
10251028- ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as
10261029 =53m43s=, where piped output keeps RFC3339, bytes and seconds;
10271030- =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.
2727 except that =profile show='s section labels are plural (=links:=,
2828 =orgs:=, =members:=, =repos:=) and an unknown flag's message names the
2929 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)
3036
3137* v1.40.1 — 2026-09-29
3238
internal/control/build.go +10 −3
@@ -299,10 +299,14 @@ func runBuildLog(c *Ctx, args []string) int {
299299 if err != nil {
300300 return c.fail(protocol.ExitFailure, "%v", err)
301301 }
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 }
302309 if f.Has("--step") {
303 var steps []string
304 json.Unmarshal([]byte(b.Steps), &steps)
305 sections := SplitBuildLog(string(log), steps)
306310 at := -1
307311 if want := f.Value("--step"); want == "failed" {
308312 if at = FailedSection(sections, b.Status, b.FailedStep); at < 0 {
@@ -327,6 +331,9 @@ func runBuildLog(c *Ctx, args []string) int {
327331 if tail > 0 {
328332 log = tailLines(log, tail)
329333 }
334 if c.Term.Cols > 0 {
335 log = []byte(c.Term.buildLog(string(log), failed))
336 }
330337 c.Stdout.Write(log)
331338 return protocol.ExitOK
332339}
internal/control/mr.go +2 −2
@@ -970,7 +970,7 @@ func runMRDiff(c *Ctx, args []string) int {
970970 if err != nil {
971971 return c.fail(protocol.ExitFailure, "%v", err)
972972 }
973 fmt.Fprint(c.Stdout, patch)
973 fmt.Fprint(c.Stdout, c.Term.diff(patch))
974974 if truncated {
975975 fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB; fetch the branch for the rest")
976976 }
@@ -2121,7 +2121,7 @@ func runMRRangeDiff(c *Ctx, args []string) int {
21212121 return c.fail(protocol.ExitFailure,
21222122 "%v (the objects for an older revision may have been garbage-collected)", err)
21232123 }
2124 fmt.Fprint(c.Stdout, patch)
2124 fmt.Fprint(c.Stdout, c.Term.diff(patch))
21252125 if truncated {
21262126 fmt.Fprintln(c.Stderr, "range-diff truncated at 4 MiB")
21272127 }
internal/control/repo.go +1 −1
@@ -1311,7 +1311,7 @@ func runRepoDiff(c *Ctx, args []string) int {
13111311 if c.JSON {
13121312 return c.emit(map[string]any{"base": base, "head": head, "merge_base": mergeBase, "patch": patch, "truncated": truncated}, nil)
13131313 }
1314 fmt.Fprint(c.Stdout, patch)
1314 fmt.Fprint(c.Stdout, c.Term.diff(patch))
13151315 if truncated {
13161316 fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB")
13171317 }
internal/control/sig.go +35 −5
@@ -324,12 +324,42 @@ func runRepoCommit(c *Ctx, args []string) int {
324324 for _, st := range statuses {
325325 d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL})
326326 }
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 }
327332 return c.emit(d, func(w io.Writer) {
328 fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate: %s\n\n %s\n",
329 d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject)
330 if d.Message != "" {
331 fmt.Fprintf(w, "\n%s\n", d.Message)
333 if c.Term.Cols == 0 {
334 fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate: %s\n\n %s\n",
335 d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject)
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()
332362 }
333 fmt.Fprintf(w, "\n%s", d.Diff)
363 fmt.Fprintf(w, "\n%s", c.Term.diff(d.Diff))
334364 })
335365}
internal/control/term.go +104
@@ -2,6 +2,7 @@ package control
22
33import (
44 "fmt"
5 "regexp"
56 "strconv"
67 "strings"
78 "time"
@@ -191,6 +192,109 @@ func wrapUsage(u string, width int, indent string) string {
191192 return b.String()
192193}
193194
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
194298// cells is the width of s in terminal cells: SGR sequences and
195299// combining marks take none, East Asian wide and fullwidth runes two.
196300func cells(s string) int {
internal/control/term_test.go +62
@@ -1,6 +1,7 @@
11package control
22
33import (
4 "strings"
45 "testing"
56 "time"
67)
@@ -137,3 +138,64 @@ func TestFailureAtTerminal(t *testing.T) {
137138 t.Errorf("bare usage = %q", got)
138139 }
139140}
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) {
172172 }
173173}
174174
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
175192// event is one line for a system comment: its text without link
176193// targets, the time at the right edge at a terminal.
177194func (v *view) event(text, format, ts string) {