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 | 1022 | =krz/gitbay=, SHAs) in cyan. A marker piped output keeps as its own |
| 1023 | 1023 | trailing cell (=[archived]=, =primary=) joins the state at a terminal: |
| 1024 | 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 | 1028 | - ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as |
| 1026 | 1029 | =53m43s=, where piped output keeps RFC3339, bytes and seconds; |
| 1027 | 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 | 27 | except that =profile show='s section labels are plural (=links:=, |
| 28 | 28 | =orgs:=, =members:=, =repos:=) and an unknown flag's message names the |
| 29 | 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 | 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 | 299 | if err != nil { |
| 300 | 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 | 309 | if f.Has("--step") { |
| 303 | var steps []string | |
| 304 | json.Unmarshal([]byte(b.Steps), &steps) | |
| 305 | sections := SplitBuildLog(string(log), steps) | |
| 306 | 310 | at := -1 |
| 307 | 311 | if want := f.Value("--step"); want == "failed" { |
| 308 | 312 | if at = FailedSection(sections, b.Status, b.FailedStep); at < 0 { |
| @@ -327,6 +331,9 @@ func runBuildLog(c *Ctx, args []string) int { | ||
| 327 | 331 | if tail > 0 { |
| 328 | 332 | log = tailLines(log, tail) |
| 329 | 333 | } |
| 334 | if c.Term.Cols > 0 { | |
| 335 | log = []byte(c.Term.buildLog(string(log), failed)) | |
| 336 | } | |
| 330 | 337 | c.Stdout.Write(log) |
| 331 | 338 | return protocol.ExitOK |
| 332 | 339 | } |
internal/control/mr.go +2 −2
| @@ -970,7 +970,7 @@ func runMRDiff(c *Ctx, args []string) int { | ||
| 970 | 970 | if err != nil { |
| 971 | 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 | 974 | if truncated { |
| 975 | 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 | 2121 | return c.fail(protocol.ExitFailure, |
| 2122 | 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 | 2125 | if truncated { |
| 2126 | 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 | 1311 | if c.JSON { |
| 1312 | 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 | 1315 | if truncated { |
| 1316 | 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 | 324 | for _, st := range statuses { |
| 325 | 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 | 332 | 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() | |
| 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 | 3 | import ( |
| 4 | 4 | "fmt" |
| 5 | "regexp" | |
| 5 | 6 | "strconv" |
| 6 | 7 | "strings" |
| 7 | 8 | "time" |
| @@ -191,6 +192,109 @@ func wrapUsage(u string, width int, indent string) string { | ||
| 191 | 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 | 298 | // cells is the width of s in terminal cells: SGR sequences and |
| 195 | 299 | // combining marks take none, East Asian wide and fullwidth runes two. |
| 196 | 300 | func cells(s string) int { |
internal/control/term_test.go +62
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "strings" | |
| 4 | 5 | "testing" |
| 5 | 6 | "time" |
| 6 | 7 | ) |
| @@ -137,3 +138,64 @@ func TestFailureAtTerminal(t *testing.T) { | ||
| 137 | 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 | 192 | // event is one line for a system comment: its text without link |
| 176 | 193 | // targets, the time at the right edge at a terminal. |
| 177 | 194 | func (v *view) event(text, format, ts string) { |