Commit 3e75710732

3e75710732680bc79357080d72a716a869571646

parent: c8bc377b47

Verified · cmc ci/build: success ci/test: error

cmc <hello@cleberg.net> · 2026-09-07 02:26 UTC

control, wiki: repo show reports watch, bookmark and fork state

The caller's own watch and bookmark state, and the parent of a fork when
the caller can read it, so a client can draw the real state without a
second read. A private parent is not named.

Closes #178

Layout: unified · split

.gitbay/wiki/Users.org +5
@@ -154,6 +154,11 @@ gitbay repo fork other/project [--name mine]
154gitbay repo delete you/project --yes 154gitbay repo delete you/project --yes
155#+end_src 155#+end_src
156 156
157=repo show= also reports your own state on the repository — =watch=
158(=watching= or =muted=) and =bookmarked= — and =fork_of= when it is a
159fork whose parent you can read, so a client draws a toggle rather than
160two blind buttons. Absent means none.
161
157Pushing is SSH-only. Public repositories are anonymously readable over 162Pushing is SSH-only. Public repositories are anonymously readable over
158HTTPS (and =git://= where enabled); private repositories exist only over 163HTTPS (and =git://= where enabled); private repositories exist only over
159SSH and answer "not found" to everyone without access. 164SSH and answer "not found" to everyone without access.
e2e/reposhowstate_test.go added +59
@@ -0,0 +1,59 @@
1package e2e
2
3import (
4 "strings"
5 "testing"
6)
7
8// repo show carries the caller's own watch and bookmark state and, when
9// the caller can read it, what the repository was forked from — so a
10// client draws the real state without a second read per screen (#178).
11func TestRepoShowCarriesViewerState(t *testing.T) {
12 inst := startInstance(t)
13 aliceKey := inst.newKey(t, "alice")
14 bobKey := inst.newKey(t, "bob")
15 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
16 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
17 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
18 t.Fatalf("repo create: %s", errOut)
19 }
20
21 // Nothing yet: the fields are absent, not false or empty strings.
22 out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app", "--json")
23 for _, k := range []string{`"watch"`, `"bookmarked"`, `"fork_of"`} {
24 if strings.Contains(out, k) {
25 t.Errorf("%s present with no state:\n%s", k, out)
26 }
27 }
28
29 inst.ssh(t, bobKey, "", "repo", "watch", "alice/app")
30 inst.ssh(t, bobKey, "", "repo", "bookmark", "alice/app")
31 out, _, _ = inst.ssh(t, bobKey, "", "repo", "show", "alice/app", "--json")
32 if !strings.Contains(out, `"watch":"watching"`) || !strings.Contains(out, `"bookmarked":true`) {
33 t.Fatalf("bob's state not reported:\n%s", out)
34 }
35 // It is the caller's state: alice sees her own, not bob's.
36 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json"); strings.Contains(out, `"bookmarked"`) {
37 t.Errorf("alice sees bob's bookmark:\n%s", out)
38 }
39
40 // Plain output carries the same.
41 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app"); !strings.Contains(out, "watch: watching") ||
42 !strings.Contains(out, "bookmarked") {
43 t.Errorf("plain output lacks the state:\n%s", out)
44 }
45
46 // A fork names its parent to anyone who can read the parent.
47 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/app"); code != 0 {
48 t.Fatalf("fork: %s", errOut)
49 }
50 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "bob/app", "--json"); !strings.Contains(out, `"fork_of":"alice/app"`) {
51 t.Fatalf("fork_of missing:\n%s", out)
52 }
53 // Once the parent is private and unreadable, the fork stops naming
54 // it: a private repository is never confirmed to exist.
55 inst.ssh(t, aliceKey, "", "repo", "settings", "visibility", "alice/app", "private")
56 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "bob/app", "--json"); strings.Contains(out, "alice/app") {
57 t.Errorf("a fork named a parent the caller cannot read:\n%s", out)
58 }
59}
internal/control/repo.go +30 −2
@@ -289,6 +289,13 @@ func runRepoShow(c *Ctx, args []string) int {
289 Topics []string `json:"topics,omitempty"` 289 Topics []string `json:"topics,omitempty"`
290 Domains []string `json:"domains,omitempty"` 290 Domains []string `json:"domains,omitempty"`
291 Mirrors []mirrorOut `json:"mirrors,omitempty"` 291 Mirrors []mirrorOut `json:"mirrors,omitempty"`
292 // ForkOf names the parent only when the caller can read it: a
293 // private parent is not confirmed to exist, here as anywhere.
294 ForkOf string `json:"fork_of,omitempty"`
295 // Watch and Bookmarked are the caller's own state, so a client
296 // can draw a toggle rather than two stateless buttons (#178).
297 Watch string `json:"watch,omitempty"` // watching, muted, or absent
298 Bookmarked bool `json:"bookmarked,omitempty"`
292 } 299 }
293 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) 300 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
294 topics, err := c.Store.ListTopics(repo.ID) 301 topics, err := c.Store.ListTopics(repo.ID)
@@ -303,8 +310,20 @@ func runRepoShow(c *Ctx, args []string) int {
303 } 310 }
304 } 311 }
305 } 312 }
306 d := out{repo.Path(), desc, repo.Settings.Website, repo.Visibility, repo.DefaultBranch, 313 d := out{Path: repo.Path(), Description: desc, Website: repo.Settings.Website, Visibility: repo.Visibility,
307 repo.Settings.ProtectedBranches, repo.Settings.Archived, topics, domains, nil} 314 DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches,
315 Archived: repo.Settings.Archived, Topics: topics, Domains: domains}
316 if repo.ForkOf != 0 {
317 if parent, err := c.Store.RepoByID(repo.ForkOf); err == nil {
318 if grant, err := c.Store.AccessRole(parent.ID, c.User.ID); err == nil && policy.CanRead(c.User, parent, grant) {
319 d.ForkOf = parent.Path()
320 }
321 }
322 }
323 if c.User.ID != 0 {
324 d.Watch = c.Store.RepoWatchState(repo.ID, c.User.ID)
325 d.Bookmarked = c.Store.IsBookmarked(c.User.ID, repo.ID)
326 }
308 // Mirror status is admin-only, like repo mirror list. The token never 327 // Mirror status is admin-only, like repo mirror list. The token never
309 // leaves the server. 328 // leaves the server.
310 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) { 329 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
@@ -337,6 +356,15 @@ func runRepoShow(c *Ctx, args []string) int {
337 if len(d.Domains) > 0 { 356 if len(d.Domains) > 0 {
338 fmt.Fprintf(w, "pages domains: %s\n", strings.Join(d.Domains, ", ")) 357 fmt.Fprintf(w, "pages domains: %s\n", strings.Join(d.Domains, ", "))
339 } 358 }
359 if d.ForkOf != "" {
360 fmt.Fprintf(w, "fork of: %s\n", d.ForkOf)
361 }
362 if d.Watch != "" {
363 fmt.Fprintf(w, "watch: %s\n", d.Watch)
364 }
365 if d.Bookmarked {
366 fmt.Fprintln(w, "bookmarked")
367 }
340 for _, m := range d.Mirrors { 368 for _, m := range d.Mirrors {
341 status := "ok" 369 status := "ok"
342 if m.Pending { 370 if m.Pending {