internal/control/repo.go

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