control: show screens (CLI views stage 2a) !553

merged merged by cmc on 2026-10-02 03:37 UTC · krz/gitbay:cli-views-2a into main

20 files changed, +880 −109

Layout: unified · split

.gitbay/wiki/Users.org +14 −1
@@ -1034,7 +1034,20 @@ then prints:
10341034 both made safe like any user text, the log's own colour codes dropped;
10351035- ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as
10361036 =53m43s=, where piped output keeps RFC3339, bytes and seconds;
1037- =show= views with a title line, aligned fields, the body rendered
1037- =mr show=, =issue show=, =repo show= and =build show= drawn as
1038 screens: a header block of =Label:= lines (labels dim), the
1039 description, sections titled =Title (n)= in bold blue (=Commits=,
1040 =Files=, =Discussion=, =Events=, =Steps=, open merge requests and
1041 issues), and a legend under a rule of the commands that apply now,
1042 grouped (=Unblock=, =Review=, =Merge=, =Read=, ...) and chosen from
1043 the item's state and your access. Rows have no header and no indent:
1044 a dim reference, a state glyph, the text, then dim facts joined by
1045 =·=. The glyphs are =✓= passed, =✗= failed or blocked, =◐= still
1046 going, =●= waiting on you, =○= closed or draft. A section cut short
1047 ends with =+n more= and the command for the rest. Below 80 columns the
1048 legend's groups stack; a suggested command is never cut, so it may run
1049 past the width;
1050- other =show= views with a title line, aligned fields, the body rendered
10381051 from markdown or org, one line per event, and comments under a rule;
10391052 a sub-table (labels, revisions, and the like) carries a section label
10401053 line in both terminal and piped output; timestamps as
CHANGELOG.org +8
@@ -12,6 +12,14 @@ anything beyond "replace the binary and restart" is needed.
1212 sends the repository it inferred as =here== in =--term=, so suggested
1313 commands leave it out; an older instance ignores the option. Piped
1414 and =--json= output are unchanged.
15- =mr show=, =issue show=, =repo show= and =build show= at a terminal
16 are screens (#318): a header block, the description, counted
17 sections, and a legend of the commands that apply, chosen from the
18 item's state (=mr rebase= when behind, =mr merge= only when the gates
19 pass, =issue close= only for the author or a writer). =mr show= adds
20 the files with their line counts; =repo show= its open merge requests,
21 open issues and latest commits; system events sit in their own
22 =Events= section. Pending checks lead with =◐=.
1523
1624* v1.41.0 — 2026-10-01
1725
internal/control/build.go +42 −21
@@ -244,7 +244,13 @@ func runBuildShow(c *Ctx, args []string) int {
244244 }
245245 d := buildToOut(b)
246246 json.Unmarshal([]byte(b.Steps), &d.Steps)
247 return c.emit(d, func(w io.Writer) {
247 var mr *store.MR
248 if c.Term.Cols > 0 && !c.JSON {
249 if m, ok, err := c.Store.OpenMRBySource(repo.ID, d.Ref); err == nil && ok {
250 mr = &m
251 }
252 }
253 return c.emitView(d, func(w io.Writer) {
248254 failedStep, failed := "", ""
249255 if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) {
250256 step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n")
@@ -259,15 +265,6 @@ func runBuildShow(c *Ctx, args []string) int {
259265 if d.DurationS > 0 {
260266 duration = (time.Duration(d.DurationS) * time.Second).String()
261267 }
262 // At a terminal: the open merge request the build ran for, and
263 // the command that prints its log.
264 mr, logCmd := "", ""
265 if c.Term.Cols > 0 {
266 if m, ok, err := c.Store.OpenMRBySource(repo.ID, d.Ref); err == nil && ok {
267 mr = fmt.Sprintf("!%d %s", m.Number, m.Title)
268 }
269 logCmd = fmt.Sprintf("gitbay build log %s %d", repo.Path(), d.Number)
270 }
271268 v := c.view(w)
272269 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status)
273270 v.fields(
@@ -278,20 +275,44 @@ func runBuildShow(c *Ctx, args []string) int {
278275 "duration", duration,
279276 "failed step", failedStep,
280277 "failed", failed,
281 "mr", mr,
282 "log", logCmd,
283278 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)),
284279 )
285 if c.Term.Cols > 0 && len(d.Steps) > 0 {
286 v.section("steps")
287 tb := c.table(w, "#", "STEP", "STATE")
288 for i, step := range d.Steps {
289 line, _, _ := strings.Cut(step, "\n")
290 tb.row(cNum(int64(i+1)), cFlex(line), cState(stepState(d.Status, d.FailedStep, i+1)))
291 }
292 tb.flush()
280 }, func() screen { return buildShowScreen(c, repo, d, mr) })
281}
282
283// buildShowScreen is build show at a terminal: the build's outcome, what
284// it ran on, each step's outcome, and the command for its log.
285func buildShowScreen(c *Ctx, repo store.Repo, d BuildOut, mr *store.MR) screen {
286 n := strconv.FormatInt(d.Number, 10)
287 path := repo.Path()
288 var s screen
289 s.fields = append(s.fields,
290 field{"Build", []cell{cLink(n, c.siteURL(path, "builds", n)), cText(d.Job)}},
291 field{"State", []cell{cGlyph(d.Status), cState(d.Status)}},
292 field{"Commit", []cell{cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Subject)}},
293 field{"Ref", []cell{cText(d.Ref)}},
294 )
295 if mr != nil {
296 s.fields = append(s.fields, field{"MR", []cell{cRef(fmt.Sprintf("!%d", mr.Number)), cText(mr.Title)}})
297 }
298 if d.DurationS > 0 {
299 s.fields = append(s.fields, field{"Duration", []cell{cText(c.Term.dur(d.DurationS))}})
300 }
301 if !c.Term.Links {
302 s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "builds", n))}})
303 }
304 steps := section{title: "Steps", n: len(d.Steps)}
305 for i, step := range d.Steps {
306 line, _, _ := strings.Cut(step, "\n")
307 meta := ""
308 if i+1 == d.FailedStep {
309 meta = d.FailedReason
293310 }
294 })
311 steps.rows = append(steps.rows, rowOf(cGlyph(stepState(d.Status, d.FailedStep, i+1)), cFlex(line), cMeta(meta)))
312 }
313 s.sections = []section{steps}
314 s.actions = []action{{"Read", []string{"build", "log", path, n}}}
315 return s
295316}
296317
297318// stepState is what a finished build says about one of its steps: those
internal/control/buildscreen_test.go added +69
@@ -0,0 +1,69 @@
1package control
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12func TestBuildShowPlainPinned(t *testing.T) {
13 st, repo, uid := newQueueTestRepo(t)
14 id, err := st.CreateBuild(repo.ID, "test", "a136534ff9a136534ff9a136534ff9a136534ff9", "main", `["go vet ./...","go test ./..."]`, "", "", true)
15 if err != nil {
16 t.Fatal(err)
17 }
18 if _, ok, err := st.ClaimBuild([]int64{repo.ID}, false); err != nil || !ok {
19 t.Fatalf("claim: %v %v", ok, err)
20 }
21 if err := st.SetBuildFailure(id, 2, "exit 1"); err != nil {
22 t.Fatal(err)
23 }
24 if err := st.FinishBuild(id, "failure"); err != nil {
25 t.Fatal(err)
26 }
27 b, err := st.BuildByID(id)
28 if err != nil {
29 t.Fatal(err)
30 }
31 c, out, errOut := mrTestCtx(st, store.User{ID: uid, Username: "alice"})
32 if code := Dispatch(c, []string{"build", "show", repo.Path(), strconv.FormatInt(b.Number, 10)}); code != protocol.ExitOK {
33 t.Fatalf("exit %d: %s", code, errOut)
34 }
35 pinPlain(t, "build-show", out.String())
36}
37
38func TestBuildShowScreenFailed(t *testing.T) {
39 c := screenCtx(100, false)
40 d := BuildOut{Number: 83, Job: "daily-pull", Status: "failure", SHA: "355b04066b", Ref: "main",
41 Steps: []string{"setup", "pull", "publish"}, FailedStep: 2, FailedReason: "exit 1", DurationS: 74}
42 s := buildShowScreen(c, store.Repo{OwnerName: "krz", Name: "omaha-metro-blotter"}, d, nil)
43 steps := s.sections[0]
44 if steps.title != "Steps" || steps.n != 3 {
45 t.Fatalf("steps = %+v", steps)
46 }
47 for i, want := range []string{"✓", "✗", "○"} {
48 if g := steps.rows[i].cells[0]; g.s != want {
49 t.Errorf("step %d glyph = %q, want %q", i+1, g.s, want)
50 }
51 }
52 if len(s.actions) != 1 || s.actions[0].argv[1] != "log" {
53 t.Errorf("actions = %+v", s.actions)
54 }
55 out := renderString(c, s)
56 if !strings.Contains(out, "✗ pull exit 1\n") {
57 t.Errorf("failed step line:\n%s", out)
58 }
59 checkActions(t, s)
60}
61
62func TestBuildShowScreenNamesItsMR(t *testing.T) {
63 c := screenCtx(100, false)
64 d := BuildOut{Number: 1781, Job: "test", Status: "running", SHA: "ee77220399", Ref: "cli-views-1"}
65 out := renderString(c, buildShowScreen(c, store.Repo{OwnerName: "krz", Name: "gitbay"}, d, &store.MR{Number: 552, Title: "screen renderer"}))
66 if !strings.Contains(out, "MR: !552 screen renderer\n") || !strings.Contains(out, "State: ◐ running\n") {
67 t.Errorf("render:\n%s", out)
68 }
69}
internal/control/issue.go +54 −2
@@ -4,6 +4,7 @@ import (
44 "errors"
55 "fmt"
66 "io"
7 "slices"
78 "strconv"
89 "strings"
910
@@ -399,7 +400,13 @@ func runIssueShow(c *Ctx, args []string) int {
399400 cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])})
400401 }
401402 d := IssueShow{issueOut: issueToOut(issue, true), Reactions: reactionsOut(rx[0]), Comments: cs}
402 return c.emit(d, func(w io.Writer) {
403 canWrite := false
404 if c.Term.Cols > 0 && !c.JSON {
405 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil {
406 canWrite = policy.CanWrite(c.User, repo, grant)
407 }
408 }
409 return c.emitView(d, func(w io.Writer) {
403410 v := c.view(w)
404411 v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State)
405412 v.fields(
@@ -429,7 +436,7 @@ func runIssueShow(c *Ctx, args []string) int {
429436 v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
430437 v.reactions(cm.Reactions)
431438 }
432 })
439 }, func() screen { return issueShowScreen(c, repo, d, canWrite) })
433440}
434441
435442func runIssueComment(c *Ctx, args []string) int {
@@ -717,3 +724,48 @@ func assignIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []sto
717724 }
718725 return updated.Assignees, -1
719726}
727
728// issueShowScreen is issue show at a terminal. canWrite is write access,
729// which assigning and labelling need; the author may also close.
730func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen {
731 n := strconv.FormatInt(d.Number, 10)
732 path := repo.Path()
733 s := screen{body: d.Body, format: d.BodyFormat}
734 s.fields = append(s.fields,
735 field{"Issue", []cell{cLink("#"+n, c.siteURL(path, "issues", n)), cText(d.Title)}},
736 field{"State", []cell{cState(d.State), cMeta(d.Author, relAge(d.CreatedAt, termNow()))}},
737 )
738 if len(d.Labels) > 0 {
739 s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}})
740 }
741 if len(d.Assignees) > 0 {
742 v := cText(strings.Join(d.Assignees, ", "))
743 if slices.Contains(d.Assignees, c.User.Username) {
744 v.sgr = sgrYellow
745 }
746 s.fields = append(s.fields, field{"Assignee", []cell{v}})
747 }
748 if d.Milestone != "" {
749 s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}})
750 }
751 if !c.Term.Links {
752 s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "issues", n))}})
753 }
754 s.sections = []section{discussion(d.Comments), events(d.Comments)}
755
756 s.actions = append(s.actions, action{"Discuss", []string{"issue", "comment", path, n}})
757 if canWrite {
758 s.actions = append(s.actions,
759 action{"Triage", []string{"issue", "assign", path, n, "--add", c.User.Username}},
760 action{"Triage", []string{"issue", "label", path, n, "--add", "<label>"}},
761 )
762 }
763 if canWrite || d.Author == c.User.Username {
764 verb := "close"
765 if d.State == "closed" {
766 verb = "reopen"
767 }
768 s.actions = append(s.actions, action{"State", []string{"issue", verb, path, n}})
769 }
770 return s
771}
internal/control/issuescreen_test.go added +77
@@ -0,0 +1,77 @@
1package control
2
3import (
4 "slices"
5 "testing"
6
7 "gitbay.org/gitbay/internal/protocol"
8 "gitbay.org/gitbay/internal/store"
9)
10
11func TestIssueShowPlainPinned(t *testing.T) {
12 st, repo, uid := newQueueTestRepo(t)
13 owner := store.User{ID: uid, Username: "alice"}
14 id, err := st.CreateIssue(repo.ID, uid, "Android app", "An app.", "md")
15 if err != nil {
16 t.Fatal(err)
17 }
18 if err := st.AddIssueComment(id, uid, "Started.", "md"); err != nil {
19 t.Fatal(err)
20 }
21 c, out, errOut := mrTestCtx(st, owner)
22 if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK {
23 t.Fatalf("exit %d: %s", code, errOut)
24 }
25 pinPlain(t, "issue-show", out.String())
26}
27
28func issueFixture() IssueShow {
29 return IssueShow{issueOut: issueOut{Number: 12, Title: "Android app", State: "open", Author: "cmc",
30 Labels: []string{"mobile"}, Assignees: []string{"alice"}, Body: "An app.", BodyFormat: "md",
31 CreatedAt: "2026-08-21T10:00:00Z"},
32 Comments: []commentOut{
33 {ID: 1, Author: "cmc", Body: "Started.", BodyFormat: "md", CreatedAt: "2026-09-01T10:00:00Z", Kind: "comment"},
34 {ID: 2, Author: "cmc", Body: "labelled mobile", CreatedAt: "2026-09-01T10:00:00Z", Kind: "system"},
35 }}
36}
37
38func actionVerbs(s screen) []string {
39 var v []string
40 for _, a := range s.actions {
41 v = append(v, a.argv[1])
42 }
43 return v
44}
45
46func TestIssueShowScreenWriter(t *testing.T) {
47 c := screenCtx(100, false)
48 c.User = store.User{Username: "alice"}
49 s := issueShowScreen(c, screenRepo, issueFixture(), true)
50 if n := sectionCounts(s); n["Discussion"] != 1 || n["Events"] != 1 {
51 t.Errorf("sections = %v", n)
52 }
53 if got, want := actionVerbs(s), []string{"comment", "assign", "label", "close"}; !slices.Equal(got, want) {
54 t.Errorf("actions = %q, want %q", got, want)
55 }
56 checkActions(t, s)
57}
58
59func TestIssueShowScreenAuthorCanClose(t *testing.T) {
60 c := screenCtx(100, false)
61 c.User = store.User{Username: "cmc"}
62 s := issueShowScreen(c, screenRepo, issueFixture(), false)
63 if got, want := actionVerbs(s), []string{"comment", "close"}; !slices.Equal(got, want) {
64 t.Errorf("actions = %q, want %q", got, want)
65 }
66}
67
68func TestIssueShowScreenReader(t *testing.T) {
69 c := screenCtx(100, false)
70 c.User = store.User{Username: "bob"}
71 d := issueFixture()
72 d.State = "closed"
73 s := issueShowScreen(c, screenRepo, d, false)
74 if got, want := actionVerbs(s), []string{"comment"}; !slices.Equal(got, want) {
75 t.Errorf("actions = %q, want %q", got, want)
76 }
77}
internal/control/marks.go +2 −2
@@ -9,7 +9,7 @@ import (
99)
1010
1111// checksMark sums a commit's statuses for a list row at a terminal:
12// "✗ 2 failed" red, "• 1 pending" dim, "✓ 3/3" green, blank with none.
12// "✗ 2 failed" red, "◐ 1 pending" dim, "✓ 3/3" green, blank with none.
1313// The glyph leads so the column reads at a glance; the words stay for
1414// a terminal without colour.
1515func checksMark(sts []store.CommitStatus) cell {
@@ -30,7 +30,7 @@ func checksMark(sts []store.CommitStatus) cell {
3030 case failed > 0:
3131 return cMark(fmt.Sprintf("✗ %d failed", failed), sgrRed)
3232 case pending > 0:
33 return cMark(fmt.Sprintf("• %d pending", pending), sgrDim)
33 return cMark(fmt.Sprintf("◐ %d pending", pending), sgrDim)
3434 }
3535 return cMark(fmt.Sprintf("✓ %d/%d", passed, len(sts)), sgrGreen)
3636}
internal/control/marks_test.go +1 −1
@@ -20,7 +20,7 @@ func TestChecksMark(t *testing.T) {
2020 }{
2121 {nil, "", ""},
2222 {st("success", "success", "skipped"), "✓ 3/3", sgrGreen},
23 {st("success", "pending"), "• 1 pending", sgrDim},
23 {st("success", "pending"), "◐ 1 pending", sgrDim},
2424 {st("success", "failure", "error", "pending"), "✗ 2 failed", sgrRed},
2525 }
2626 for _, tc := range cases {
internal/control/mr.go +94 −2
@@ -816,7 +816,11 @@ func runMRShow(c *Ctx, args []string) int {
816816 }
817817 }
818818 }
819 return c.emit(d, func(w io.Writer) {
819 var files []gitutil.NumStat
820 if c.Term.Cols > 0 && !c.JSON && base != "" {
821 files, _ = gitutil.DiffNumstat(dir, base, mrHeadRef(mr.Number))
822 }
823 return c.emitView(d, func(w io.Writer) {
820824 state := d.State
821825 if d.Draft {
822826 state = "draft"
@@ -967,7 +971,7 @@ func runMRShow(c *Ctx, args []string) int {
967971 v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
968972 v.reactions(cm.Reactions)
969973 }
970 })
974 }, func() screen { return mrShowScreen(c, repo, d, files) })
971975}
972976
973977// reviewLine renders one review as fields prose: "reviewer verdict
@@ -2196,3 +2200,91 @@ func ReviewersWhoCount(st *store.Store, repo store.Repo, reviews []store.MRRevie
21962200 }
21972201 return counts
21982202}
2203
2204// mrShowScreen is mr show at a terminal: where the merge request stands,
2205// its description, its commits, files and discussion, and what can be
2206// done about it now.
2207func mrShowScreen(c *Ctx, repo store.Repo, d MRShow, files []gitutil.NumStat) screen {
2208 n := strconv.FormatInt(d.Number, 10)
2209 path := repo.Path()
2210 state := d.State
2211 if d.Draft {
2212 state = "draft"
2213 }
2214 s := screen{body: d.Body, format: d.BodyFormat}
2215 s.fields = append(s.fields,
2216 field{"Merge", []cell{cLink("!"+n, c.siteURL(path, "mrs", n)), cText(d.Title)}},
2217 field{"State", []cell{cState(state), cMeta(d.Source+" → "+d.TargetRef, d.Author, relAge(d.CreatedAt, termNow()))}},
2218 )
2219 if len(d.Checks) > 0 {
2220 sts := make([]store.CommitStatus, len(d.Checks))
2221 for i, x := range d.Checks {
2222 sts[i] = store.CommitStatus{Context: x.Context, State: x.State}
2223 }
2224 s.fields = append(s.fields, field{"Checks", []cell{checksMark(sts)}})
2225 }
2226 open := d.State == "open" || d.State == "source_gone"
2227 behind := false
2228 if g := d.Gates; g != nil && open {
2229 behind = !g.FastForward
2230 if len(g.Unmet) == 0 {
2231 s.fields = append(s.fields, field{"Gates", []cell{cMark("✓ ready to merge", sgrGreen)}})
2232 } else {
2233 parts := make([]string, len(g.Unmet))
2234 for i, u := range g.Unmet {
2235 parts[i] = "✗ " + u
2236 }
2237 s.fields = append(s.fields, field{"Gates", []cell{cMark(strings.Join(parts, " · "), sgrRed)}})
2238 }
2239 }
2240 if len(d.ReviewRequests) > 0 {
2241 v := cText("requested of " + strings.Join(d.ReviewRequests, ", "))
2242 if slices.Contains(d.ReviewRequests, c.User.Username) {
2243 v.sgr = sgrYellow
2244 }
2245 s.fields = append(s.fields, field{"Review", []cell{v}})
2246 }
2247 if d.Milestone != "" {
2248 s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}})
2249 }
2250 if len(d.Labels) > 0 {
2251 s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}})
2252 }
2253 if !c.Term.Links {
2254 s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "mrs", n))}})
2255 }
2256
2257 commits := section{title: "Commits", n: len(d.Commits)}
2258 for _, cm := range d.Commits {
2259 commits.rows = append(commits.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject)))
2260 }
2261 fs := section{title: "Files", n: len(files)}
2262 add, del := 0, 0
2263 for _, f := range files {
2264 counts := "binary"
2265 if f.Added >= 0 {
2266 add, del = add+f.Added, del+f.Deleted
2267 counts = fmt.Sprintf("+%d −%d", f.Added, f.Deleted)
2268 }
2269 fs.rows = append(fs.rows, rowOf(cRef(f.Status), cFlex(f.Path), cMeta(counts)))
2270 }
2271 if len(files) > 0 {
2272 fs.note = fmt.Sprintf("+%d −%d", add, del)
2273 }
2274 s.sections = []section{commits, fs, discussion(d.Comments), events(d.Comments)}
2275
2276 if open {
2277 if behind {
2278 s.actions = append(s.actions, action{"Unblock", []string{"mr", "rebase", n}})
2279 }
2280 s.actions = append(s.actions,
2281 action{"Review", []string{"mr", "review", path, n, "--approve"}},
2282 action{"Review", []string{"mr", "comment", path, n}},
2283 )
2284 if d.Gates != nil && len(d.Gates.Unmet) == 0 {
2285 s.actions = append(s.actions, action{"Merge", []string{"mr", "merge", path, n}})
2286 }
2287 }
2288 s.actions = append(s.actions, action{"Read", []string{"mr", "diff", path, n}})
2289 return s
2290}
internal/control/mrscreen_test.go added +132
@@ -0,0 +1,132 @@
1package control
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func TestMRShowPlainPinned(t *testing.T) {
14 st, repo, owner := twoMRTestRepo(t)
15 mr, err := st.MRByNumber(repo.ID, 1)
16 if err != nil {
17 t.Fatal(err)
18 }
19 if err := st.AddMRComment(mr.ID, owner.ID, "Looks right.", "md"); err != nil {
20 t.Fatal(err)
21 }
22 for _, ctx := range []string{"test", "build"} {
23 if err := st.SetCommitStatus(repo.ID, "abc111", ctx, "success", "", "", owner.ID); err != nil {
24 t.Fatal(err)
25 }
26 }
27 c, out, errOut := mrTestCtx(st, owner)
28 if code := Dispatch(c, []string{"mr", "show", repo.Path(), "1"}); code != protocol.ExitOK {
29 t.Fatalf("exit %d: %s", code, errOut)
30 }
31 pinPlain(t, "mr-show", out.String())
32}
33
34func mrShowFixture() MRShow {
35 d := MRShow{mrOut: mrOut{Number: 552, Title: "wire $PAGER through long views", State: "open",
36 Author: "cmc", Source: "cli-pager", TargetRef: "main", Body: "Pages long views.", BodyFormat: "md",
37 CreatedAt: "2026-10-01T11:40:00Z"}}
38 d.Commits = []CommitOut{{"8f3a1c2aaaaaaaa", "cli: page long output"}, {"2b77e90bbbbbbbb", "control: mark views"}}
39 d.Checks = []CheckOut{{Context: "test", State: "pending"}, {Context: "build", State: "success"}}
40 d.Gates = &GatesOut{ApprovalsRequired: 1, FastForward: false, Unmet: []string{"needs 1 approval", "behind main"}}
41 d.Comments = []commentOut{
42 {ID: 1, Author: "alice", Body: "Looks right.", BodyFormat: "md", CreatedAt: "2026-10-01T11:50:00Z", Kind: "comment"},
43 {ID: 2, Author: "cmc", Body: "marked ready", CreatedAt: "2026-10-01T11:51:00Z", Kind: "system"},
44 }
45 return d
46}
47
48func actionArgvs(s screen, group string) [][]string {
49 var out [][]string
50 for _, a := range s.actions {
51 if a.group == group {
52 out = append(out, a.argv)
53 }
54 }
55 return out
56}
57
58func sectionCounts(s screen) map[string]int {
59 m := map[string]int{}
60 for _, sec := range s.sections {
61 m[sec.title] = sec.n
62 }
63 return m
64}
65
66var screenRepo = store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"}
67
68func TestMRShowScreenBehind(t *testing.T) {
69 c := screenCtx(100, false)
70 s := mrShowScreen(c, screenRepo, mrShowFixture(), []gitutil.NumStat{{Path: "cmd/gitbay/ssh.go", Added: 41, Deleted: 6, Status: "M"}})
71 if got := actionArgvs(s, "Unblock"); len(got) != 1 || got[0][1] != "rebase" {
72 t.Errorf("Unblock = %q, want mr rebase", got)
73 }
74 if got := actionArgvs(s, "Merge"); len(got) != 0 {
75 t.Errorf("Merge offered with unmet gates: %q", got)
76 }
77 if n := sectionCounts(s); n["Commits"] != 2 || n["Files"] != 1 || n["Discussion"] != 1 {
78 t.Errorf("sections = %v", n)
79 }
80 checkActions(t, s)
81}
82
83func TestMRShowScreenReady(t *testing.T) {
84 c := screenCtx(100, false)
85 d := mrShowFixture()
86 d.Gates = &GatesOut{FastForward: true}
87 s := mrShowScreen(c, screenRepo, d, nil)
88 if len(actionArgvs(s, "Unblock")) != 0 {
89 t.Error("Unblock offered when nothing blocks")
90 }
91 if got := actionArgvs(s, "Merge"); len(got) != 1 || got[0][1] != "merge" {
92 t.Errorf("Merge = %q", got)
93 }
94 checkActions(t, s)
95}
96
97func TestMRShowScreenMergedOffersOnlyReading(t *testing.T) {
98 c := screenCtx(100, false)
99 d := mrShowFixture()
100 d.State, d.Gates = "merged", nil
101 s := mrShowScreen(c, screenRepo, d, nil)
102 for _, a := range s.actions {
103 if a.group != "Read" {
104 t.Errorf("merged MR offers %q", a.argv)
105 }
106 }
107}
108
109func TestMRShowScreenRenders(t *testing.T) {
110 termNow = func() time.Time { return time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) }
111 t.Cleanup(func() { termNow = time.Now })
112 c := screenCtx(100, false)
113 c.Term.Here = "krz/gitbay"
114 s := mrShowScreen(c, screenRepo, mrShowFixture(), []gitutil.NumStat{{Path: "cmd/gitbay/ssh.go", Added: 41, Deleted: 6, Status: "M"}})
115 out := renderString(c, s)
116 for _, want := range []string{
117 "Merge: !552 wire $PAGER through long views\n",
118 "Gates: ✗ needs 1 approval · ✗ behind main\n",
119 "Pages long views.\n",
120 "Files (1) +41 −6\n",
121 "alice 10m ago\n Looks right.\n",
122 "gitbay mr rebase 552",
123 "gitbay mr diff 552",
124 } {
125 if !strings.Contains(out, want) {
126 t.Errorf("missing %q in:\n%s", want, out)
127 }
128 }
129 if !strings.Contains(out, "Events (1)\n9m ago marked ready\n") {
130 t.Errorf("system event not under Events:\n%s", out)
131 }
132}
internal/control/repo.go +118 −69
@@ -373,6 +373,36 @@ func runRepoList(c *Ctx, args []string) int {
373373 })
374374}
375375
376// repoMirrorOut is one mirror as repo show emits it, for admins.
377type repoMirrorOut struct {
378 Direction string `json:"direction"`
379 URL string `json:"url"`
380 Pending bool `json:"pending"`
381 LastSync string `json:"last_sync,omitempty"`
382 LastError string `json:"last_error,omitempty"`
383}
384
385// repoShowOut is what repo show emits.
386type repoShowOut struct {
387 Path string `json:"path"`
388 Description string `json:"description,omitempty"`
389 Website string `json:"website,omitempty"`
390 Visibility string `json:"visibility"`
391 DefaultBranch string `json:"default_branch"`
392 ProtectedBranches []string `json:"protected_branches,omitempty"`
393 Archived bool `json:"archived,omitempty"`
394 Topics []string `json:"topics,omitempty"`
395 Domains []string `json:"domains,omitempty"`
396 Mirrors []repoMirrorOut `json:"mirrors,omitempty"`
397 // ForkOf names the parent only when the caller can read it: a
398 // private parent is not confirmed to exist, here as anywhere.
399 ForkOf string `json:"fork_of,omitempty"`
400 // Watch and Bookmarked are the caller's own state, so a client
401 // can draw a toggle rather than two stateless buttons (#178).
402 Watch string `json:"watch,omitempty"` // watching, muted, or absent
403 Bookmarked bool `json:"bookmarked,omitempty"`
404}
405
376406func runRepoShow(c *Ctx, args []string) int {
377407 if len(args) != 1 {
378408 return c.usage()
@@ -381,32 +411,6 @@ func runRepoShow(c *Ctx, args []string) int {
381411 if code >= 0 {
382412 return code
383413 }
384 type mirrorOut struct {
385 Direction string `json:"direction"`
386 URL string `json:"url"`
387 Pending bool `json:"pending"`
388 LastSync string `json:"last_sync,omitempty"`
389 LastError string `json:"last_error,omitempty"`
390 }
391 type out struct {
392 Path string `json:"path"`
393 Description string `json:"description,omitempty"`
394 Website string `json:"website,omitempty"`
395 Visibility string `json:"visibility"`
396 DefaultBranch string `json:"default_branch"`
397 ProtectedBranches []string `json:"protected_branches,omitempty"`
398 Archived bool `json:"archived,omitempty"`
399 Topics []string `json:"topics,omitempty"`
400 Domains []string `json:"domains,omitempty"`
401 Mirrors []mirrorOut `json:"mirrors,omitempty"`
402 // ForkOf names the parent only when the caller can read it: a
403 // private parent is not confirmed to exist, here as anywhere.
404 ForkOf string `json:"fork_of,omitempty"`
405 // Watch and Bookmarked are the caller's own state, so a client
406 // can draw a toggle rather than two stateless buttons (#178).
407 Watch string `json:"watch,omitempty"` // watching, muted, or absent
408 Bookmarked bool `json:"bookmarked,omitempty"`
409 }
410414 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
411415 topics, err := c.Store.ListTopics(repo.ID)
412416 if err != nil {
@@ -420,7 +424,7 @@ func runRepoShow(c *Ctx, args []string) int {
420424 }
421425 }
422426 }
423 d := out{Path: repo.Path(), Description: desc, Website: repo.Settings.Website, Visibility: repo.Visibility,
427 d := repoShowOut{Path: repo.Path(), Description: desc, Website: repo.Settings.Website, Visibility: repo.Visibility,
424428 DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches,
425429 Archived: repo.Settings.Archived, Topics: topics, Domains: domains}
426430 if repo.ForkOf != 0 {
@@ -442,14 +446,26 @@ func runRepoShow(c *Ctx, args []string) int {
442446 return c.fail(protocol.ExitFailure, "%v", err)
443447 }
444448 for _, m := range ms {
445 d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
449 d.Mirrors = append(d.Mirrors, repoMirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
446450 }
447451 }
448452 var glance repoGlance
453 var mrs []store.MR
454 var issues []store.Issue
455 var commits []CommitOut
449456 if c.Term.Cols > 0 && !c.JSON {
450457 glance = repoAtAGlance(c, repo)
458 mrs, _ = c.Store.ListMRs(repo.ID, "open", 5, 0)
459 issues, _ = c.Store.QueryIssues(repo.ID, store.IssueFilter{State: "open", Limit: 5})
460 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
461 if shas, err := gitutil.RevList(dir, "refs/heads/"+repo.DefaultBranch, 5); err == nil {
462 subjects := gitutil.Subjects(dir, shas)
463 for _, sha := range shas {
464 commits = append(commits, CommitOut{sha, subjects[sha]})
465 }
466 }
451467 }
452 return c.emit(d, func(w io.Writer) {
468 return c.emitView(d, func(w io.Writer) {
453469 bookmarked, archived := "", ""
454470 if d.Bookmarked {
455471 bookmarked = "yes"
@@ -458,41 +474,19 @@ func runRepoShow(c *Ctx, args []string) int {
458474 archived = "yes"
459475 }
460476 v := c.view(w)
461 if c.Term.Cols > 0 {
462 v.title(d.Path, "", d.Visibility)
463 v.text(d.Description)
464 v.fields(
465 "clone", glance.clone,
466 "issues", glance.issues,
467 "merge requests", glance.mrs,
468 "release", glance.release,
469 "checks", glance.checks,
470 "default branch", d.DefaultBranch,
471 "website", d.Website,
472 "topics", strings.Join(d.Topics, ", "),
473 "protected", strings.Join(d.ProtectedBranches, ", "),
474 "pages domains", strings.Join(d.Domains, ", "),
475 "fork of", d.ForkOf,
476 "watch", d.Watch,
477 "bookmarked", bookmarked,
478 "archived", archived,
479 "url", c.siteURL(d.Path),
480 )
481 } else {
482 v.title(d.Path, d.Description, d.Visibility)
483 v.fields(
484 "default branch", d.DefaultBranch,
485 "website", d.Website,
486 "topics", strings.Join(d.Topics, ", "),
487 "protected", strings.Join(d.ProtectedBranches, ", "),
488 "pages domains", strings.Join(d.Domains, ", "),
489 "fork of", d.ForkOf,
490 "watch", d.Watch,
491 "bookmarked", bookmarked,
492 "archived", archived,
493 "url", c.siteURL(d.Path),
494 )
495 }
477 v.title(d.Path, d.Description, d.Visibility)
478 v.fields(
479 "default branch", d.DefaultBranch,
480 "website", d.Website,
481 "topics", strings.Join(d.Topics, ", "),
482 "protected", strings.Join(d.ProtectedBranches, ", "),
483 "pages domains", strings.Join(d.Domains, ", "),
484 "fork of", d.ForkOf,
485 "watch", d.Watch,
486 "bookmarked", bookmarked,
487 "archived", archived,
488 "url", c.siteURL(d.Path),
489 )
496490 if len(d.Mirrors) > 0 {
497491 v.section("mirror")
498492 tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS")
@@ -508,13 +502,14 @@ func runRepoShow(c *Ctx, args []string) int {
508502 }
509503 tb.flush()
510504 }
511 })
505 }, func() screen { return repoShowScreen(c, d, glance, mrs, issues, commits) })
512506}
513507
514508// repoGlance is what repo show adds at a terminal: how to clone it and
515509// what is going on in it.
516510type repoGlance struct {
517 clone, issues, mrs, release, checks string
511 clone, release, checks string
512 issuesN, mrsN int
518513}
519514
520515// repoAtAGlance reads the glance fields. Each is left blank when it
@@ -525,9 +520,7 @@ func repoAtAGlance(c *Ctx, repo store.Repo) repoGlance {
525520 host += ":" + strconv.Itoa(c.Cfg.SSH.Port)
526521 }
527522 g := repoGlance{clone: "ssh://git@" + host + "/" + repo.Path() + ".git"}
528 issues, mrs := c.Store.OpenCounts(repo.ID)
529 g.issues = fmt.Sprintf("%d open", issues)
530 g.mrs = fmt.Sprintf("%d open", mrs)
523 g.issuesN, g.mrsN = c.Store.OpenCounts(repo.ID)
531524 if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 {
532525 g.release = rs[0].Tag + ", " + relAge(rs[0].CreatedAt, termNow())
533526 }
@@ -1374,3 +1367,59 @@ func runRepoDiff(c *Ctx, args []string) int {
13741367 }
13751368 return protocol.ExitOK
13761369}
1370
1371// repoShowScreen is repo show at a terminal: how to clone it, where the
1372// default branch stands, what is open, and the latest commits.
1373func repoShowScreen(c *Ctx, d repoShowOut, g repoGlance, mrs []store.MR, issues []store.Issue, commits []CommitOut) screen {
1374 s := screen{body: d.Description, format: "text"}
1375 name := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility)}
1376 if d.Archived {
1377 name[1].s += ", archived"
1378 }
1379 s.fields = append(s.fields, field{"Repo", name})
1380 if g.clone != "" {
1381 s.fields = append(s.fields, field{"Clone", []cell{cText(g.clone)}})
1382 }
1383 head := []cell{cText(d.DefaultBranch)}
1384 if g.checks != "" {
1385 head = []cell{cText(d.DefaultBranch), cText(strings.TrimSuffix(g.checks, " on "+d.DefaultBranch))}
1386 }
1387 s.fields = append(s.fields, field{"Head", head})
1388 if g.release != "" {
1389 s.fields = append(s.fields, field{"Release", []cell{cText(g.release)}})
1390 }
1391 if len(d.Topics) > 0 {
1392 s.fields = append(s.fields, field{"Topics", []cell{cText(strings.Join(d.Topics, ", "))}})
1393 }
1394 if d.ForkOf != "" {
1395 s.fields = append(s.fields, field{"Fork of", []cell{cRef(d.ForkOf)}})
1396 }
1397 for _, m := range d.Mirrors {
1398 if m.LastError != "" {
1399 s.fields = append(s.fields, field{"Mirror", []cell{cGlyph("failed"), cText(m.Direction + " " + m.URL + ": " + m.LastError)}})
1400 }
1401 }
1402 if !c.Term.Links {
1403 s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(d.Path))}})
1404 }
1405
1406 ms := section{title: "Open merge requests", n: g.mrsN, more: []string{"mr", "list", d.Path}}
1407 for _, m := range mrs {
1408 ms.rows = append(ms.rows, rowOf(cRef(fmt.Sprintf("!%d", m.Number)), cFlex(m.Title), cAge(m.UpdatedAt)))
1409 }
1410 is := section{title: "Open issues", n: g.issuesN, more: []string{"issue", "list", d.Path}}
1411 for _, i := range issues {
1412 is.rows = append(is.rows, rowOf(cRef(fmt.Sprintf("#%d", i.Number)), cFlex(i.Title), cAge(i.UpdatedAt)))
1413 }
1414 cs := section{title: "Recent commits", n: len(commits), more: []string{"repo", "log", d.Path}}
1415 for _, cm := range commits {
1416 cs.rows = append(cs.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject)))
1417 }
1418 s.sections = []section{ms, is, cs}
1419 s.actions = []action{
1420 {"Contribute", []string{"mr", "create", d.Path}},
1421 {"Contribute", []string{"issue", "create", d.Path}},
1422 {"Read", []string{"repo", "log", d.Path}},
1423 }
1424 return s
1425}
internal/control/reposcreen_test.go added +54
@@ -0,0 +1,54 @@
1package control
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/protocol"
8 "gitbay.org/gitbay/internal/store"
9)
10
11func TestRepoShowPlainPinned(t *testing.T) {
12 st, repo, uid := newQueueTestRepo(t)
13 c, out, errOut := mrTestCtx(st, store.User{ID: uid, Username: "alice"})
14 if code := Dispatch(c, []string{"repo", "show", repo.Path()}); code != protocol.ExitOK {
15 t.Fatalf("exit %d: %s", code, errOut)
16 }
17 pinPlain(t, "repo-show", out.String())
18}
19
20func TestRepoShowScreen(t *testing.T) {
21 c := screenCtx(100, false)
22 d := repoShowOut{Path: "krz/gitbay", Description: "A CLI-first git forge.", Visibility: "public", DefaultBranch: "main",
23 Topics: []string{"cli", "forge"}}
24 g := repoGlance{clone: "ssh://git@gitbay.org/krz/gitbay.git", release: "v1.41.0, 2h ago", checks: "✓ 2/2 on main", mrsN: 1, issuesN: 3}
25 mrs := []store.MR{{Number: 552, Title: "wire $PAGER", UpdatedAt: "2026-10-01T11:40:00Z"}}
26 issues := []store.Issue{{Number: 12, Title: "Android app", UpdatedAt: "2026-08-21T10:00:00Z"}}
27 s := repoShowScreen(c, d, g, mrs, issues, []CommitOut{{"a136534ff9", "changelog: v1.41.0"}})
28 if n := sectionCounts(s); n["Open merge requests"] != 1 || n["Open issues"] != 3 || n["Recent commits"] != 1 {
29 t.Errorf("sections = %v", n)
30 }
31 out := renderString(c, s)
32 for _, want := range []string{
33 "Head: main ✓ 2/2\n",
34 "A CLI-first git forge.\n",
35 "+2 more gitbay issue list krz/gitbay\n",
36 } {
37 if !strings.Contains(out, want) {
38 t.Errorf("missing %q in:\n%s", want, out)
39 }
40 }
41 checkActions(t, s)
42}
43
44func TestRepoShowScreenMirrorErrorOnly(t *testing.T) {
45 c := screenCtx(100, false)
46 d := repoShowOut{Path: "krz/tap", Visibility: "public", DefaultBranch: "main", Mirrors: []repoMirrorOut{
47 {Direction: "push", URL: "https://github.com/x/tap.git"},
48 {Direction: "push", URL: "https://github.com/x/tap2.git", LastError: "exit status 1"},
49 }}
50 out := renderString(c, repoShowScreen(c, d, repoGlance{}, nil, nil, nil))
51 if strings.Count(out, "Mirror:") != 1 || !strings.Contains(out, "tap2.git: exit status 1") {
52 t.Errorf("mirror lines:\n%s", out)
53 }
54}
internal/control/screen.go +39 −4
@@ -27,12 +27,14 @@ type field struct {
2727 value []cell
2828}
2929
30// section is a titled run of rows. n is the total, shown as "(n)"; when
31// it is more than the rows shown, more is the command for the rest. An
32// empty section is left out unless empty says to draw "(0)".
30// section is a titled run of rows. n is the total, shown as "(n)", and
31// note follows it dim ("+86 −12"); when n is more than the rows shown,
32// more is the command for the rest. An empty section is left out unless
33// empty says to draw "(0)".
3334type section struct {
3435 title string
3536 n int
37 note string
3638 rows []row
3739 more []string
3840 empty bool
@@ -181,7 +183,11 @@ func (c *Ctx) renderSection(s section) string {
181183 return ""
182184 }
183185 var b strings.Builder
184 b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n)) + "\n")
186 b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n)))
187 if s.note != "" {
188 b.WriteString(" " + c.Term.paint(sgrDim, termSafe(s.note)))
189 }
190 b.WriteString("\n")
185191 if len(s.rows) > 0 {
186192 tb := &table{term: c.Term, w: io.Discard}
187193 for _, r := range s.rows {
@@ -277,3 +283,32 @@ func (c *Ctx) renderLegend(as []action) string {
277283 }
278284 return b.String()
279285}
286
287// discussion is a thread's comments as a section, each author and age
288// with its body beneath. System events are events'. It is drawn when
289// empty, as "(0)".
290func discussion(cs []commentOut) section {
291 sec := section{title: "Discussion", empty: true}
292 for _, cm := range cs {
293 if cm.Kind == "system" {
294 continue
295 }
296 sec.n++
297 sec.rows = append(sec.rows, row{cells: []cell{cText(cm.Author), cAge(cm.CreatedAt)}, body: cm.Body, format: cm.BodyFormat})
298 }
299 return sec
300}
301
302// events is a thread's system comments ("referenced in commit ...") as
303// a section, each its age and its text without link targets.
304func events(cs []commentOut) section {
305 sec := section{title: "Events"}
306 for _, cm := range cs {
307 if cm.Kind != "system" {
308 continue
309 }
310 sec.n++
311 sec.rows = append(sec.rows, rowOf(cAge(cm.CreatedAt), cFlex(termtext.Inline(cm.Body, cm.BodyFormat))))
312 }
313 return sec
314}
internal/control/testdata/plain/build-show.txt added +8
@@ -0,0 +1,8 @@
1#1 test failure
2
3 sha a136534ff9
4 ref main
5 queued <time>
6 finished <time>
7 failed step 2/2 go test ./... (exit 1)
8 url /alice/app/builds/1
internal/control/testdata/plain/issue-show.txt added +10
@@ -0,0 +1,10 @@
1#1 Android app open
2
3 author alice, <time>
4 url /alice/app/issues/1
5
6 An app.
7
8── alice, <time> (comment 1)
9
10 Started.
internal/control/testdata/plain/mr-show.txt added +14
@@ -0,0 +1,14 @@
1!1 one open
2
3 author alice, <time>
4 source feature1 -> main
5 head abc111
6 url /alice/app/mrs/1
7
8checks (2):
9build success <time>
10test success <time>
11
12── alice, <time> (comment 1)
13
14 Looks right.
internal/control/testdata/plain/repo-show.txt added +4
@@ -0,0 +1,4 @@
1alice/app public
2
3 default branch main
4 url /alice/app
internal/control/view_test.go +13 −7
@@ -55,10 +55,14 @@ func TestIssueShowTerminal(t *testing.T) {
5555 if !strings.Contains(out, sgrGreen+"open"+sgrReset) {
5656 t.Errorf("state not coloured:\n%s", out)
5757 }
58 if !strings.Contains(out, " UTC") {
59 t.Errorf("no web-format timestamp:\n%s", out)
58 if !strings.Contains(stripSGR(out), "referenced in commit abc1234567 by alice") {
59 t.Errorf("event missing:\n%s", out)
6060 }
6161 for _, line := range strings.Split(stripSGR(out), "\n") {
62 // A suggested command is never cut: it has to paste whole.
63 if strings.HasPrefix(line, "gitbay ") {
64 continue
65 }
6266 if cells(line) > 60 {
6367 t.Errorf("line over 60 cells: %q", line)
6468 }
@@ -95,21 +99,23 @@ func TestIssueShowNarrowWraps(t *testing.T) {
9599 var titleLines []string
96100 inTitle := false
97101 for _, line := range strings.Split(stripSGR(out), "\n") {
98 if cells(line) > 40 {
102 // A suggested command is never cut: it has to paste whole.
103 if cells(line) > 40 && !strings.HasPrefix(line, "gitbay ") {
99104 t.Errorf("line over 40 cells: %q", line)
100105 }
106 _, rest, isTitle := strings.Cut(line, "#1 ")
101107 switch {
102 case strings.HasPrefix(line, "#1 "):
108 case strings.HasPrefix(line, "Issue:") && isTitle:
103109 inTitle = true
104 titleLines = append(titleLines, strings.TrimPrefix(line, "#1 "))
105 case inTitle && strings.HasPrefix(line, " "):
110 titleLines = append(titleLines, rest)
111 case inTitle && strings.HasPrefix(line, " "):
106112 titleLines = append(titleLines, strings.TrimSpace(line))
107113 default:
108114 inTitle = false
109115 }
110116 }
111117 got := strings.Join(strings.Fields(strings.Join(titleLines, " ")), " ")
112 want := strings.Join(strings.Fields(title+" open"), " ")
118 want := strings.Join(strings.Fields(title), " ")
113119 if got != want {
114120 t.Errorf("title text lost across wrap: got %q, want %q", got, want)
115121 }
internal/gitutil/numstat.go added +84
@@ -0,0 +1,84 @@
1package gitutil
2
3import (
4 "fmt"
5 "os/exec"
6 "strconv"
7 "strings"
8
9 "gitbay.org/gitbay/internal/toolpath"
10)
11
12// NumStat is one file's line counts between two commits. A binary file
13// counts -1 each way. Status is A, M, D or R.
14type NumStat struct {
15 Path string
16 Added int
17 Deleted int
18 Status string
19}
20
21// DiffNumstat lists the files changed from base to head with their line
22// counts and status, renames detected. A renamed file is listed under
23// its new path.
24func DiffNumstat(dir, base, head string) ([]NumStat, error) {
25 run := func(args ...string) (string, error) {
26 cmd := exec.Command(toolpath.Look("git"), append([]string{"-C", dir}, args...)...)
27 out, err := cmd.Output()
28 if err != nil {
29 return "", fmt.Errorf("git %s %s: %w", args[0], args[1], err)
30 }
31 return string(out), nil
32 }
33 num, err := run("diff", "--numstat", "-z", "-M", "--end-of-options", base, head)
34 if err != nil {
35 return nil, err
36 }
37 names, err := run("diff", "--name-status", "-z", "-M", "--end-of-options", base, head)
38 if err != nil {
39 return nil, err
40 }
41 // --name-status -z: "M\0path\0", or "R100\0old\0new\0" for a rename
42 // or copy.
43 status := map[string]string{}
44 f := strings.Split(strings.TrimSuffix(names, "\x00"), "\x00")
45 for i := 0; i < len(f); {
46 s := f[i]
47 if s == "" {
48 i++
49 continue
50 }
51 if s[0] == 'R' || s[0] == 'C' {
52 if i+2 < len(f) {
53 status[f[i+2]] = s[:1]
54 }
55 i += 3
56 continue
57 }
58 if i+1 < len(f) {
59 status[f[i+1]] = s[:1]
60 }
61 i += 2
62 }
63 // --numstat -z: "a\td\tpath\0", or "a\td\t\0old\0new\0" for a rename.
64 var out []NumStat
65 recs := strings.Split(strings.TrimSuffix(num, "\x00"), "\x00")
66 for i := 0; i < len(recs); i++ {
67 parts := strings.SplitN(recs[i], "\t", 3)
68 if len(parts) != 3 {
69 continue
70 }
71 path := parts[2]
72 if path == "" && i+2 < len(recs) {
73 path = recs[i+2]
74 i += 2
75 }
76 ns := NumStat{Path: path, Added: -1, Deleted: -1, Status: status[path]}
77 if parts[0] != "-" {
78 ns.Added, _ = strconv.Atoi(parts[0])
79 ns.Deleted, _ = strconv.Atoi(parts[1])
80 }
81 out = append(out, ns)
82 }
83 return out, nil
84}
internal/gitutil/numstat_test.go added +43
@@ -0,0 +1,43 @@
1package gitutil
2
3import (
4 "os"
5 "path/filepath"
6 "slices"
7 "strings"
8 "testing"
9)
10
11func TestDiffNumstat(t *testing.T) {
12 dir := t.TempDir()
13 git(t, dir, "init", "-q", "-b", "main")
14 write(t, dir, "a.txt", "one\ntwo\nthree\n")
15 write(t, dir, "old.txt", strings.Repeat("line\n", 20))
16 git(t, dir, "add", ".")
17 git(t, dir, "commit", "-qm", "base")
18 git(t, dir, "tag", "base")
19
20 write(t, dir, "a.txt", "one\n2\n3\nthree\n")
21 write(t, dir, "new.txt", "hello\n")
22 if err := os.WriteFile(filepath.Join(dir, "bin.dat"), []byte{0, 1, 2, 0, 3}, 0o644); err != nil {
23 t.Fatal(err)
24 }
25 git(t, dir, "mv", "old.txt", "moved.txt")
26 git(t, dir, "add", ".")
27 git(t, dir, "commit", "-qm", "head")
28
29 got, err := DiffNumstat(dir, "base", "HEAD")
30 if err != nil {
31 t.Fatal(err)
32 }
33 want := []NumStat{
34 {Path: "a.txt", Added: 2, Deleted: 1, Status: "M"},
35 {Path: "bin.dat", Added: -1, Deleted: -1, Status: "A"},
36 {Path: "moved.txt", Added: 0, Deleted: 0, Status: "R"},
37 {Path: "new.txt", Added: 1, Deleted: 0, Status: "A"},
38 }
39 slices.SortFunc(got, func(a, b NumStat) int { return strings.Compare(a.Path, b.Path) })
40 if !slices.Equal(got, want) {
41 t.Errorf("got %+v\nwant %+v", got, want)
42 }
43}