internal/control/repo.go
791 lines · 27426 bytes
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11
12 "gitbay.org/gitbay/internal/gitutil"
13 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// RepoDir returns the on-disk path for a repository.
19func RepoDir(root, owner, name string) string {
20 return filepath.Join(root, "repos", owner, name+".git")
21}
22
23// HooksDir is the shared core.hooksPath directory.
24func HooksDir(root string) string { return filepath.Join(root, "hooks") }
25
26func init() {
27 register(Command{Path: []string{"repo", "create"},
28 Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate})
29 register(Command{Path: []string{"repo", "list"},
30 Summary: "list repositories you own or can access", ReadOnly: true, Run: runRepoList})
31 register(Command{Path: []string{"repo", "show"},
32 Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow})
33 register(Command{Path: []string{"repo", "transfer"},
34 Summary: "move a repository to another owner: repo transfer <owner/name> <new-owner> (clone URLs change)", Run: runRepoTransfer})
35 register(Command{Path: []string{"repo", "delete"},
36 Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete})
37 register(Command{Path: []string{"repo", "access", "grant"},
38 Summary: "grant access: repo access grant <owner/name> <user> read|write|admin", Run: runAccessGrant})
39 register(Command{Path: []string{"repo", "access", "revoke"},
40 Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke})
41 register(Command{Path: []string{"repo", "access", "list"},
42 Summary: "list access grants: repo access list <owner/name>", ReadOnly: true, Run: runAccessList})
43 register(Command{Path: []string{"repo", "settings", "show"},
44 Summary: "show settings: repo settings show <owner/name>", ReadOnly: true, Run: runSettingsShow})
45 register(Command{Path: []string{"repo", "settings", "protect"},
46 Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
47 register(Command{Path: []string{"repo", "settings", "unprotect"},
48 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
49 register(Command{Path: []string{"repo", "settings", "description"},
50 Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription})
51 register(Command{Path: []string{"repo", "settings", "git-daemon"},
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 register(Command{Path: []string{"repo", "search"},
64 Summary: "find repositories by name, description, or topic: repo search <query>", ReadOnly: true, Run: runRepoSearch})
65 register(Command{Path: []string{"repo", "grep"},
66 Summary: "search file contents: repo grep <owner/name> <query> [--ref <ref>]", ReadOnly: true, Run: runRepoGrep})
67 register(Command{Path: []string{"repo", "pin"},
68 Summary: "pin a repository to your dashboard: repo pin <owner/name>", Run: runRepoPin})
69 register(Command{Path: []string{"repo", "unpin"},
70 Summary: "unpin a repository: repo unpin <owner/name>", Run: runRepoUnpin})
71}
72
73const (
74 minQueryLen = 2
75 maxQueryLen = 200
76 maxGrepMatches = 200
77)
78
79func validQuery(q string) error {
80 if len(q) < minQueryLen || len(q) > maxQueryLen {
81 return fmt.Errorf("query must be %d to %d characters", minQueryLen, maxQueryLen)
82 }
83 return nil
84}
85
86// refuseArchived blocks content writes (pushes are refused in the transport
87// layer) on archived repositories. Settings, access, and lifecycle commands
88// stay available so an archived repo can be managed and unarchived.
89func refuseArchived(c *Ctx, repo store.Repo) int {
90 if repo.Settings.Archived {
91 return c.fail(protocol.ExitDenied, "%s is archived and read-only", repo.Path())
92 }
93 return -1
94}
95
96// resolveRepo loads a repo and checks the given permission for c.User.
97func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
98 repo, err := c.Store.RepoByPath(path)
99 if err != nil {
100 if errors.Is(err, store.ErrNotFound) {
101 // Same message whether it doesn't exist or is invisible.
102 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
103 }
104 return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
105 }
106 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
107 if err != nil {
108 return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
109 }
110 if !check(c.User, repo, grant) {
111 if !policy.CanRead(c.User, repo, grant) {
112 // Invisible repos 404, per the enumeration rule.
113 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
114 }
115 return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
116 }
117 return repo, -1
118}
119
120func runRepoCreate(c *Ctx, args []string) int {
121 visibility := "public"
122 var path, description string
123 for i := 0; i < len(args); i++ {
124 switch args[i] {
125 case "--private":
126 visibility = "private"
127 case "--description":
128 if i+1 >= len(args) {
129 return c.fail(protocol.ExitUsage, "--description requires a value")
130 }
131 description = args[i+1]
132 i++
133 default:
134 if path != "" {
135 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private] [--description <text>]")
136 }
137 path = args[i]
138 }
139 }
140 owner, name, ok := strings.Cut(path, "/")
141 if !ok {
142 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
143 }
144 if err := policyValidateRepoName(name); err != nil {
145 return c.fail(protocol.ExitUsage, "%v", err)
146 }
147 ownerKind, ownerID := "user", c.User.ID
148 if owner != c.User.Username {
149 org, err := c.Store.OrgByName(owner)
150 if err != nil {
151 return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
152 }
153 role, err := c.Store.OrgRole(org.ID, c.User.ID)
154 if err != nil {
155 return c.fail(protocol.ExitFailure, "%v", err)
156 }
157 if role != "admin" {
158 return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
159 }
160 ownerKind, ownerID = "org", org.ID
161 }
162 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
163 if err != nil {
164 return c.fail(protocol.ExitFailure, "%v", err)
165 }
166 dir := RepoDir(c.Cfg.Server.Root, owner, name)
167 if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
168 c.Store.DeleteRepo(id)
169 return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
170 }
171 if description != "" {
172 if err := gitutil.WriteDescription(dir, description); err != nil {
173 return c.fail(protocol.ExitFailure, "writing description: %v", err)
174 }
175 }
176 type out struct {
177 Path string `json:"path"`
178 Visibility string `json:"visibility"`
179 SSHURL string `json:"ssh_url"`
180 }
181 d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
182 return c.emit(d, func(w io.Writer) {
183 fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
184 })
185}
186
187func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
188
189func hostOf(siteURL string) string {
190 s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
191 return strings.TrimSuffix(s, "/")
192}
193
194func runRepoList(c *Ctx, args []string) int {
195 repos, err := c.Store.ListReposForUser(c.User.ID)
196 if err != nil {
197 return c.fail(protocol.ExitFailure, "%v", err)
198 }
199 type out struct {
200 Path string `json:"path"`
201 Visibility string `json:"visibility"`
202 Description string `json:"description,omitempty"`
203 Archived bool `json:"archived,omitempty"`
204 }
205 var ds []out
206 for _, r := range repos {
207 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
208 ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
209 }
210 return c.emit(ds, func(w io.Writer) {
211 for _, d := range ds {
212 mark := ""
213 if d.Archived {
214 mark = "\t[archived]"
215 }
216 fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark)
217 }
218 })
219}
220
221func runRepoShow(c *Ctx, args []string) int {
222 if len(args) != 1 {
223 return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
224 }
225 repo, code := resolveRepo(c, args[0], policy.CanRead)
226 if code >= 0 {
227 return code
228 }
229 type mirrorOut struct {
230 Direction string `json:"direction"`
231 URL string `json:"url"`
232 Pending bool `json:"pending"`
233 LastSync string `json:"last_sync,omitempty"`
234 LastError string `json:"last_error,omitempty"`
235 }
236 type out struct {
237 Path string `json:"path"`
238 Description string `json:"description,omitempty"`
239 Visibility string `json:"visibility"`
240 DefaultBranch string `json:"default_branch"`
241 ProtectedBranches []string `json:"protected_branches,omitempty"`
242 Archived bool `json:"archived,omitempty"`
243 Topics []string `json:"topics,omitempty"`
244 Mirrors []mirrorOut `json:"mirrors,omitempty"`
245 }
246 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
247 topics, err := c.Store.ListTopics(repo.ID)
248 if err != nil {
249 return c.fail(protocol.ExitFailure, "%v", err)
250 }
251 d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches,
252 repo.Settings.Archived, topics, nil}
253 // Mirror status is admin-only, like repo mirror list. The token never
254 // leaves the server.
255 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
256 ms, err := c.Store.ListMirrors(repo.ID)
257 if err != nil {
258 return c.fail(protocol.ExitFailure, "%v", err)
259 }
260 for _, m := range ms {
261 d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
262 }
263 }
264 return c.emit(d, func(w io.Writer) {
265 line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch)
266 if d.Archived {
267 line += "\t[archived]"
268 }
269 fmt.Fprintln(w, line)
270 if d.Description != "" {
271 fmt.Fprintf(w, "%s\n", d.Description)
272 }
273 if len(d.Topics) > 0 {
274 fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
275 }
276 if len(d.ProtectedBranches) > 0 {
277 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
278 }
279 for _, m := range d.Mirrors {
280 status := "ok"
281 if m.Pending {
282 status = "pending"
283 }
284 if m.LastError != "" {
285 status = "error: " + m.LastError
286 }
287 fmt.Fprintf(w, "mirror: %s %s\tlast %s\t%s\n", m.Direction, m.URL, orDash(m.LastSync), status)
288 }
289 })
290}
291
292func runRepoTransfer(c *Ctx, args []string) int {
293 if len(args) != 2 {
294 return c.fail(protocol.ExitUsage, "usage: repo transfer <owner/name> <new-owner>")
295 }
296 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
297 if code >= 0 {
298 return code
299 }
300 newOwner := args[1]
301 if newOwner == repo.OwnerName {
302 return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
303 }
304
305 // Target: yourself, or an org you admin — same rule as repo create.
306 newKind, newID := "", int64(0)
307 if newOwner == c.User.Username {
308 newKind, newID = "user", c.User.ID
309 } else if org, err := c.Store.OrgByName(newOwner); err == nil {
310 role, err := c.Store.OrgRole(org.ID, c.User.ID)
311 if err != nil {
312 return c.fail(protocol.ExitFailure, "%v", err)
313 }
314 if role != "admin" {
315 return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
316 }
317 newKind, newID = "org", org.ID
318 } else {
319 return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
320 }
321
322 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
323 newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
324 if _, err := os.Stat(newDir); err == nil {
325 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
326 }
327 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
328 return c.fail(protocol.ExitUsage, "%v", err)
329 }
330 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
331 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
332 return c.fail(protocol.ExitFailure, "%v", err)
333 }
334 if err := os.Rename(oldDir, newDir); err != nil {
335 // Keep name and disk consistent: revert the database change.
336 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
337 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
338 }
339 // The wiki companion follows its repo.
340 oldWiki := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")
341 if _, err := os.Stat(oldWiki); err == nil {
342 os.Rename(oldWiki, RepoDir(c.Cfg.Server.Root, newOwner, repo.Name+".wiki"))
343 }
344 newPath := newOwner + "/" + repo.Name
345 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
346 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
347 })
348}
349
350func runRepoDelete(c *Ctx, args []string) int {
351 var path string
352 var yes bool
353 for _, a := range args {
354 if a == "--yes" {
355 yes = true
356 } else if path == "" {
357 path = a
358 } else {
359 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
360 }
361 }
362 if path == "" {
363 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
364 }
365 repo, code := resolveRepo(c, path, policy.CanAdmin)
366 if code >= 0 {
367 return code
368 }
369 if !yes {
370 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
371 }
372 // Open MRs sourced from this repo keep working (targets own the
373 // objects) but must show that the source is gone.
374 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
375 return c.fail(protocol.ExitFailure, "%v", err)
376 }
377 if err := c.Store.DeleteRepo(repo.ID); err != nil {
378 return c.fail(protocol.ExitFailure, "%v", err)
379 }
380 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
381 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
382 }
383 os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki"))
384 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
385 fmt.Fprintf(w, "deleted %s\n", repo.Path())
386 })
387}
388
389func runAccessGrant(c *Ctx, args []string) int {
390 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
391 return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
392 }
393 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
394 if code >= 0 {
395 return code
396 }
397 target, err := c.Store.UserByUsername(args[1])
398 if err != nil {
399 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
400 }
401 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
402 return c.fail(protocol.ExitFailure, "%v", err)
403 }
404 return c.emit(map[string]string{"granted": args[2], "user": target.Username},
405 func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
406}
407
408func runAccessRevoke(c *Ctx, args []string) int {
409 if len(args) != 2 {
410 return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
411 }
412 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
413 if code >= 0 {
414 return code
415 }
416 target, err := c.Store.UserByUsername(args[1])
417 if err != nil {
418 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
419 }
420 if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
421 if errors.Is(err, store.ErrNotFound) {
422 return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
423 }
424 return c.fail(protocol.ExitFailure, "%v", err)
425 }
426 return c.emit(map[string]string{"revoked": target.Username},
427 func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
428}
429
430func runAccessList(c *Ctx, args []string) int {
431 if len(args) != 1 {
432 return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
433 }
434 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
435 if code >= 0 {
436 return code
437 }
438 entries, err := c.Store.ListAccess(repo.ID)
439 if err != nil {
440 return c.fail(protocol.ExitFailure, "%v", err)
441 }
442 type out struct {
443 User string `json:"user"`
444 Role string `json:"role"`
445 }
446 var ds []out
447 for _, e := range entries {
448 ds = append(ds, out{e.Username, e.Role})
449 }
450 return c.emit(ds, func(w io.Writer) {
451 for _, d := range ds {
452 fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
453 }
454 })
455}
456
457func runSettingsShow(c *Ctx, args []string) int {
458 if len(args) != 1 {
459 return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
460 }
461 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
462 if code >= 0 {
463 return code
464 }
465 return c.emit(repo.Settings, func(w io.Writer) {
466 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
467 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
468 })
469}
470
471func runSetDescription(c *Ctx, args []string) int {
472 if len(args) != 2 {
473 return c.fail(protocol.ExitUsage, "usage: repo settings description <owner/name> <text>")
474 }
475 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
476 if code >= 0 {
477 return code
478 }
479 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
480 if err := gitutil.WriteDescription(dir, args[1]); err != nil {
481 return c.fail(protocol.ExitFailure, "%v", err)
482 }
483 return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
484 fmt.Fprintf(w, "description set on %s\n", repo.Path())
485 })
486}
487
488func runGitDaemon(c *Ctx, args []string) int {
489 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
490 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
491 }
492 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
493 if code >= 0 {
494 return code
495 }
496 on := args[1] == "on"
497 if on && repo.Visibility != "public" {
498 return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
499 }
500 if on && !c.Cfg.GitDaemon.Enabled {
501 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
502 }
503 s := repo.Settings
504 s.GitDaemon = on
505 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
506 return c.fail(protocol.ExitFailure, "%v", err)
507 }
508 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
509}
510
511func runArchive(c *Ctx, args []string) int { return setArchived(c, args, true) }
512func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
513
514func setArchived(c *Ctx, args []string, archived bool) int {
515 verb := "archive"
516 if !archived {
517 verb = "unarchive"
518 }
519 if len(args) != 1 {
520 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
521 }
522 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
523 if code >= 0 {
524 return code
525 }
526 if repo.Settings.Archived == archived {
527 return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
528 }
529 s := repo.Settings
530 s.Archived = archived
531 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
532 return c.fail(protocol.ExitFailure, "%v", err)
533 }
534 c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
535 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
536}
537
538func runTopicsList(c *Ctx, args []string) int {
539 if len(args) != 1 {
540 return c.fail(protocol.ExitUsage, "usage: repo topics <owner/name>")
541 }
542 repo, code := resolveRepo(c, args[0], policy.CanRead)
543 if code >= 0 {
544 return code
545 }
546 topics, err := c.Store.ListTopics(repo.ID)
547 if err != nil {
548 return c.fail(protocol.ExitFailure, "%v", err)
549 }
550 return c.emit(topics, func(w io.Writer) {
551 for _, t := range topics {
552 fmt.Fprintln(w, t)
553 }
554 })
555}
556
557func runTopicsAdd(c *Ctx, args []string) int { return editTopics(c, args, true) }
558func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) }
559
560func editTopics(c *Ctx, args []string, add bool) int {
561 verb := "add"
562 if !add {
563 verb = "remove"
564 }
565 if len(args) < 2 {
566 return c.fail(protocol.ExitUsage, "usage: repo topics %s <owner/name> <topic>...", verb)
567 }
568 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
569 if code >= 0 {
570 return code
571 }
572 topics := args[1:]
573 if add {
574 for _, t := range topics {
575 if err := policy.ValidateTopic(t); err != nil {
576 return c.fail(protocol.ExitUsage, "%v", err)
577 }
578 }
579 have, err := c.Store.ListTopics(repo.ID)
580 if err != nil {
581 return c.fail(protocol.ExitFailure, "%v", err)
582 }
583 added := 0
584 for _, t := range topics {
585 if !slices.Contains(have, t) {
586 added++
587 }
588 }
589 if len(have)+added > policy.MaxTopics {
590 return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics)
591 }
592 for _, t := range topics {
593 if err := c.Store.AddTopic(repo.ID, t); err != nil {
594 return c.fail(protocol.ExitFailure, "%v", err)
595 }
596 }
597 } else {
598 for _, t := range topics {
599 if err := c.Store.RemoveTopic(repo.ID, t); err != nil {
600 if errors.Is(err, store.ErrNotFound) {
601 return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t)
602 }
603 return c.fail(protocol.ExitFailure, "%v", err)
604 }
605 }
606 }
607 now, err := c.Store.ListTopics(repo.ID)
608 if err != nil {
609 return c.fail(protocol.ExitFailure, "%v", err)
610 }
611 return c.emit(now, func(w io.Writer) {
612 fmt.Fprintf(w, "topics on %s: %s\n", repo.Path(), strings.Join(now, ", "))
613 })
614}
615
616// runRepoSearch matches the query against name, owner/name, description,
617// and topics of every repository the caller can see.
618func runRepoSearch(c *Ctx, args []string) int {
619 if len(args) != 1 {
620 return c.fail(protocol.ExitUsage, "usage: repo search <query>")
621 }
622 if err := validQuery(args[0]); err != nil {
623 return c.fail(protocol.ExitUsage, "%v", err)
624 }
625 q := strings.ToLower(args[0])
626
627 public, err := c.Store.ListPublicRepos()
628 if err != nil {
629 return c.fail(protocol.ExitFailure, "%v", err)
630 }
631 own, err := c.Store.ListReposForUser(c.User.ID)
632 if err != nil {
633 return c.fail(protocol.ExitFailure, "%v", err)
634 }
635 seen := map[int64]bool{}
636 type out struct {
637 Path string `json:"path"`
638 Visibility string `json:"visibility"`
639 Description string `json:"description,omitempty"`
640 Topics []string `json:"topics,omitempty"`
641 }
642 var ds []out
643 for _, r := range append(public, own...) {
644 if seen[r.ID] {
645 continue
646 }
647 seen[r.ID] = true
648 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
649 topics, _ := c.Store.ListTopics(r.ID)
650 if !matchesRepo(q, r, desc, topics) {
651 continue
652 }
653 ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
654 }
655 return c.emit(ds, func(w io.Writer) {
656 for _, d := range ds {
657 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description)
658 }
659 })
660}
661
662func matchesRepo(q string, r store.Repo, desc string, topics []string) bool {
663 if strings.Contains(strings.ToLower(r.Path()), q) ||
664 strings.Contains(strings.ToLower(desc), q) {
665 return true
666 }
667 for _, t := range topics {
668 if strings.Contains(t, q) {
669 return true
670 }
671 }
672 return false
673}
674
675func runRepoGrep(c *Ctx, args []string) int {
676 var path, query, ref string
677 for i := 0; i < len(args); i++ {
678 switch args[i] {
679 case "--ref":
680 if i+1 >= len(args) {
681 return c.fail(protocol.ExitUsage, "--ref requires a value")
682 }
683 ref = args[i+1]
684 i++
685 default:
686 if path == "" {
687 path = args[i]
688 } else if query == "" {
689 query = args[i]
690 } else {
691 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
692 }
693 }
694 }
695 if path == "" || query == "" {
696 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
697 }
698 if err := validQuery(query); err != nil {
699 return c.fail(protocol.ExitUsage, "%v", err)
700 }
701 repo, code := resolveRepo(c, path, policy.CanRead)
702 if code >= 0 {
703 return code
704 }
705 if ref == "" {
706 ref = repo.DefaultBranch
707 }
708 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
709 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
710 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
711 }
712 matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches)
713 if err != nil {
714 return c.fail(protocol.ExitFailure, "%v", err)
715 }
716 type out struct {
717 Path string `json:"path"`
718 Line int `json:"line"`
719 Text string `json:"text"`
720 }
721 var ds []out
722 for _, m := range matches {
723 ds = append(ds, out{m.Path, m.Line, m.Text})
724 }
725 return c.emit(ds, func(w io.Writer) {
726 for _, d := range ds {
727 fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text)
728 }
729 })
730}
731
732func runRepoPin(c *Ctx, args []string) int { return setPinned(c, args, true) }
733func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) }
734
735func setPinned(c *Ctx, args []string, pin bool) int {
736 verb := "pin"
737 if !pin {
738 verb = "unpin"
739 }
740 if len(args) != 1 {
741 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
742 }
743 repo, code := resolveRepo(c, args[0], policy.CanRead)
744 if code >= 0 {
745 return code
746 }
747 if pin {
748 if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil {
749 return c.fail(protocol.ExitFailure, "%v", err)
750 }
751 } else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil {
752 if errors.Is(err, store.ErrNotFound) {
753 return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path())
754 }
755 return c.fail(protocol.ExitFailure, "%v", err)
756 }
757 return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) {
758 fmt.Fprintf(w, "%sned %s\n", verb, repo.Path())
759 })
760}
761
762func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
763func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
764
765func setProtect(c *Ctx, args []string, protect bool) int {
766 if len(args) != 2 {
767 return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
768 }
769 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
770 if code >= 0 {
771 return code
772 }
773 branch := args[1]
774 s := repo.Settings
775 has := slices.Contains(s.ProtectedBranches, branch)
776 if protect && !has {
777 s.ProtectedBranches = append(s.ProtectedBranches, branch)
778 slices.Sort(s.ProtectedBranches)
779 }
780 if !protect && has {
781 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
782 }
783 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
784 return c.fail(protocol.ExitFailure, "%v", err)
785 }
786 verb := "protected"
787 if !protect {
788 verb = "unprotected"
789 }
790 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
791}