Commit d298215429

d298215429fa4946ce82afebe7364ad178725e7b

parent: 590c75d99c

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 17:01 UTC

Add archived repositories and topics (#23)

repo archive/unarchive (admin) makes a repository read-only: pushes are
refused in the SSH transport and content writes (issues, comments, MRs,
reviews, merges, diff threads, statuses) are refused in the handlers,
while browsing, cloning, settings, access, transfer, and delete keep
working. Archived state lives in repo settings and shows in repo
show/list and settings show.

Topics are free-form tags in a new repo_topics table (migration 0011):
repo topics [list|add|remove], lowercase [a-z0-9-] up to 35 chars, at
most 20 per repo, surfaced in repo show. Web rendering of both follows
with the design revamp.

Layout: unified · split

cmd/gitbay/main.go +7
@@ -216,6 +216,8 @@ func repoCmd() *cobra.Command {
216 pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}), 216 pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}),
217 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}), 217 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
218 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), 218 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
219 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
220 pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}),
219 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), 221 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone),
220 importCmd(), 222 importCmd(),
221 group("deploy-key", "repository-bound CI keys", 223 group("deploy-key", "repository-bound CI keys",
@@ -223,6 +225,11 @@ func repoCmd() *cobra.Command {
223 pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}), 225 pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}),
224 pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}), 226 pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}),
225 ), 227 ),
228 group("topics", "free-form repository tags",
229 pass("list", "list topics", passOpts{server: []string{"repo", "topics"}, needsRepo: true}),
230 pass("add", "add topics: <topic>...", passOpts{server: []string{"repo", "topics", "add"}, needsRepo: true}),
231 pass("remove", "remove topics: <topic>...", passOpts{server: []string{"repo", "topics", "remove"}, needsRepo: true}),
232 ),
226 group("access", "manage access grants", 233 group("access", "manage access grants",
227 pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}), 234 pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}),
228 pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}), 235 pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}),
e2e/archive_test.go added +134
@@ -0,0 +1,134 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func TestArchiveAndTopics(t *testing.T) {
11 inst := startInstance(t)
12 aliceKey := inst.newKey(t, "alice")
13 bobKey := inst.newKey(t, "bob")
14 inst.admin(t, "admin", "user", "create", "alice",
15 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
16 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
17
18 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
19 t.Fatalf("repo create: %s", errOut)
20 }
21 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
22 t.Fatal("grant failed")
23 }
24 work := t.TempDir()
25 env := inst.gitEnv(aliceKey)
26 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
27 dir := filepath.Join(work, "w")
28 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
29 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
30 mustGit(t, dir, env, "add", ".")
31 mustGit(t, dir, env, "commit", "-q", "-m", "base")
32 mustGit(t, dir, env, "push", "-q", "origin", "main")
33
34 // Topics: admin-only edits, validation, idempotent add, listing.
35 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "topics", "add", "alice/app", "go"); code != 4 {
36 t.Fatalf("non-admin added topics: exit %d, %s", code, errOut)
37 }
38 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/app", "Bad_Topic"); code != 2 || !strings.Contains(errOut, "invalid topic") {
39 t.Fatalf("bad topic accepted: exit %d, %s", code, errOut)
40 }
41 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/app", "go", "cli", "go"); code != 0 {
42 t.Fatalf("topics add: %s", errOut)
43 }
44 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "topics", "alice/app", "--json")
45 if !strings.Contains(out, `["cli","go"]`) {
46 t.Fatalf("topics list: %s", out)
47 }
48 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json")
49 if !strings.Contains(out, `"topics":["cli","go"]`) || strings.Contains(out, `"archived"`) {
50 t.Fatalf("repo show topics: %s", out)
51 }
52 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "topics", "remove", "alice/app", "cli"); code != 0 {
53 t.Fatal("topics remove failed")
54 }
55 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "topics", "remove", "alice/app", "cli"); code != 3 {
56 t.Fatal("removing an absent topic should be not-found")
57 }
58
59 // An MR and an issue that predate archiving.
60 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
61 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
62 mustGit(t, dir, env, "add", ".")
63 mustGit(t, dir, env, "commit", "-q", "-m", "feat")
64 mustGit(t, dir, env, "push", "-q", "origin", "feat")
65 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
66 "--source", "feat", "--target", "main", "--title", "'feat'"); code != 0 {
67 t.Fatalf("mr create: %s", errOut)
68 }
69 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'todo'"); code != 0 {
70 t.Fatalf("issue create: %s", errOut)
71 }
72
73 // Archive: admin-only, then everything content-mutating is refused.
74 if _, _, code := inst.ssh(t, bobKey, "", "repo", "archive", "alice/app"); code != 4 {
75 t.Fatal("non-admin archived the repo")
76 }
77 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "archive", "alice/app"); code != 0 {
78 t.Fatalf("archive: %s", errOut)
79 }
80 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "archive", "alice/app"); code != 2 {
81 t.Fatal("double archive not refused")
82 }
83
84 os.WriteFile(filepath.Join(dir, "c.txt"), []byte("c\n"), 0o644)
85 mustGit(t, dir, env, "checkout", "-q", "main")
86 mustGit(t, dir, env, "add", ".")
87 mustGit(t, dir, env, "commit", "-q", "-m", "more")
88 if out, code := gitRun(t, dir, env, "push", "origin", "main"); code == 0 || !strings.Contains(out, "archived and read-only") {
89 t.Fatalf("push to archived repo: exit %d\n%s", code, out)
90 }
91 for _, cmd := range [][]string{
92 {"issue", "create", "alice/app", "--title", "'x'"},
93 {"issue", "comment", "alice/app", "1", "--message", "'x'"},
94 {"issue", "close", "alice/app", "1"},
95 {"mr", "comment", "alice/app", "1", "--message", "'x'"},
96 {"mr", "review", "alice/app", "1", "--approve"},
97 {"mr", "merge", "alice/app", "1"},
98 {"mr", "close", "alice/app", "1"},
99 {"status", "set", "alice/app", "HEAD", "--context", "ci", "--state", "success"},
100 } {
101 if _, errOut, code := inst.ssh(t, bobKey, "", cmd...); code != 4 || !strings.Contains(errOut, "archived and read-only") {
102 t.Fatalf("%v on archived repo: exit %d, %s", cmd, code, errOut)
103 }
104 }
105
106 // Reading stays untouched: clone, listings, show, web.
107 work2 := t.TempDir()
108 mustGit(t, work2, env, "clone", "-q", inst.sshURL("alice/app"), "r")
109 if out, _, code := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app"); code != 0 || !strings.Contains(out, "todo") {
110 t.Fatalf("issue list on archived repo: %s", out)
111 }
112 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json")
113 if !strings.Contains(out, `"archived":true`) {
114 t.Fatalf("repo show archived flag: %s", out)
115 }
116 if status, _ := inst.get(t, "/alice/app"); status != 200 {
117 t.Fatalf("web browse on archived repo: %d", status)
118 }
119
120 // Settings and unarchive still work; writes come back.
121 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "protect", "alice/app", "main"); code != 0 {
122 t.Fatal("settings on archived repo should still work")
123 }
124 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "unprotect", "alice/app", "main"); code != 0 {
125 t.Fatal("unprotect failed")
126 }
127 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "unarchive", "alice/app"); code != 0 {
128 t.Fatalf("unarchive: %s", errOut)
129 }
130 mustGit(t, dir, env, "push", "-q", "origin", "main")
131 if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "comment", "alice/app", "1", "--message", "'back'"); code != 0 {
132 t.Fatalf("comment after unarchive: %s", errOut)
133 }
134}
internal/control/diffcomment.go +6
@@ -69,6 +69,9 @@ func runDiffComment(c *Ctx, args []string) int {
69 if code >= 0 { 69 if code >= 0 {
70 return code 70 return code
71 } 71 }
72 if code := refuseArchived(c, repo); code >= 0 {
73 return code
74 }
72 if replyTo == 0 && (path == "" || line == 0) { 75 if replyTo == 0 && (path == "" || line == 0) {
73 return c.fail(protocol.ExitUsage, "a new thread needs --path and --line (or reply to one with --reply <id>)") 76 return c.fail(protocol.ExitUsage, "a new thread needs --path and --line (or reply to one with --reply <id>)")
74 } 77 }
@@ -203,6 +206,9 @@ func setThreadResolved(c *Ctx, args []string, resolved bool) int {
203 if code >= 0 { 206 if code >= 0 {
204 return code 207 return code
205 } 208 }
209 if code := refuseArchived(c, repo); code >= 0 {
210 return code
211 }
206 threadID, err := strconv.ParseInt(args[2], 10, 64) 212 threadID, err := strconv.ParseInt(args[2], 10, 64)
207 if err != nil { 213 if err != nil {
208 return c.fail(protocol.ExitUsage, "bad thread id %q", args[2]) 214 return c.fail(protocol.ExitUsage, "bad thread id %q", args[2])
internal/control/issue.go +15
@@ -130,6 +130,9 @@ func runIssueCreate(c *Ctx, args []string) int {
130 if code >= 0 { 130 if code >= 0 {
131 return code 131 return code
132 } 132 }
133 if code := refuseArchived(c, repo); code >= 0 {
134 return code
135 }
133 b, err := bodyFrom(c, body, file) 136 b, err := bodyFrom(c, body, file)
134 if err != nil { 137 if err != nil {
135 return c.fail(protocol.ExitUsage, "%v", err) 138 return c.fail(protocol.ExitUsage, "%v", err)
@@ -256,6 +259,9 @@ func runIssueComment(c *Ctx, args []string) int {
256 if code >= 0 { 259 if code >= 0 {
257 return code 260 return code
258 } 261 }
262 if code := refuseArchived(c, repo); code >= 0 {
263 return code
264 }
259 body, err := bodyFrom(c, message, file) 265 body, err := bodyFrom(c, message, file)
260 if err != nil { 266 if err != nil {
261 return c.fail(protocol.ExitUsage, "%v", err) 267 return c.fail(protocol.ExitUsage, "%v", err)
@@ -282,6 +288,9 @@ func setIssueState(c *Ctx, args []string, state string) int {
282 if code >= 0 { 288 if code >= 0 {
283 return code 289 return code
284 } 290 }
291 if code := refuseArchived(c, repo); code >= 0 {
292 return code
293 }
285 if len(args) != 2 { 294 if len(args) != 2 {
286 return c.fail(protocol.ExitUsage, "usage: issue %s <owner/name> <n>", state) 295 return c.fail(protocol.ExitUsage, "usage: issue %s <owner/name> <n>", state)
287 } 296 }
@@ -348,6 +357,9 @@ func runIssueLabel(c *Ctx, args []string) int {
348 if code >= 0 { 357 if code >= 0 {
349 return code 358 return code
350 } 359 }
360 if code := refuseArchived(c, repo); code >= 0 {
361 return code
362 }
351 for _, l := range adds { 363 for _, l := range adds {
352 if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil { 364 if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil {
353 return c.fail(protocol.ExitFailure, "%v", err) 365 return c.fail(protocol.ExitFailure, "%v", err)
@@ -382,6 +394,9 @@ func runIssueAssign(c *Ctx, args []string) int {
382 if code >= 0 { 394 if code >= 0 {
383 return code 395 return code
384 } 396 }
397 if code := refuseArchived(c, repo); code >= 0 {
398 return code
399 }
385 resolve := func(name string) (store.User, int) { 400 resolve := func(name string) (store.User, int) {
386 u, err := c.Store.UserByUsername(name) 401 u, err := c.Store.UserByUsername(name)
387 if errors.Is(err, store.ErrNotFound) { 402 if errors.Is(err, store.ErrNotFound) {
internal/control/mr.go +15
@@ -241,6 +241,9 @@ func runMRCreate(c *Ctx, args []string) int {
241 if code >= 0 { 241 if code >= 0 {
242 return code 242 return code
243 } 243 }
244 if code := refuseArchived(c, repo); code >= 0 {
245 return code
246 }
244 if target == "" { 247 if target == "" {
245 target = repo.DefaultBranch 248 target = repo.DefaultBranch
246 } 249 }
@@ -488,6 +491,9 @@ func runMRComment(c *Ctx, args []string) int {
488 if code >= 0 { 491 if code >= 0 {
489 return code 492 return code
490 } 493 }
494 if code := refuseArchived(c, repo); code >= 0 {
495 return code
496 }
491 body, err := bodyFrom(c, message, file) 497 body, err := bodyFrom(c, message, file)
492 if err != nil { 498 if err != nil {
493 return c.fail(protocol.ExitUsage, "%v", err) 499 return c.fail(protocol.ExitUsage, "%v", err)
@@ -529,6 +535,9 @@ func runMRReview(c *Ctx, args []string) int {
529 if code >= 0 { 535 if code >= 0 {
530 return code 536 return code
531 } 537 }
538 if code := refuseArchived(c, repo); code >= 0 {
539 return code
540 }
532 if mr.State != "open" { 541 if mr.State != "open" {
533 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State) 542 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
534 } 543 }
@@ -566,6 +575,9 @@ func runMRMerge(c *Ctx, args []string) int {
566 if code >= 0 { 575 if code >= 0 {
567 return code 576 return code
568 } 577 }
578 if code := refuseArchived(c, repo); code >= 0 {
579 return code
580 }
569 if mr.State != "open" && mr.State != "source_gone" { 581 if mr.State != "open" && mr.State != "source_gone" {
570 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State) 582 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
571 } 583 }
@@ -926,6 +938,9 @@ func runMRClose(c *Ctx, args []string) int {
926 if code >= 0 { 938 if code >= 0 {
927 return code 939 return code
928 } 940 }
941 if code := refuseArchived(c, repo); code >= 0 {
942 return code
943 }
929 if len(args) != 2 { 944 if len(args) != 2 {
930 return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>") 945 return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
931 } 946 }
internal/control/repo.go +150 −6
@@ -50,6 +50,26 @@ func init() {
50 Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription}) 50 Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription})
51 register(Command{Path: []string{"repo", "settings", "git-daemon"}, 51 register(Command{Path: []string{"repo", "settings", "git-daemon"},
52 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon}) 52 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
53 register(Command{Path: []string{"repo", "archive"},
54 Summary: "archive a repository (read-only: pushes and issue/MR writes refused): repo archive <owner/name>", Run: runArchive})
55 register(Command{Path: []string{"repo", "unarchive"},
56 Summary: "unarchive a repository: repo unarchive <owner/name>", Run: runUnarchive})
57 register(Command{Path: []string{"repo", "topics"},
58 Summary: "list topics: repo topics <owner/name>", ReadOnly: true, Run: runTopicsList})
59 register(Command{Path: []string{"repo", "topics", "add"},
60 Summary: "add topics: repo topics add <owner/name> <topic>...", Run: runTopicsAdd})
61 register(Command{Path: []string{"repo", "topics", "remove"},
62 Summary: "remove topics: repo topics remove <owner/name> <topic>...", Run: runTopicsRemove})
63}
64
65// refuseArchived blocks content writes (pushes are refused in the transport
66// layer) on archived repositories. Settings, access, and lifecycle commands
67// stay available so an archived repo can be managed and unarchived.
68func refuseArchived(c *Ctx, repo store.Repo) int {
69 if repo.Settings.Archived {
70 return c.fail(protocol.ExitDenied, "%s is archived and read-only", repo.Path())
71 }
72 return -1
53} 73}
54 74
55// resolveRepo loads a repo and checks the given permission for c.User. 75// resolveRepo loads a repo and checks the given permission for c.User.
@@ -159,15 +179,20 @@ func runRepoList(c *Ctx, args []string) int {
159 Path string `json:"path"` 179 Path string `json:"path"`
160 Visibility string `json:"visibility"` 180 Visibility string `json:"visibility"`
161 Description string `json:"description,omitempty"` 181 Description string `json:"description,omitempty"`
182 Archived bool `json:"archived,omitempty"`
162 } 183 }
163 var ds []out 184 var ds []out
164 for _, r := range repos { 185 for _, r := range repos {
165 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) 186 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
166 ds = append(ds, out{r.Path(), r.Visibility, desc}) 187 ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
167 } 188 }
168 return c.emit(ds, func(w io.Writer) { 189 return c.emit(ds, func(w io.Writer) {
169 for _, d := range ds { 190 for _, d := range ds {
170 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description) 191 mark := ""
192 if d.Archived {
193 mark = "\t[archived]"
194 }
195 fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark)
171 } 196 }
172 }) 197 })
173} 198}
@@ -186,14 +211,28 @@ func runRepoShow(c *Ctx, args []string) int {
186 Visibility string `json:"visibility"` 211 Visibility string `json:"visibility"`
187 DefaultBranch string `json:"default_branch"` 212 DefaultBranch string `json:"default_branch"`
188 ProtectedBranches []string `json:"protected_branches,omitempty"` 213 ProtectedBranches []string `json:"protected_branches,omitempty"`
214 Archived bool `json:"archived,omitempty"`
215 Topics []string `json:"topics,omitempty"`
189 } 216 }
190 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) 217 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
191 d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches} 218 topics, err := c.Store.ListTopics(repo.ID)
219 if err != nil {
220 return c.fail(protocol.ExitFailure, "%v", err)
221 }
222 d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches,
223 repo.Settings.Archived, topics}
192 return c.emit(d, func(w io.Writer) { 224 return c.emit(d, func(w io.Writer) {
193 fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch) 225 line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch)
226 if d.Archived {
227 line += "\t[archived]"
228 }
229 fmt.Fprintln(w, line)
194 if d.Description != "" { 230 if d.Description != "" {
195 fmt.Fprintf(w, "%s\n", d.Description) 231 fmt.Fprintf(w, "%s\n", d.Description)
196 } 232 }
233 if len(d.Topics) > 0 {
234 fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
235 }
197 if len(d.ProtectedBranches) > 0 { 236 if len(d.ProtectedBranches) > 0 {
198 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", ")) 237 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
199 } 238 }
@@ -368,8 +407,8 @@ func runSettingsShow(c *Ctx, args []string) int {
368 return code 407 return code
369 } 408 }
370 return c.emit(repo.Settings, func(w io.Writer) { 409 return c.emit(repo.Settings, func(w io.Writer) {
371 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n", 410 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
372 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon) 411 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
373 }) 412 })
374} 413}
375 414
@@ -413,6 +452,111 @@ func runGitDaemon(c *Ctx, args []string) int {
413 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) }) 452 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
414} 453}
415 454
455func runArchive(c *Ctx, args []string) int { return setArchived(c, args, true) }
456func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
457
458func setArchived(c *Ctx, args []string, archived bool) int {
459 verb := "archive"
460 if !archived {
461 verb = "unarchive"
462 }
463 if len(args) != 1 {
464 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
465 }
466 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
467 if code >= 0 {
468 return code
469 }
470 if repo.Settings.Archived == archived {
471 return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
472 }
473 s := repo.Settings
474 s.Archived = archived
475 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
476 return c.fail(protocol.ExitFailure, "%v", err)
477 }
478 c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
479 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
480}
481
482func runTopicsList(c *Ctx, args []string) int {
483 if len(args) != 1 {
484 return c.fail(protocol.ExitUsage, "usage: repo topics <owner/name>")
485 }
486 repo, code := resolveRepo(c, args[0], policy.CanRead)
487 if code >= 0 {
488 return code
489 }
490 topics, err := c.Store.ListTopics(repo.ID)
491 if err != nil {
492 return c.fail(protocol.ExitFailure, "%v", err)
493 }
494 return c.emit(topics, func(w io.Writer) {
495 for _, t := range topics {
496 fmt.Fprintln(w, t)
497 }
498 })
499}
500
501func runTopicsAdd(c *Ctx, args []string) int { return editTopics(c, args, true) }
502func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) }
503
504func editTopics(c *Ctx, args []string, add bool) int {
505 verb := "add"
506 if !add {
507 verb = "remove"
508 }
509 if len(args) < 2 {
510 return c.fail(protocol.ExitUsage, "usage: repo topics %s <owner/name> <topic>...", verb)
511 }
512 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
513 if code >= 0 {
514 return code
515 }
516 topics := args[1:]
517 if add {
518 for _, t := range topics {
519 if err := policy.ValidateTopic(t); err != nil {
520 return c.fail(protocol.ExitUsage, "%v", err)
521 }
522 }
523 have, err := c.Store.ListTopics(repo.ID)
524 if err != nil {
525 return c.fail(protocol.ExitFailure, "%v", err)
526 }
527 added := 0
528 for _, t := range topics {
529 if !slices.Contains(have, t) {
530 added++
531 }
532 }
533 if len(have)+added > policy.MaxTopics {
534 return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics)
535 }
536 for _, t := range topics {
537 if err := c.Store.AddTopic(repo.ID, t); err != nil {
538 return c.fail(protocol.ExitFailure, "%v", err)
539 }
540 }
541 } else {
542 for _, t := range topics {
543 if err := c.Store.RemoveTopic(repo.ID, t); err != nil {
544 if errors.Is(err, store.ErrNotFound) {
545 return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t)
546 }
547 return c.fail(protocol.ExitFailure, "%v", err)
548 }
549 }
550 }
551 now, err := c.Store.ListTopics(repo.ID)
552 if err != nil {
553 return c.fail(protocol.ExitFailure, "%v", err)
554 }
555 return c.emit(now, func(w io.Writer) {
556 fmt.Fprintf(w, "topics on %s: %s\n", repo.Path(), strings.Join(now, ", "))
557 })
558}
559
416func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } 560func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
417func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } 561func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
418 562
internal/control/status.go +3
@@ -64,6 +64,9 @@ func runStatusSet(c *Ctx, args []string) int {
64 if code >= 0 { 64 if code >= 0 {
65 return code 65 return code
66 } 66 }
67 if code := refuseArchived(c, repo); code >= 0 {
68 return code
69 }
67 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) 70 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
68 full, err := gitutil.ResolveRef(dir, sha) 71 full, err := gitutil.ResolveRef(dir, sha)
69 if err != nil { 72 if err != nil {
internal/policy/topic.go added +20
@@ -0,0 +1,20 @@
1package policy
2
3import (
4 "fmt"
5 "regexp"
6)
7
8// MaxTopics caps topics per repository.
9const MaxTopics = 20
10
11var topicPat = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,34}$`)
12
13// ValidateTopic checks a repository topic: lowercase alphanumerics and
14// dashes, must start with an alphanumeric, max 35 chars.
15func ValidateTopic(topic string) error {
16 if !topicPat.MatchString(topic) {
17 return fmt.Errorf("invalid topic %q: lowercase letters, digits, and '-' only; must start with a letter or digit; max 35 chars", topic)
18 }
19 return nil
20}
internal/sshd/sshd.go +4
@@ -298,6 +298,10 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
298 return protocol.ExitDenied 298 return protocol.ExitDenied
299 } 299 }
300 } 300 }
301 if write && repo.Settings.Archived {
302 fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path())
303 return protocol.ExitDenied
304 }
301 305
302 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name) 306 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
303 env := []string{ 307 env := []string{
internal/store/migrations/0011_topics.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE repo_topics;
internal/store/migrations/0011_topics.up.sql added +6
@@ -0,0 +1,6 @@
1CREATE TABLE repo_topics (
2 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
3 topic TEXT NOT NULL,
4 PRIMARY KEY (repo_id, topic)
5);
6CREATE INDEX repo_topics_topic ON repo_topics(topic);
internal/store/repos.go +1
@@ -27,6 +27,7 @@ type RepoSettings struct {
27 RequireApprovals int `json:"require_approvals,omitempty"` 27 RequireApprovals int `json:"require_approvals,omitempty"`
28 RequireResolved bool `json:"require_resolved,omitempty"` 28 RequireResolved bool `json:"require_resolved,omitempty"`
29 GitDaemon bool `json:"git_daemon,omitempty"` 29 GitDaemon bool `json:"git_daemon,omitempty"`
30 Archived bool `json:"archived,omitempty"`
30} 31}
31 32
32// Path returns the canonical owner/name form. 33// Path returns the canonical owner/name form.
internal/store/topics.go added +38
@@ -0,0 +1,38 @@
1package store
2
3func (s *Store) ListTopics(repoID int64) ([]string, error) {
4 rows, err := s.DB.Query("SELECT topic FROM repo_topics WHERE repo_id = ? ORDER BY topic", repoID)
5 if err != nil {
6 return nil, err
7 }
8 defer rows.Close()
9 var out []string
10 for rows.Next() {
11 var t string
12 if err := rows.Scan(&t); err != nil {
13 return nil, err
14 }
15 out = append(out, t)
16 }
17 return out, rows.Err()
18}
19
20// AddTopic is idempotent: adding an existing topic is not an error.
21func (s *Store) AddTopic(repoID int64, topic string) error {
22 _, err := s.DB.Exec(
23 "INSERT INTO repo_topics (repo_id, topic) VALUES (?, ?) ON CONFLICT DO NOTHING",
24 repoID, topic)
25 return err
26}
27
28func (s *Store) RemoveTopic(repoID int64, topic string) error {
29 res, err := s.DB.Exec(
30 "DELETE FROM repo_topics WHERE repo_id = ? AND topic = ?", repoID, topic)
31 if err != nil {
32 return err
33 }
34 if n, _ := res.RowsAffected(); n == 0 {
35 return ErrNotFound
36 }
37 return nil
38}