internal/control/repo.go

6b01ef3788b21e3a4c027a2afe7e7a5bda9ca000
gitbay/internal/control/repo.go history · blame · raw

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