internal/control/repo.go

c2d81105344db93058ba50f63e5e81c49abd4b7f
gitbay/internal/control/repo.go history · blame · raw

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