Commit 4ea42433c4
Verified · cmc ci/build: success ci/test: success
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 | ||
| 3 | import ( | 3 | import ( |
| 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. | ||
| 199 | func (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). | ||
| 233 | func (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. | ||
| 250 | func (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 "-: -------". | ||
| 267 | func 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. | ||
| 274 | var 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. | ||
| 279 | func (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. |
| 196 | func cells(s string) int { | 300 | func cells(s string) int { |
internal/control/term_test.go +62
| @@ -1,6 +1,7 @@ | |||
| 1 | package control | 1 | package control |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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 | |||
| 142 | func 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 | |||
| 170 | func 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 | |||
| 184 | func 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 | |||
| 191 | func 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. | ||
| 177 | func (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. |
| 177 | func (v *view) event(text, format, ts string) { | 194 | func (v *view) event(text, format, ts string) { |