A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit d298215429

d298215429fa4946ce82afebe7364ad178725e7b

parent: 590c75d99c

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T17:01:25Z

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.
cmd/gitbay/main.go +7
@@ -216,6 +216,8 @@ func repoCmd() *cobra.Command {
216216 pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}),
217217 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
218218 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}),
219221 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone),
220222 importCmd(),
221223 group("deploy-key", "repository-bound CI keys",
@@ -223,6 +225,11 @@ func repoCmd() *cobra.Command {
223225 pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}),
224226 pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}),
225227 ),
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 ),
226233 group("access", "manage access grants",
227234 pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}),
228235 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 {
6969 if code >= 0 {
7070 return code
7171 }
72 if code := refuseArchived(c, repo); code >= 0 {
73 return code
74 }
7275 if replyTo == 0 && (path == "" || line == 0) {
7376 return c.fail(protocol.ExitUsage, "a new thread needs --path and --line (or reply to one with --reply <id>)")
7477 }
@@ -203,6 +206,9 @@ func setThreadResolved(c *Ctx, args []string, resolved bool) int {
203206 if code >= 0 {
204207 return code
205208 }
209 if code := refuseArchived(c, repo); code >= 0 {
210 return code
211 }
206212 threadID, err := strconv.ParseInt(args[2], 10, 64)
207213 if err != nil {
208214 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 {
130130 if code >= 0 {
131131 return code
132132 }
133 if code := refuseArchived(c, repo); code >= 0 {
134 return code
135 }
133136 b, err := bodyFrom(c, body, file)
134137 if err != nil {
135138 return c.fail(protocol.ExitUsage, "%v", err)
@@ -256,6 +259,9 @@ func runIssueComment(c *Ctx, args []string) int {
256259 if code >= 0 {
257260 return code
258261 }
262 if code := refuseArchived(c, repo); code >= 0 {
263 return code
264 }
259265 body, err := bodyFrom(c, message, file)
260266 if err != nil {
261267 return c.fail(protocol.ExitUsage, "%v", err)
@@ -282,6 +288,9 @@ func setIssueState(c *Ctx, args []string, state string) int {
282288 if code >= 0 {
283289 return code
284290 }
291 if code := refuseArchived(c, repo); code >= 0 {
292 return code
293 }
285294 if len(args) != 2 {
286295 return c.fail(protocol.ExitUsage, "usage: issue %s <owner/name> <n>", state)
287296 }
@@ -348,6 +357,9 @@ func runIssueLabel(c *Ctx, args []string) int {
348357 if code >= 0 {
349358 return code
350359 }
360 if code := refuseArchived(c, repo); code >= 0 {
361 return code
362 }
351363 for _, l := range adds {
352364 if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil {
353365 return c.fail(protocol.ExitFailure, "%v", err)
@@ -382,6 +394,9 @@ func runIssueAssign(c *Ctx, args []string) int {
382394 if code >= 0 {
383395 return code
384396 }
397 if code := refuseArchived(c, repo); code >= 0 {
398 return code
399 }
385400 resolve := func(name string) (store.User, int) {
386401 u, err := c.Store.UserByUsername(name)
387402 if errors.Is(err, store.ErrNotFound) {
internal/control/mr.go +15
@@ -241,6 +241,9 @@ func runMRCreate(c *Ctx, args []string) int {
241241 if code >= 0 {
242242 return code
243243 }
244 if code := refuseArchived(c, repo); code >= 0 {
245 return code
246 }
244247 if target == "" {
245248 target = repo.DefaultBranch
246249 }
@@ -488,6 +491,9 @@ func runMRComment(c *Ctx, args []string) int {
488491 if code >= 0 {
489492 return code
490493 }
494 if code := refuseArchived(c, repo); code >= 0 {
495 return code
496 }
491497 body, err := bodyFrom(c, message, file)
492498 if err != nil {
493499 return c.fail(protocol.ExitUsage, "%v", err)
@@ -529,6 +535,9 @@ func runMRReview(c *Ctx, args []string) int {
529535 if code >= 0 {
530536 return code
531537 }
538 if code := refuseArchived(c, repo); code >= 0 {
539 return code
540 }
532541 if mr.State != "open" {
533542 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
534543 }
@@ -566,6 +575,9 @@ func runMRMerge(c *Ctx, args []string) int {
566575 if code >= 0 {
567576 return code
568577 }
578 if code := refuseArchived(c, repo); code >= 0 {
579 return code
580 }
569581 if mr.State != "open" && mr.State != "source_gone" {
570582 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
571583 }
@@ -926,6 +938,9 @@ func runMRClose(c *Ctx, args []string) int {
926938 if code >= 0 {
927939 return code
928940 }
941 if code := refuseArchived(c, repo); code >= 0 {
942 return code
943 }
929944 if len(args) != 2 {
930945 return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
931946 }
internal/control/repo.go +150 −6
@@ -50,6 +50,26 @@ func init() {
5050 Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription})
5151 register(Command{Path: []string{"repo", "settings", "git-daemon"},
5252 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
5373 }
5474
5575 // resolveRepo loads a repo and checks the given permission for c.User.
@@ -159,15 +179,20 @@ func runRepoList(c *Ctx, args []string) int {
159179 Path string `json:"path"`
160180 Visibility string `json:"visibility"`
161181 Description string `json:"description,omitempty"`
182 Archived bool `json:"archived,omitempty"`
162183 }
163184 var ds []out
164185 for _, r := range repos {
165186 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})
167188 }
168189 return c.emit(ds, func(w io.Writer) {
169190 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)
171196 }
172197 })
173198 }
@@ -186,14 +211,28 @@ func runRepoShow(c *Ctx, args []string) int {
186211 Visibility string `json:"visibility"`
187212 DefaultBranch string `json:"default_branch"`
188213 ProtectedBranches []string `json:"protected_branches,omitempty"`
214 Archived bool `json:"archived,omitempty"`
215 Topics []string `json:"topics,omitempty"`
189216 }
190217 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}
192224 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)
194230 if d.Description != "" {
195231 fmt.Fprintf(w, "%s\n", d.Description)
196232 }
233 if len(d.Topics) > 0 {
234 fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
235 }
197236 if len(d.ProtectedBranches) > 0 {
198237 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
199238 }
@@ -368,8 +407,8 @@ func runSettingsShow(c *Ctx, args []string) int {
368407 return code
369408 }
370409 return c.emit(repo.Settings, func(w io.Writer) {
371 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n",
372 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon)
410 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
411 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
373412 })
374413 }
375414
@@ -413,6 +452,111 @@ func runGitDaemon(c *Ctx, args []string) int {
413452 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
414453 }
415454
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
416560 func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
417561 func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
418562
internal/control/status.go +3
@@ -64,6 +64,9 @@ func runStatusSet(c *Ctx, args []string) int {
6464 if code >= 0 {
6565 return code
6666 }
67 if code := refuseArchived(c, repo); code >= 0 {
68 return code
69 }
6770 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
6871 full, err := gitutil.ResolveRef(dir, sha)
6972 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
298298 return protocol.ExitDenied
299299 }
300300 }
301 if write && repo.Settings.Archived {
302 fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path())
303 return protocol.ExitDenied
304 }
301305
302306 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
303307 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 {
2727 RequireApprovals int `json:"require_approvals,omitempty"`
2828 RequireResolved bool `json:"require_resolved,omitempty"`
2929 GitDaemon bool `json:"git_daemon,omitempty"`
30 Archived bool `json:"archived,omitempty"`
3031 }
3132
3233 // 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}