A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 59ae144d84

59ae144d8401824b80ae6531f5926f155f058b59

parent: a715db2f28

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-28T02:16:22Z

control: profile aggregate and repo commit

Two more places the web reached past the registry, and the same
consequence: capabilities no other surface could have.

profile show now returns what a profile page is — org memberships (or
an org's members), the repositories the caller may read, and the
activity window with its total — not just the description and website.
Private repositories stay invisible: each is filtered through CanRead
for the asking account, so a profile shows no more than a listing
would.

repo commit shows one commit: metadata, signature verdict, check
statuses, and its patch, so a log entry can be opened from anywhere
rather than only in the browser.

ActivityWindow has one definition now, shared by the command and the
web, so the two cannot report different years.
e2e/blame_test.go +17
@@ -58,6 +58,23 @@ func TestBlameView(t *testing.T) {
5858 t.Errorf("ranged blame leaked lines outside the window: %s", out)
5959 }
6060
61 // One commit, with its patch — the capability behind a tappable log
62 // entry, which the web read straight from git.
63 out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "commit", "alice/app", "HEAD", "--json")
64 if code != 0 {
65 t.Fatalf("repo commit: %s", errOut)
66 }
67 for _, want := range []string{
68 `"subject":"change line two"`, `"signature"`, `"diff"`, "TWO CHANGED",
69 } {
70 if !strings.Contains(out, want) {
71 t.Errorf("repo commit missing %q: %s", want, out)
72 }
73 }
74 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "commit", "alice/app", "deadbeef"); code == 0 {
75 t.Error("an unknown sha resolved")
76 }
77
6178 // Binary files have nothing to attribute, and say so rather than 500.
6279 os.WriteFile(filepath.Join(dir, "logo.bin"), []byte{0, 1, 2, 0, 3}, 0o644)
6380 mustGit(t, dir, env, "add", ".")
e2e/profile_test.go +36
@@ -25,6 +25,42 @@ func TestOwnerProfiles(t *testing.T) {
2525 t.Fatalf("profile show: %s", out)
2626 }
2727
28 // A profile is the whole page's worth: repositories the caller may
29 // see, org membership, and the activity window — all previously
30 // readable only by the web, which went straight to the store.
31 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/public-tool"); code != 0 {
32 t.Fatal("repo create")
33 }
34 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
35 t.Fatal("private repo create")
36 }
37 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "toolmakers"); code != 0 {
38 t.Fatal("org create")
39 }
40
41 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
42 for _, want := range []string{`"path":"alice/public-tool"`, `"path":"alice/secret"`,
43 `"name":"toolmakers"`, `"activity_total"`} {
44 if !strings.Contains(out, want) {
45 t.Errorf("own profile missing %q: %s", want, out)
46 }
47 }
48
49 // A stranger sees the public repository and not the private one.
50 out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
51 if !strings.Contains(out, `"path":"alice/public-tool"`) {
52 t.Errorf("stranger cannot see a public repo on a profile: %s", out)
53 }
54 if strings.Contains(out, `"alice/secret"`) {
55 t.Errorf("a private repository leaked onto a profile: %s", out)
56 }
57
58 // An org profile lists its members rather than org memberships.
59 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "toolmakers", "--json")
60 if !strings.Contains(out, `"kind":"org"`) || !strings.Contains(out, `"name":"alice"`) {
61 t.Errorf("org profile members: %s", out)
62 }
63
2864 // Partial update leaves the other field untouched; empty clears.
2965 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--description", "'tinkerer'"); code != 0 {
3066 t.Fatal("partial set failed")
internal/control/profile.go +122 −3
@@ -4,8 +4,12 @@ import (
44 "errors"
55 "fmt"
66 "io"
7 "sort"
78 "strings"
9 "time"
810
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/policy"
913 "gitbay.org/gitbay/internal/protocol"
1014 "gitbay.org/gitbay/internal/store"
1115 )
@@ -72,6 +76,43 @@ type profileOut struct {
7276 Kind string `json:"kind"`
7377 Description string `json:"description,omitempty"`
7478 Website string `json:"website,omitempty"`
79 // The rest is what a profile page shows: who they work with, what
80 // they own that you can see, and how active they have been. The web
81 // read these straight out of the store, which kept them off every
82 // other surface.
83 Orgs []profileMember `json:"orgs,omitempty"` // for a user
84 Members []profileMember `json:"members,omitempty"` // for an org
85 Repos []profileRepo `json:"repos"`
86 Activity []activityDay `json:"activity,omitempty"`
87 // ActivityTotal counts the same window the days cover.
88 ActivityTotal int `json:"activity_total"`
89}
90
91type profileMember struct {
92 Name string `json:"name"`
93 Role string `json:"role,omitempty"`
94}
95
96type profileRepo struct {
97 Path string `json:"path"`
98 Visibility string `json:"visibility"`
99 Description string `json:"description,omitempty"`
100 Archived bool `json:"archived,omitempty"`
101}
102
103// activityDay is one day's contribution count. Days with nothing are
104// omitted; a client fills the calendar it wants to draw.
105type activityDay struct {
106 Date string `json:"date"`
107 Count int `json:"count"`
108}
109
110// ActivityWindow is the span a profile reports: the start of the web's
111// 53-week calendar, so every surface shows the same year.
112func ActivityWindow() string {
113 today := time.Now().UTC()
114 end := today.AddDate(0, 0, int(time.Saturday-today.Weekday()))
115 return end.AddDate(0, 0, -53*7+1).Format("2006-01-02")
75116 }
76117
77118 func emitProfile(c *Ctx, d profileOut) int {
@@ -83,6 +124,18 @@ func emitProfile(c *Ctx, d profileOut) int {
83124 if d.Website != "" {
84125 fmt.Fprintf(w, "%s\n", d.Website)
85126 }
127 for _, m := range d.Orgs {
128 fmt.Fprintf(w, "org\t%s\t%s\n", m.Name, m.Role)
129 }
130 for _, m := range d.Members {
131 fmt.Fprintf(w, "member\t%s\t%s\n", m.Name, m.Role)
132 }
133 for _, r := range d.Repos {
134 fmt.Fprintf(w, "repo\t%s\t%s\t%s\n", r.Path, r.Visibility, r.Description)
135 }
136 if d.ActivityTotal > 0 {
137 fmt.Fprintf(w, "activity\t%d in the last year\n", d.ActivityTotal)
138 }
86139 })
87140 }
88141
@@ -105,7 +158,71 @@ func runProfileShow(c *Ctx, args []string) int {
105158 if err != nil {
106159 return c.fail(protocol.ExitFailure, "%v", err)
107160 }
108 return emitProfile(c, profileOut{name, kind, p.Description, p.Website})
161 d := profileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website,
162 Repos: []profileRepo{}}
163
164 // Who they work with. Both lists are public on a profile — the web
165 // has always shown them — and neither exposes anything a member
166 // listing would not.
167 if kind == "user" {
168 orgs, err := c.Store.ListOrgsForUser(id)
169 if err != nil {
170 return c.fail(protocol.ExitFailure, "%v", err)
171 }
172 for _, o := range orgs {
173 d.Orgs = append(d.Orgs, profileMember{Name: o.Username, Role: o.Role})
174 }
175 } else {
176 members, err := c.Store.OrgMembers(id)
177 if err != nil {
178 return c.fail(protocol.ExitFailure, "%v", err)
179 }
180 for _, m := range members {
181 d.Members = append(d.Members, profileMember{Name: m.Username, Role: m.Role})
182 }
183 }
184
185 // Only repositories this caller may read: a private repo must not
186 // surface on a profile any more than it does in a listing.
187 all, err := c.Store.ListReposForOwner(kind, id)
188 if err != nil {
189 return c.fail(protocol.ExitFailure, "%v", err)
190 }
191 for _, repo := range all {
192 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
193 if err != nil {
194 return c.fail(protocol.ExitFailure, "%v", err)
195 }
196 if !policy.CanRead(c.User, repo, grant) {
197 continue
198 }
199 d.Repos = append(d.Repos, profileRepo{
200 Path: repo.Path(),
201 Visibility: repo.Visibility,
202 Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)),
203 Archived: repo.Settings.Archived,
204 })
205 }
206
207 var counts map[string]int
208 if kind == "user" {
209 counts, err = c.Store.ActivityByDay(id, ActivityWindow())
210 } else {
211 counts, err = c.Store.OrgActivityByDay(id, ActivityWindow())
212 }
213 if err != nil {
214 return c.fail(protocol.ExitFailure, "%v", err)
215 }
216 days := make([]string, 0, len(counts))
217 for day := range counts {
218 days = append(days, day)
219 }
220 sort.Strings(days)
221 for _, day := range days {
222 d.Activity = append(d.Activity, activityDay{Date: day, Count: counts[day]})
223 d.ActivityTotal += counts[day]
224 }
225 return emitProfile(c, d)
109226 }
110227
111228 func runProfileSet(c *Ctx, args []string) int {
@@ -127,7 +244,8 @@ func runProfileSet(c *Ctx, args []string) int {
127244 if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil {
128245 return c.fail(protocol.ExitFailure, "%v", err)
129246 }
130 return emitProfile(c, profileOut{c.User.Username, "user", p.Description, p.Website})
247 return emitProfile(c, profileOut{Name: c.User.Username, Kind: "user",
248 Description: p.Description, Website: p.Website, Repos: []profileRepo{}})
131249 }
132250
133251 func runOrgProfile(c *Ctx, args []string) int {
@@ -154,5 +272,6 @@ func runOrgProfile(c *Ctx, args []string) int {
154272 if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil {
155273 return c.fail(protocol.ExitFailure, "%v", err)
156274 }
157 return emitProfile(c, profileOut{org.Name, "org", p.Description, p.Website})
275 return emitProfile(c, profileOut{Name: org.Name, Kind: "org",
276 Description: p.Description, Website: p.Website, Repos: []profileRepo{}})
158277 }
internal/control/sig.go +101
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "io"
88 "strconv"
9 "strings"
910 "time"
1011
1112 "gitbay.org/gitbay/internal/gitutil"
@@ -22,6 +23,9 @@ func init() {
2223 Summary: "list registered OpenPGP keys", ReadOnly: true, Run: runPGPList})
2324 register(Command{Path: []string{"pgp", "remove"},
2425 Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove})
26 register(Command{Path: []string{"repo", "commit"},
27 Summary: "show one commit with its patch: repo commit <owner/name> <sha>",
28 ReadOnly: true, Run: runRepoCommit})
2529 register(Command{Path: []string{"repo", "log"},
2630 Summary: "commit log with signature states: repo log <owner/name> [--limit n] [--path <file>]", ReadOnly: true, Run: runRepoLog})
2731 }
@@ -216,3 +220,100 @@ func runRepoLog(c *Ctx, args []string) int {
216220 }
217221 })
218222 }
223
224// runRepoCommit shows one commit: its metadata, signature verdict, check
225// statuses, and its patch. The web's commit page read these straight from
226// git, which is why no other surface could open a commit.
227func runRepoCommit(c *Ctx, args []string) int {
228 const usage = "repo commit <owner/name> <sha>"
229 if len(args) != 2 {
230 return c.fail(protocol.ExitUsage, "usage: %s", usage)
231 }
232 repo, code := resolveRepo(c, args[0], policy.CanRead)
233 if code >= 0 {
234 return code
235 }
236 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
237 full, err := gitutil.ResolveRef(dir, args[1])
238 if err != nil {
239 return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
240 }
241 raw, err := gitutil.ReadCommit(dir, full)
242 if err != nil {
243 return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
244 }
245 parsed, err := sig.ParseCommit(raw)
246 if err != nil {
247 return c.fail(protocol.ExitFailure, "parsing %s: %v", full, err)
248 }
249 res, err := VerifyCommitCached(c.Store, repo, parsed, full)
250 if err != nil {
251 return c.fail(protocol.ExitFailure, "verifying %s: %v", full, err)
252 }
253 patch, err := gitutil.ShowPatch(dir, full, 4<<20)
254 if err != nil {
255 return c.fail(protocol.ExitFailure, "%v", err)
256 }
257 statuses, err := c.Store.ListCommitStatuses(repo.ID, full)
258 if err != nil {
259 return c.fail(protocol.ExitFailure, "%v", err)
260 }
261
262 // The message body is everything after the subject line.
263 message := ""
264 if i := strings.Index(string(parsed.Payload), "\n\n"); i >= 0 {
265 message = string(parsed.Payload)[i+2:]
266 }
267
268 type checkOut struct {
269 Context string `json:"context"`
270 State string `json:"state"`
271 URL string `json:"url,omitempty"`
272 }
273 type sigOut struct {
274 State string `json:"state"`
275 Signer string `json:"signer,omitempty"`
276 Fingerprint string `json:"key_fingerprint,omitempty"`
277 }
278 type out struct {
279 Path string `json:"path"`
280 SHA string `json:"sha"`
281 Subject string `json:"subject"`
282 Message string `json:"message,omitempty"`
283 AuthorName string `json:"author_name"`
284 AuthorEmail string `json:"author_email"`
285 CommitterEmail string `json:"committer_email,omitempty"`
286 Date string `json:"date"`
287 Signature sigOut `json:"signature"`
288 Checks []checkOut `json:"checks,omitempty"`
289 // Diff is the unified patch, parsed by the client the same way
290 // mr diff is.
291 Diff string `json:"diff"`
292 }
293 d := out{
294 Path: repo.Path(), SHA: full, Subject: parsed.Subject, Message: message,
295 AuthorName: parsed.AuthorName, AuthorEmail: parsed.AuthorEmail,
296 Date: time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
297 Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
298 Diff: patch,
299 }
300 if parsed.CommitterEmail != parsed.AuthorEmail {
301 d.CommitterEmail = parsed.CommitterEmail
302 }
303 if res.SignerUserID != 0 {
304 if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
305 d.Signature.Signer = u.Username
306 }
307 }
308 for _, st := range statuses {
309 d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL})
310 }
311 return c.emit(d, func(w io.Writer) {
312 fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate: %s\n\n %s\n",
313 d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject)
314 if d.Message != "" {
315 fmt.Fprintf(w, "\n%s\n", d.Message)
316 }
317 fmt.Fprintf(w, "\n%s", d.Diff)
318 })
319}
internal/httpd/activity.go +9 −7
@@ -1,13 +1,17 @@
11 package httpd
22
3import "time"
3import (
4 "time"
5
6 "gitbay.org/gitbay/internal/control"
7)
48
59 // activityDay is one cell of the graph; Level buckets Count into the five
610 // intensity classes the stylesheet colors.
711 type activityDay struct {
812 Date string
913 Count int
10 Level int // 0..4
14 Level int // 0..4
1115 Pad bool // before the range start / after today
1216 }
1317
@@ -57,8 +61,6 @@ func activityLevel(n int) int {
5761 }
5862
5963 // activitySince is the first day the grid can show, for the query bound.
60func activitySince() string {
61 today := time.Now().UTC()
62 end := today.AddDate(0, 0, int(time.Saturday-today.Weekday()))
63 return end.AddDate(0, 0, -53*7+1).Format("2006-01-02")
64}
64// activitySince is control.ActivityWindow: the profile command and the
65// web must report the same year, so the span has one definition.
66func activitySince() string { return control.ActivityWindow() }