control: show screens (CLI views stage 2a) !553
20 files changed, +880 −109
Layout: unified · split
.gitbay/wiki/Users.org +14 −1
| @@ -1034,7 +1034,20 @@ then prints: | |||
| 1034 | both made safe like any user text, the log's own colour codes dropped; | 1034 | both made safe like any user text, the log's own colour codes dropped; |
| 1035 | - ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as | 1035 | - ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as |
| 1036 | =53m43s=, where piped output keeps RFC3339, bytes and seconds; | 1036 | =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 | ||
| 1038 | from markdown or org, one line per event, and comments under a rule; | 1051 | from markdown or org, one line per event, and comments under a rule; |
| 1039 | a sub-table (labels, revisions, and the like) carries a section label | 1052 | a sub-table (labels, revisions, and the like) carries a section label |
| 1040 | line in both terminal and piped output; timestamps as | 1053 | 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. | |||
| 12 | sends the repository it inferred as =here== in =--term=, so suggested | 12 | sends the repository it inferred as =here== in =--term=, so suggested |
| 13 | commands leave it out; an older instance ignores the option. Piped | 13 | commands leave it out; an older instance ignores the option. Piped |
| 14 | and =--json= output are unchanged. | 14 | 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 =◐=. | ||
| 15 | 23 | ||
| 16 | * v1.41.0 — 2026-10-01 | 24 | * v1.41.0 — 2026-10-01 |
| 17 | 25 | ||
internal/control/build.go +42 −21
| @@ -244,7 +244,13 @@ func runBuildShow(c *Ctx, args []string) int { | |||
| 244 | } | 244 | } |
| 245 | d := buildToOut(b) | 245 | d := buildToOut(b) |
| 246 | json.Unmarshal([]byte(b.Steps), &d.Steps) | 246 | 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) { | ||
| 248 | failedStep, failed := "", "" | 254 | failedStep, failed := "", "" |
| 249 | if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) { | 255 | if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) { |
| 250 | step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n") | 256 | step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n") |
| @@ -259,15 +265,6 @@ func runBuildShow(c *Ctx, args []string) int { | |||
| 259 | if d.DurationS > 0 { | 265 | if d.DurationS > 0 { |
| 260 | duration = (time.Duration(d.DurationS) * time.Second).String() | 266 | duration = (time.Duration(d.DurationS) * time.Second).String() |
| 261 | } | 267 | } |
| 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 | } | ||
| 271 | v := c.view(w) | 268 | v := c.view(w) |
| 272 | v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status) | 269 | v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status) |
| 273 | v.fields( | 270 | v.fields( |
| @@ -278,20 +275,44 @@ func runBuildShow(c *Ctx, args []string) int { | |||
| 278 | "duration", duration, | 275 | "duration", duration, |
| 279 | "failed step", failedStep, | 276 | "failed step", failedStep, |
| 280 | "failed", failed, | 277 | "failed", failed, |
| 281 | "mr", mr, | ||
| 282 | "log", logCmd, | ||
| 283 | "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)), | 278 | "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)), |
| 284 | ) | 279 | ) |
| 285 | if c.Term.Cols > 0 && len(d.Steps) > 0 { | 280 | }, func() screen { return buildShowScreen(c, repo, d, mr) }) |
| 286 | v.section("steps") | 281 | } |
| 287 | tb := c.table(w, "#", "STEP", "STATE") | 282 | |
| 288 | for i, step := range d.Steps { | 283 | // buildShowScreen is build show at a terminal: the build's outcome, what |
| 289 | line, _, _ := strings.Cut(step, "\n") | 284 | // it ran on, each step's outcome, and the command for its log. |
| 290 | tb.row(cNum(int64(i+1)), cFlex(line), cState(stepState(d.Status, d.FailedStep, i+1))) | 285 | func buildShowScreen(c *Ctx, repo store.Repo, d BuildOut, mr *store.MR) screen { |
| 291 | } | 286 | n := strconv.FormatInt(d.Number, 10) |
| 292 | tb.flush() | 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 | ||
| 293 | } | 310 | } |
| 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 | ||
| 295 | } | 316 | } |
| 296 | 317 | ||
| 297 | // stepState is what a finished build says about one of its steps: those | 318 | // stepState is what a finished build says about one of its steps: those |
internal/control/buildscreen_test.go added +69
| @@ -0,0 +1,69 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strconv" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/protocol" | ||
| 9 | "gitbay.org/gitbay/internal/store" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func 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 | |||
| 38 | func 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 | |||
| 62 | func 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 ( | |||
| 4 | "errors" | 4 | "errors" |
| 5 | "fmt" | 5 | "fmt" |
| 6 | "io" | 6 | "io" |
| 7 | "slices" | ||
| 7 | "strconv" | 8 | "strconv" |
| 8 | "strings" | 9 | "strings" |
| 9 | 10 | ||
| @@ -399,7 +400,13 @@ func runIssueShow(c *Ctx, args []string) int { | |||
| 399 | cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])}) | 400 | cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])}) |
| 400 | } | 401 | } |
| 401 | d := IssueShow{issueOut: issueToOut(issue, true), Reactions: reactionsOut(rx[0]), Comments: cs} | 402 | 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) { | ||
| 403 | v := c.view(w) | 410 | v := c.view(w) |
| 404 | v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State) | 411 | v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State) |
| 405 | v.fields( | 412 | v.fields( |
| @@ -429,7 +436,7 @@ func runIssueShow(c *Ctx, args []string) int { | |||
| 429 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) | 436 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) |
| 430 | v.reactions(cm.Reactions) | 437 | v.reactions(cm.Reactions) |
| 431 | } | 438 | } |
| 432 | }) | 439 | }, func() screen { return issueShowScreen(c, repo, d, canWrite) }) |
| 433 | } | 440 | } |
| 434 | 441 | ||
| 435 | func runIssueComment(c *Ctx, args []string) int { | 442 | func runIssueComment(c *Ctx, args []string) int { |
| @@ -717,3 +724,48 @@ func assignIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []sto | |||
| 717 | } | 724 | } |
| 718 | return updated.Assignees, -1 | 725 | return updated.Assignees, -1 |
| 719 | } | 726 | } |
| 727 | |||
| 728 | // issueShowScreen is issue show at a terminal. canWrite is write access, | ||
| 729 | // which assigning and labelling need; the author may also close. | ||
| 730 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "slices" | ||
| 5 | "testing" | ||
| 6 | |||
| 7 | "gitbay.org/gitbay/internal/protocol" | ||
| 8 | "gitbay.org/gitbay/internal/store" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func 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 | |||
| 28 | func 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 | |||
| 38 | func 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 | |||
| 46 | func 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 | |||
| 59 | func 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 | |||
| 68 | func 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 ( | |||
| 9 | ) | 9 | ) |
| 10 | 10 | ||
| 11 | // checksMark sums a commit's statuses for a list row at a terminal: | 11 | // 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. |
| 13 | // The glyph leads so the column reads at a glance; the words stay for | 13 | // The glyph leads so the column reads at a glance; the words stay for |
| 14 | // a terminal without colour. | 14 | // a terminal without colour. |
| 15 | func checksMark(sts []store.CommitStatus) cell { | 15 | func checksMark(sts []store.CommitStatus) cell { |
| @@ -30,7 +30,7 @@ func checksMark(sts []store.CommitStatus) cell { | |||
| 30 | case failed > 0: | 30 | case failed > 0: |
| 31 | return cMark(fmt.Sprintf("✗ %d failed", failed), sgrRed) | 31 | return cMark(fmt.Sprintf("✗ %d failed", failed), sgrRed) |
| 32 | case pending > 0: | 32 | case pending > 0: |
| 33 | return cMark(fmt.Sprintf("• %d pending", pending), sgrDim) | 33 | return cMark(fmt.Sprintf("◐ %d pending", pending), sgrDim) |
| 34 | } | 34 | } |
| 35 | return cMark(fmt.Sprintf("✓ %d/%d", passed, len(sts)), sgrGreen) | 35 | return cMark(fmt.Sprintf("✓ %d/%d", passed, len(sts)), sgrGreen) |
| 36 | } | 36 | } |
internal/control/marks_test.go +1 −1
| @@ -20,7 +20,7 @@ func TestChecksMark(t *testing.T) { | |||
| 20 | }{ | 20 | }{ |
| 21 | {nil, "", ""}, | 21 | {nil, "", ""}, |
| 22 | {st("success", "success", "skipped"), "✓ 3/3", sgrGreen}, | 22 | {st("success", "success", "skipped"), "✓ 3/3", sgrGreen}, |
| 23 | {st("success", "pending"), "• 1 pending", sgrDim}, | 23 | {st("success", "pending"), "◐ 1 pending", sgrDim}, |
| 24 | {st("success", "failure", "error", "pending"), "✗ 2 failed", sgrRed}, | 24 | {st("success", "failure", "error", "pending"), "✗ 2 failed", sgrRed}, |
| 25 | } | 25 | } |
| 26 | for _, tc := range cases { | 26 | for _, tc := range cases { |
internal/control/mr.go +94 −2
| @@ -816,7 +816,11 @@ func runMRShow(c *Ctx, args []string) int { | |||
| 816 | } | 816 | } |
| 817 | } | 817 | } |
| 818 | } | 818 | } |
| 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) { | ||
| 820 | state := d.State | 824 | state := d.State |
| 821 | if d.Draft { | 825 | if d.Draft { |
| 822 | state = "draft" | 826 | state = "draft" |
| @@ -967,7 +971,7 @@ func runMRShow(c *Ctx, args []string) int { | |||
| 967 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) | 971 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) |
| 968 | v.reactions(cm.Reactions) | 972 | v.reactions(cm.Reactions) |
| 969 | } | 973 | } |
| 970 | }) | 974 | }, func() screen { return mrShowScreen(c, repo, d, files) }) |
| 971 | } | 975 | } |
| 972 | 976 | ||
| 973 | // reviewLine renders one review as fields prose: "reviewer verdict | 977 | // reviewLine renders one review as fields prose: "reviewer verdict |
| @@ -2196,3 +2200,91 @@ func ReviewersWhoCount(st *store.Store, repo store.Repo, reviews []store.MRRevie | |||
| 2196 | } | 2200 | } |
| 2197 | return counts | 2201 | return counts |
| 2198 | } | 2202 | } |
| 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. | ||
| 2207 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 13 | func 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 | |||
| 34 | func 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 | |||
| 48 | func 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 | |||
| 58 | func 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 | |||
| 66 | var screenRepo = store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"} | ||
| 67 | |||
| 68 | func 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 | |||
| 83 | func 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 | |||
| 97 | func 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 | |||
| 109 | func 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 { | |||
| 373 | }) | 373 | }) |
| 374 | } | 374 | } |
| 375 | 375 | ||
| 376 | // repoMirrorOut is one mirror as repo show emits it, for admins. | ||
| 377 | type 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. | ||
| 386 | type 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 | |||
| 376 | func runRepoShow(c *Ctx, args []string) int { | 406 | func runRepoShow(c *Ctx, args []string) int { |
| 377 | if len(args) != 1 { | 407 | if len(args) != 1 { |
| 378 | return c.usage() | 408 | return c.usage() |
| @@ -381,32 +411,6 @@ func runRepoShow(c *Ctx, args []string) int { | |||
| 381 | if code >= 0 { | 411 | if code >= 0 { |
| 382 | return code | 412 | return code |
| 383 | } | 413 | } |
| 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 | } | ||
| 410 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) | 414 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) |
| 411 | topics, err := c.Store.ListTopics(repo.ID) | 415 | topics, err := c.Store.ListTopics(repo.ID) |
| 412 | if err != nil { | 416 | if err != nil { |
| @@ -420,7 +424,7 @@ func runRepoShow(c *Ctx, args []string) int { | |||
| 420 | } | 424 | } |
| 421 | } | 425 | } |
| 422 | } | 426 | } |
| 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, |
| 424 | DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches, | 428 | DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches, |
| 425 | Archived: repo.Settings.Archived, Topics: topics, Domains: domains} | 429 | Archived: repo.Settings.Archived, Topics: topics, Domains: domains} |
| 426 | if repo.ForkOf != 0 { | 430 | if repo.ForkOf != 0 { |
| @@ -442,14 +446,26 @@ func runRepoShow(c *Ctx, args []string) int { | |||
| 442 | return c.fail(protocol.ExitFailure, "%v", err) | 446 | return c.fail(protocol.ExitFailure, "%v", err) |
| 443 | } | 447 | } |
| 444 | for _, m := range ms { | 448 | 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}) |
| 446 | } | 450 | } |
| 447 | } | 451 | } |
| 448 | var glance repoGlance | 452 | var glance repoGlance |
| 453 | var mrs []store.MR | ||
| 454 | var issues []store.Issue | ||
| 455 | var commits []CommitOut | ||
| 449 | if c.Term.Cols > 0 && !c.JSON { | 456 | if c.Term.Cols > 0 && !c.JSON { |
| 450 | glance = repoAtAGlance(c, repo) | 457 | 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 | } | ||
| 451 | } | 467 | } |
| 452 | return c.emit(d, func(w io.Writer) { | 468 | return c.emitView(d, func(w io.Writer) { |
| 453 | bookmarked, archived := "", "" | 469 | bookmarked, archived := "", "" |
| 454 | if d.Bookmarked { | 470 | if d.Bookmarked { |
| 455 | bookmarked = "yes" | 471 | bookmarked = "yes" |
| @@ -458,41 +474,19 @@ func runRepoShow(c *Ctx, args []string) int { | |||
| 458 | archived = "yes" | 474 | archived = "yes" |
| 459 | } | 475 | } |
| 460 | v := c.view(w) | 476 | v := c.view(w) |
| 461 | if c.Term.Cols > 0 { | 477 | v.title(d.Path, d.Description, d.Visibility) |
| 462 | v.title(d.Path, "", d.Visibility) | 478 | v.fields( |
| 463 | v.text(d.Description) | 479 | "default branch", d.DefaultBranch, |
| 464 | v.fields( | 480 | "website", d.Website, |
| 465 | "clone", glance.clone, | 481 | "topics", strings.Join(d.Topics, ", "), |
| 466 | "issues", glance.issues, | 482 | "protected", strings.Join(d.ProtectedBranches, ", "), |
| 467 | "merge requests", glance.mrs, | 483 | "pages domains", strings.Join(d.Domains, ", "), |
| 468 | "release", glance.release, | 484 | "fork of", d.ForkOf, |
| 469 | "checks", glance.checks, | 485 | "watch", d.Watch, |
| 470 | "default branch", d.DefaultBranch, | 486 | "bookmarked", bookmarked, |
| 471 | "website", d.Website, | 487 | "archived", archived, |
| 472 | "topics", strings.Join(d.Topics, ", "), | 488 | "url", c.siteURL(d.Path), |
| 473 | "protected", strings.Join(d.ProtectedBranches, ", "), | 489 | ) |
| 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 | } | ||
| 496 | if len(d.Mirrors) > 0 { | 490 | if len(d.Mirrors) > 0 { |
| 497 | v.section("mirror") | 491 | v.section("mirror") |
| 498 | tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS") | 492 | tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS") |
| @@ -508,13 +502,14 @@ func runRepoShow(c *Ctx, args []string) int { | |||
| 508 | } | 502 | } |
| 509 | tb.flush() | 503 | tb.flush() |
| 510 | } | 504 | } |
| 511 | }) | 505 | }, func() screen { return repoShowScreen(c, d, glance, mrs, issues, commits) }) |
| 512 | } | 506 | } |
| 513 | 507 | ||
| 514 | // repoGlance is what repo show adds at a terminal: how to clone it and | 508 | // repoGlance is what repo show adds at a terminal: how to clone it and |
| 515 | // what is going on in it. | 509 | // what is going on in it. |
| 516 | type repoGlance struct { | 510 | type repoGlance struct { |
| 517 | clone, issues, mrs, release, checks string | 511 | clone, release, checks string |
| 512 | issuesN, mrsN int | ||
| 518 | } | 513 | } |
| 519 | 514 | ||
| 520 | // repoAtAGlance reads the glance fields. Each is left blank when it | 515 | // repoAtAGlance reads the glance fields. Each is left blank when it |
| @@ -525,9 +520,7 @@ func repoAtAGlance(c *Ctx, repo store.Repo) repoGlance { | |||
| 525 | host += ":" + strconv.Itoa(c.Cfg.SSH.Port) | 520 | host += ":" + strconv.Itoa(c.Cfg.SSH.Port) |
| 526 | } | 521 | } |
| 527 | g := repoGlance{clone: "ssh://git@" + host + "/" + repo.Path() + ".git"} | 522 | g := repoGlance{clone: "ssh://git@" + host + "/" + repo.Path() + ".git"} |
| 528 | issues, mrs := c.Store.OpenCounts(repo.ID) | 523 | g.issuesN, g.mrsN = c.Store.OpenCounts(repo.ID) |
| 529 | g.issues = fmt.Sprintf("%d open", issues) | ||
| 530 | g.mrs = fmt.Sprintf("%d open", mrs) | ||
| 531 | if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 { | 524 | if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 { |
| 532 | g.release = rs[0].Tag + ", " + relAge(rs[0].CreatedAt, termNow()) | 525 | g.release = rs[0].Tag + ", " + relAge(rs[0].CreatedAt, termNow()) |
| 533 | } | 526 | } |
| @@ -1374,3 +1367,59 @@ func runRepoDiff(c *Ctx, args []string) int { | |||
| 1374 | } | 1367 | } |
| 1375 | return protocol.ExitOK | 1368 | return protocol.ExitOK |
| 1376 | } | 1369 | } |
| 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. | ||
| 1373 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | |||
| 7 | "gitbay.org/gitbay/internal/protocol" | ||
| 8 | "gitbay.org/gitbay/internal/store" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func 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 | |||
| 20 | func 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 | |||
| 44 | func 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 { | |||
| 27 | value []cell | 27 | value []cell |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | // section is a titled run of rows. n is the total, shown as "(n)"; when | 30 | // section is a titled run of rows. n is the total, shown as "(n)", and |
| 31 | // it is more than the rows shown, more is the command for the rest. An | 31 | // note follows it dim ("+86 −12"); when n is more than the rows shown, |
| 32 | // empty section is left out unless empty says to draw "(0)". | 32 | // more is the command for the rest. An empty section is left out unless |
| 33 | // empty says to draw "(0)". | ||
| 33 | type section struct { | 34 | type section struct { |
| 34 | title string | 35 | title string |
| 35 | n int | 36 | n int |
| 37 | note string | ||
| 36 | rows []row | 38 | rows []row |
| 37 | more []string | 39 | more []string |
| 38 | empty bool | 40 | empty bool |
| @@ -181,7 +183,11 @@ func (c *Ctx) renderSection(s section) string { | |||
| 181 | return "" | 183 | return "" |
| 182 | } | 184 | } |
| 183 | var b strings.Builder | 185 | 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") | ||
| 185 | if len(s.rows) > 0 { | 191 | if len(s.rows) > 0 { |
| 186 | tb := &table{term: c.Term, w: io.Discard} | 192 | tb := &table{term: c.Term, w: io.Discard} |
| 187 | for _, r := range s.rows { | 193 | for _, r := range s.rows { |
| @@ -277,3 +283,32 @@ func (c *Ctx) renderLegend(as []action) string { | |||
| 277 | } | 283 | } |
| 278 | return b.String() | 284 | return b.String() |
| 279 | } | 285 | } |
| 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)". | ||
| 290 | func 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. | ||
| 304 | func 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 | |||
| 8 | checks (2): | ||
| 9 | build success <time> | ||
| 10 | test 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 @@ | |||
| 1 | alice/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) { | |||
| 55 | if !strings.Contains(out, sgrGreen+"open"+sgrReset) { | 55 | if !strings.Contains(out, sgrGreen+"open"+sgrReset) { |
| 56 | t.Errorf("state not coloured:\n%s", out) | 56 | t.Errorf("state not coloured:\n%s", out) |
| 57 | } | 57 | } |
| 58 | if !strings.Contains(out, " UTC") { | 58 | if !strings.Contains(stripSGR(out), "referenced in commit abc1234567 by alice") { |
| 59 | t.Errorf("no web-format timestamp:\n%s", out) | 59 | t.Errorf("event missing:\n%s", out) |
| 60 | } | 60 | } |
| 61 | for _, line := range strings.Split(stripSGR(out), "\n") { | 61 | 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 | } | ||
| 62 | if cells(line) > 60 { | 66 | if cells(line) > 60 { |
| 63 | t.Errorf("line over 60 cells: %q", line) | 67 | t.Errorf("line over 60 cells: %q", line) |
| 64 | } | 68 | } |
| @@ -95,21 +99,23 @@ func TestIssueShowNarrowWraps(t *testing.T) { | |||
| 95 | var titleLines []string | 99 | var titleLines []string |
| 96 | inTitle := false | 100 | inTitle := false |
| 97 | for _, line := range strings.Split(stripSGR(out), "\n") { | 101 | 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 ") { | ||
| 99 | t.Errorf("line over 40 cells: %q", line) | 104 | t.Errorf("line over 40 cells: %q", line) |
| 100 | } | 105 | } |
| 106 | _, rest, isTitle := strings.Cut(line, "#1 ") | ||
| 101 | switch { | 107 | switch { |
| 102 | case strings.HasPrefix(line, "#1 "): | 108 | case strings.HasPrefix(line, "Issue:") && isTitle: |
| 103 | inTitle = true | 109 | inTitle = true |
| 104 | titleLines = append(titleLines, strings.TrimPrefix(line, "#1 ")) | 110 | titleLines = append(titleLines, rest) |
| 105 | case inTitle && strings.HasPrefix(line, " "): | 111 | case inTitle && strings.HasPrefix(line, " "): |
| 106 | titleLines = append(titleLines, strings.TrimSpace(line)) | 112 | titleLines = append(titleLines, strings.TrimSpace(line)) |
| 107 | default: | 113 | default: |
| 108 | inTitle = false | 114 | inTitle = false |
| 109 | } | 115 | } |
| 110 | } | 116 | } |
| 111 | got := strings.Join(strings.Fields(strings.Join(titleLines, " ")), " ") | 117 | got := strings.Join(strings.Fields(strings.Join(titleLines, " ")), " ") |
| 112 | want := strings.Join(strings.Fields(title+" open"), " ") | 118 | want := strings.Join(strings.Fields(title), " ") |
| 113 | if got != want { | 119 | if got != want { |
| 114 | t.Errorf("title text lost across wrap: got %q, want %q", got, want) | 120 | t.Errorf("title text lost across wrap: got %q, want %q", got, want) |
| 115 | } | 121 | } |
internal/gitutil/numstat.go added +84
| @@ -0,0 +1,84 @@ | |||
| 1 | package gitutil | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 14 | type 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. | ||
| 24 | func 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 @@ | |||
| 1 | package gitutil | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "slices" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func 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 | } | ||