internal/control/repo.go

4f3bd4893bc9923a02b86ae2cbae1fb17e019d2c
gitbay/internal/control/repo.go history · blame · raw

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}