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