internal/control/repo.go

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

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