internal/control/repo.go

794702c58d07759975699235f4408486305f8008
gitbay/internal/control/repo.go history · blame · raw

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}