internal/control/repo.go

61564b7c32807deb349e28f2b5c6909cb4143870
gitbay/internal/control/repo.go history · blame · raw

961 lines · 34261 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", "diff"},
 94		Summary: "the patch between two refs, from their merge base",
 95		Usage:   "repo diff <owner/name> <base> <head>", ReadOnly: true, Run: runRepoDiff})
 96	register(Command{Path: []string{"repo", "pin"},
 97		Summary: "pin a repository to your dashboard",
 98		Usage:   "repo pin <owner/name>", Run: runRepoPin})
 99	register(Command{Path: []string{"repo", "unpin"},
100		Summary: "unpin a repository",
101		Usage:   "repo unpin <owner/name>", Run: runRepoUnpin})
102}
103
104const (
105	minQueryLen    = 2
106	maxQueryLen    = 200
107	maxGrepMatches = 200
108)
109
110func validQuery(q string) error {
111	if len(q) < minQueryLen || len(q) > maxQueryLen {
112		return fmt.Errorf("query must be %d to %d characters", minQueryLen, maxQueryLen)
113	}
114	return nil
115}
116
117// refuseArchived blocks content writes (pushes are refused in the transport
118// layer) on archived repositories. Settings, access, and lifecycle commands
119// stay available so an archived repo can be managed and unarchived.
120func refuseArchived(c *Ctx, repo store.Repo) int {
121	if repo.Settings.Archived {
122		return c.fail(protocol.ExitDenied, "%s is archived and read-only", repo.Path())
123	}
124	return -1
125}
126
127// resolveRepo loads a repo and checks the given permission for c.User.
128func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
129	repo, err := c.Store.RepoByPath(path)
130	if err != nil {
131		if errors.Is(err, store.ErrNotFound) {
132			// Same message whether it doesn't exist or is invisible.
133			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
134		}
135		return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
136	}
137	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
138	if err != nil {
139		return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
140	}
141	if !check(c.User, repo, grant) {
142		if !policy.CanRead(c.User, repo, grant) {
143			// Invisible repos 404, per the enumeration rule.
144			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
145		}
146		return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
147	}
148	return repo, -1
149}
150
151func runRepoCreate(c *Ctx, args []string) int {
152	f, err := parseFlags(args, flagSpec{Values: []string{"--description"}, Bools: []string{"--private"}, MaxPos: 1, Usage: "repo create <owner/name> [--private] [--description <text>]"})
153	if err != nil {
154		return c.fail(protocol.ExitUsage, "%v", err)
155	}
156	visibility, path, description := "public", f.pos(0), f.Value("--description")
157	if f.Has("--private") {
158		visibility = "private"
159	}
160	owner, name, ok := strings.Cut(path, "/")
161	if !ok {
162		return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
163	}
164	if err := policyValidateRepoName(name); err != nil {
165		return c.failErr(err)
166	}
167	ownerKind, ownerID := "user", c.User.ID
168	if owner != c.User.Username {
169		org, err := c.Store.OrgByName(owner)
170		if err != nil {
171			return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
172		}
173		role, err := c.Store.OrgRole(org.ID, c.User.ID)
174		if err != nil {
175			return c.fail(protocol.ExitFailure, "%v", err)
176		}
177		if role != "admin" {
178			return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
179		}
180		ownerKind, ownerID = "org", org.ID
181	}
182	repoCreateMu.Lock()
183	if ownerKind == "user" {
184		if code := checkRepoQuota(c); code >= 0 {
185			repoCreateMu.Unlock()
186			return code
187		}
188	}
189	id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
190	repoCreateMu.Unlock()
191	if err != nil {
192		return c.fail(protocol.ExitFailure, "%v", err)
193	}
194	dir := RepoDir(c.Cfg.Server.Root, owner, name)
195	if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
196		c.Store.DeleteRepo(id)
197		return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
198	}
199	if description != "" {
200		if err := gitutil.WriteDescription(dir, description); err != nil {
201			return c.fail(protocol.ExitFailure, "writing description: %v", err)
202		}
203	}
204	type out struct {
205		Path       string `json:"path"`
206		Visibility string `json:"visibility"`
207		SSHURL     string `json:"ssh_url"`
208	}
209	d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
210	return c.emit(d, func(w io.Writer) {
211		fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
212	})
213}
214
215func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
216
217func hostOf(siteURL string) string {
218	s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
219	return strings.TrimSuffix(s, "/")
220}
221
222func runRepoList(c *Ctx, args []string) int {
223	args, p, code := parsePageFlags(c, args, "repo", false)
224	if code >= 0 {
225		return code
226	}
227	if len(args) != 0 {
228		return c.fail(protocol.ExitUsage, "usage: repo list [--limit <n>] [--cursor <c>]")
229	}
230	repos, err := c.Store.ListReposForUser(c.User.ID, p.queryLimit(), p.key)
231	if err != nil {
232		return c.fail(protocol.ExitFailure, "%v", err)
233	}
234	repos, next := trimPage(p, repos, "repo", store.Repo.Path)
235	type out struct {
236		Path        string `json:"path"`
237		Visibility  string `json:"visibility"`
238		Description string `json:"description,omitempty"`
239		Archived    bool   `json:"archived,omitempty"`
240	}
241	var ds []out
242	for _, r := range repos {
243		desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
244		ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
245	}
246	return c.emitPage(p, ds, next, func(w io.Writer) {
247		for _, d := range ds {
248			mark := ""
249			if d.Archived {
250				mark = "\t[archived]"
251			}
252			fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark)
253		}
254	})
255}
256
257func runRepoShow(c *Ctx, args []string) int {
258	if len(args) != 1 {
259		return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
260	}
261	repo, code := resolveRepo(c, args[0], policy.CanRead)
262	if code >= 0 {
263		return code
264	}
265	type mirrorOut struct {
266		Direction string `json:"direction"`
267		URL       string `json:"url"`
268		Pending   bool   `json:"pending"`
269		LastSync  string `json:"last_sync,omitempty"`
270		LastError string `json:"last_error,omitempty"`
271	}
272	type out struct {
273		Path              string      `json:"path"`
274		Description       string      `json:"description,omitempty"`
275		Website           string      `json:"website,omitempty"`
276		Visibility        string      `json:"visibility"`
277		DefaultBranch     string      `json:"default_branch"`
278		ProtectedBranches []string    `json:"protected_branches,omitempty"`
279		Archived          bool        `json:"archived,omitempty"`
280		Topics            []string    `json:"topics,omitempty"`
281		Domains           []string    `json:"domains,omitempty"`
282		Mirrors           []mirrorOut `json:"mirrors,omitempty"`
283	}
284	desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
285	topics, err := c.Store.ListTopics(repo.ID)
286	if err != nil {
287		return c.fail(protocol.ExitFailure, "%v", err)
288	}
289	var domains []string
290	if ds, err := c.Store.ListPageDomains(repo.ID); err == nil {
291		for _, pd := range ds {
292			if pd.Verified() {
293				domains = append(domains, pd.Domain)
294			}
295		}
296	}
297	d := out{repo.Path(), desc, repo.Settings.Website, repo.Visibility, repo.DefaultBranch,
298		repo.Settings.ProtectedBranches, repo.Settings.Archived, topics, domains, nil}
299	// Mirror status is admin-only, like repo mirror list. The token never
300	// leaves the server.
301	if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
302		ms, err := c.Store.ListMirrors(repo.ID)
303		if err != nil {
304			return c.fail(protocol.ExitFailure, "%v", err)
305		}
306		for _, m := range ms {
307			d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
308		}
309	}
310	return c.emit(d, func(w io.Writer) {
311		line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch)
312		if d.Archived {
313			line += "\t[archived]"
314		}
315		fmt.Fprintln(w, line)
316		if d.Description != "" {
317			fmt.Fprintf(w, "%s\n", d.Description)
318		}
319		if d.Website != "" {
320			fmt.Fprintf(w, "website: %s\n", d.Website)
321		}
322		if len(d.Topics) > 0 {
323			fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
324		}
325		if len(d.ProtectedBranches) > 0 {
326			fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
327		}
328		if len(d.Domains) > 0 {
329			fmt.Fprintf(w, "pages domains: %s\n", strings.Join(d.Domains, ", "))
330		}
331		for _, m := range d.Mirrors {
332			status := "ok"
333			if m.Pending {
334				status = "pending"
335			}
336			if m.LastError != "" {
337				status = "error: " + m.LastError
338			}
339			fmt.Fprintf(w, "mirror: %s %s\tlast %s\t%s\n", m.Direction, m.URL, orDash(m.LastSync), status)
340		}
341	})
342}
343
344func runRepoTransfer(c *Ctx, args []string) int {
345	if len(args) != 2 {
346		return c.fail(protocol.ExitUsage, "usage: repo transfer <owner/name> <new-owner>")
347	}
348	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
349	if code >= 0 {
350		return code
351	}
352	newOwner := args[1]
353	if newOwner == repo.OwnerName {
354		return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
355	}
356
357	// Target: yourself, or an org you admin — same rule as repo create.
358	newKind, newID := "", int64(0)
359	if newOwner == c.User.Username {
360		newKind, newID = "user", c.User.ID
361	} else if org, err := c.Store.OrgByName(newOwner); err == nil {
362		role, err := c.Store.OrgRole(org.ID, c.User.ID)
363		if err != nil {
364			return c.fail(protocol.ExitFailure, "%v", err)
365		}
366		if role != "admin" {
367			return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
368		}
369		newKind, newID = "org", org.ID
370	} else {
371		return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
372	}
373
374	oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
375	newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
376	if _, err := os.Stat(newDir); err == nil {
377		return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
378	}
379	if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
380		return c.failErr(err)
381	}
382	if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
383		c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
384		return c.fail(protocol.ExitFailure, "%v", err)
385	}
386	if err := os.Rename(oldDir, newDir); err != nil {
387		// Keep name and disk consistent: revert the database change, and
388		// say so if even that fails, since the operator then has a row
389		// pointing at a directory that is not there.
390		if rerr := c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID); rerr != nil {
391			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())
392		}
393		return c.fail(protocol.ExitFailure, "moving repository: %v", err)
394	}
395	// The wiki companion follows its repo.
396	oldWiki := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")
397	if _, err := os.Stat(oldWiki); err == nil {
398		os.Rename(oldWiki, RepoDir(c.Cfg.Server.Root, newOwner, repo.Name+".wiki"))
399	}
400	newPath := newOwner + "/" + repo.Name
401	return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
402		fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
403	})
404}
405
406func runRepoDelete(c *Ctx, args []string) int {
407	var path string
408	var yes bool
409	for _, a := range args {
410		if a == "--yes" {
411			yes = true
412		} else if path == "" {
413			path = a
414		} else {
415			return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
416		}
417	}
418	if path == "" {
419		return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
420	}
421	repo, code := resolveRepo(c, path, policy.CanAdmin)
422	if code >= 0 {
423		return code
424	}
425	if !yes {
426		return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
427	}
428	return deleteRepo(c, repo)
429}
430
431// deleteRepo removes a repository the caller has already been cleared to
432// delete: the database row, then the directory and its wiki companion.
433//
434// There is deliberately no repo.deleted event. events.repo_id and
435// webhooks.repo_id both cascade from repos, so recording one would delete
436// it, and every webhook that could have subscribed, in the same
437// statement. A repository's deletion is not observable through its own
438// webhooks; an instance that needs to hear about it wants the audit log
439// (#112).
440func deleteRepo(c *Ctx, repo store.Repo) int {
441	// Open MRs sourced from this repo keep working (targets own the
442	// objects) but must show that the source is gone.
443	if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
444		return c.fail(protocol.ExitFailure, "%v", err)
445	}
446	if err := c.Store.DeleteRepo(repo.ID); err != nil {
447		return c.fail(protocol.ExitFailure, "%v", err)
448	}
449	if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
450		return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
451	}
452	os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki"))
453	return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
454		fmt.Fprintf(w, "deleted %s\n", repo.Path())
455	})
456}
457
458func runAccessGrant(c *Ctx, args []string) int {
459	if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
460		return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
461	}
462	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
463	if code >= 0 {
464		return code
465	}
466	target, err := c.Store.UserByUsername(args[1])
467	if err != nil {
468		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
469	}
470	if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
471		return c.fail(protocol.ExitFailure, "%v", err)
472	}
473	return c.emit(map[string]string{"granted": args[2], "user": target.Username},
474		func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
475}
476
477func runAccessRevoke(c *Ctx, args []string) int {
478	if len(args) != 2 {
479		return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
480	}
481	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
482	if code >= 0 {
483		return code
484	}
485	target, err := c.Store.UserByUsername(args[1])
486	if err != nil {
487		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
488	}
489	if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
490		if errors.Is(err, store.ErrNotFound) {
491			return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
492		}
493		return c.fail(protocol.ExitFailure, "%v", err)
494	}
495	return c.emit(map[string]string{"revoked": target.Username},
496		func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
497}
498
499func runAccessList(c *Ctx, args []string) int {
500	if len(args) != 1 {
501		return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
502	}
503	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
504	if code >= 0 {
505		return code
506	}
507	entries, err := c.Store.ListAccess(repo.ID)
508	if err != nil {
509		return c.fail(protocol.ExitFailure, "%v", err)
510	}
511	type out struct {
512		User string `json:"user"`
513		Role string `json:"role"`
514	}
515	var ds []out
516	for _, e := range entries {
517		ds = append(ds, out{e.Username, e.Role})
518	}
519	return c.emit(ds, func(w io.Writer) {
520		for _, d := range ds {
521			fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
522		}
523	})
524}
525
526func runSettingsShow(c *Ctx, args []string) int {
527	if len(args) != 1 {
528		return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
529	}
530	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
531	if code >= 0 {
532		return code
533	}
534	return c.emit(repo.Settings, func(w io.Writer) {
535		fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
536			strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
537	})
538}
539
540func runSetDescription(c *Ctx, args []string) int {
541	if len(args) != 2 {
542		return c.fail(protocol.ExitUsage, "usage: repo settings description <owner/name> <text>")
543	}
544	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
545	if code >= 0 {
546		return code
547	}
548	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
549	if err := gitutil.WriteDescription(dir, args[1]); err != nil {
550		return c.fail(protocol.ExitFailure, "%v", err)
551	}
552	return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
553		fmt.Fprintf(w, "description set on %s\n", repo.Path())
554	})
555}
556
557func runSetWebsite(c *Ctx, args []string) int {
558	if len(args) != 2 {
559		return c.fail(protocol.ExitUsage, "usage: repo settings website <owner/name> <url>")
560	}
561	site := strings.TrimSpace(args[1])
562	if err := validateWebsite(site); err != nil {
563		return c.failErr(err)
564	}
565	if len(site) > 256 {
566		return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
567	}
568	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
569	if code >= 0 {
570		return code
571	}
572	if _, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Website = site }); err != nil {
573		return c.fail(protocol.ExitFailure, "%v", err)
574	}
575	return c.emit(map[string]string{"website": site}, func(w io.Writer) {
576		if site == "" {
577			fmt.Fprintf(w, "website cleared on %s\n", repo.Path())
578		} else {
579			fmt.Fprintf(w, "website set on %s\n", repo.Path())
580		}
581	})
582}
583
584func runSetVisibility(c *Ctx, args []string) int {
585	if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
586		return c.fail(protocol.ExitUsage, "usage: repo settings visibility <owner/name> public|private")
587	}
588	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
589	if code >= 0 {
590		return code
591	}
592	return setRepoVisibility(c, repo, args[1])
593}
594
595// setRepoVisibility applies a visibility change the caller has already
596// been cleared to make.
597func setRepoVisibility(c *Ctx, repo store.Repo, visibility string) int {
598	if repo.Visibility == visibility {
599		return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
600			fmt.Fprintf(w, "%s is already %s\n", repo.Path(), visibility)
601		})
602	}
603	if err := c.Store.SetRepoVisibility(repo.ID, visibility); err != nil {
604		return c.fail(protocol.ExitFailure, "%v", err)
605	}
606	// Going private takes the repository off every anonymous surface, so
607	// git:// exposure cannot outlive the change.
608	if visibility == "private" && repo.Settings.GitDaemon {
609		c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = false })
610	}
611	c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": visibility})
612	return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
613		fmt.Fprintf(w, "%s is now %s\n", repo.Path(), visibility)
614	})
615}
616
617func runGitDaemon(c *Ctx, args []string) int {
618	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
619		return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
620	}
621	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
622	if code >= 0 {
623		return code
624	}
625	on := args[1] == "on"
626	if on && repo.Visibility != "public" {
627		return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
628	}
629	if on && !c.Cfg.GitDaemon.Enabled {
630		return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
631	}
632	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = on })
633	if err != nil {
634		return c.fail(protocol.ExitFailure, "%v", err)
635	}
636	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
637}
638
639func runArchive(c *Ctx, args []string) int   { return setArchived(c, args, true) }
640func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
641
642func setArchived(c *Ctx, args []string, archived bool) int {
643	verb := "archive"
644	if !archived {
645		verb = "unarchive"
646	}
647	if len(args) != 1 {
648		return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
649	}
650	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
651	if code >= 0 {
652		return code
653	}
654	return archiveRepo(c, repo, archived)
655}
656
657// archiveRepo flips the archived flag on a repository the caller has
658// already been cleared to manage.
659func archiveRepo(c *Ctx, repo store.Repo, archived bool) int {
660	verb := "archive"
661	if !archived {
662		verb = "unarchive"
663	}
664	if repo.Settings.Archived == archived {
665		return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
666	}
667	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Archived = archived })
668	if 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.failErr(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.failErr(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.Path(), 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
799// MatchesRepo is the one rule for matching a repository against a text
800// query: its path, its description, or any of its topics. The web's
801// /explore filter and /search page call it too, so the three surfaces
802// cannot answer the same query differently.
803func MatchesRepo(q, path, desc string, topics []string) bool {
804	q = strings.ToLower(q)
805	if strings.Contains(strings.ToLower(path), q) ||
806		strings.Contains(strings.ToLower(desc), q) {
807		return true
808	}
809	for _, t := range topics {
810		if strings.Contains(strings.ToLower(t), q) {
811			return true
812		}
813	}
814	return false
815}
816
817func runRepoGrep(c *Ctx, args []string) int {
818	f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep <owner/name> <query> [--ref <ref>]"})
819	if err != nil {
820		return c.fail(protocol.ExitUsage, "%v", err)
821	}
822	path, query, ref := f.pos(0), f.pos(1), f.Value("--ref")
823	if path == "" || query == "" {
824		return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
825	}
826	if err := validQuery(query); err != nil {
827		return c.failErr(err)
828	}
829	repo, code := resolveRepo(c, path, policy.CanRead)
830	if code >= 0 {
831		return code
832	}
833	if ref == "" {
834		ref = repo.DefaultBranch
835	}
836	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
837	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
838		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
839	}
840	matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches)
841	if err != nil {
842		return c.fail(protocol.ExitFailure, "%v", err)
843	}
844	type out struct {
845		Path string `json:"path"`
846		Line int    `json:"line"`
847		Text string `json:"text"`
848	}
849	var ds []out
850	for _, m := range matches {
851		ds = append(ds, out{m.Path, m.Line, m.Text})
852	}
853	return c.emit(ds, func(w io.Writer) {
854		for _, d := range ds {
855			fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text)
856		}
857	})
858}
859
860func runRepoPin(c *Ctx, args []string) int   { return setPinned(c, args, true) }
861func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) }
862
863func setPinned(c *Ctx, args []string, pin bool) int {
864	verb := "pin"
865	if !pin {
866		verb = "unpin"
867	}
868	if len(args) != 1 {
869		return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
870	}
871	repo, code := resolveRepo(c, args[0], policy.CanRead)
872	if code >= 0 {
873		return code
874	}
875	if pin {
876		if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil {
877			return c.fail(protocol.ExitFailure, "%v", err)
878		}
879	} else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil {
880		if errors.Is(err, store.ErrNotFound) {
881			return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path())
882		}
883		return c.fail(protocol.ExitFailure, "%v", err)
884	}
885	return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) {
886		fmt.Fprintf(w, "%sned %s\n", verb, repo.Path())
887	})
888}
889
890func runProtect(c *Ctx, args []string) int   { return setProtect(c, args, true) }
891func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
892
893func setProtect(c *Ctx, args []string, protect bool) int {
894	if len(args) != 2 {
895		return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
896	}
897	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
898	if code >= 0 {
899		return code
900	}
901	branch := args[1]
902	// The list is read and rewritten inside the update, so two admins
903	// protecting different branches at once both land.
904	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
905		has := slices.Contains(s.ProtectedBranches, branch)
906		if protect && !has {
907			s.ProtectedBranches = append(s.ProtectedBranches, branch)
908			slices.Sort(s.ProtectedBranches)
909		}
910		if !protect && has {
911			s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
912		}
913	})
914	if err != nil {
915		return c.fail(protocol.ExitFailure, "%v", err)
916	}
917	verb := "protected"
918	if !protect {
919		verb = "unprotected"
920	}
921	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
922}
923
924// runRepoDiff is the compare view's command: what head adds on top of
925// base, measured from their merge base the way a merge request diff is,
926// so a base that moved on does not show up as removals (#118).
927func runRepoDiff(c *Ctx, args []string) int {
928	f, err := parseFlags(args, flagSpec{MaxPos: 3, Usage: "repo diff <owner/name> <base> <head>"})
929	if err != nil || len(f.Pos) != 3 {
930		return c.fail(protocol.ExitUsage, "usage: repo diff <owner/name> <base> <head>")
931	}
932	repo, code := resolveRepo(c, f.pos(0), policy.CanRead)
933	if code >= 0 {
934		return code
935	}
936	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
937	base, err := gitutil.ResolveRef(dir, f.pos(1))
938	if err != nil {
939		return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(1), repo.Path())
940	}
941	head, err := gitutil.ResolveRef(dir, f.pos(2))
942	if err != nil {
943		return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(2), repo.Path())
944	}
945	mergeBase, err := gitutil.MergeBase(dir, base, head)
946	if err != nil {
947		return c.fail(protocol.ExitUsage, "%v", err)
948	}
949	patch, truncated, err := gitutil.Diff(dir, mergeBase, head, 4<<20)
950	if err != nil {
951		return c.fail(protocol.ExitFailure, "%v", err)
952	}
953	if c.JSON {
954		return c.emit(map[string]any{"base": base, "head": head, "merge_base": mergeBase, "patch": patch, "truncated": truncated}, nil)
955	}
956	fmt.Fprint(c.Stdout, patch)
957	if truncated {
958		fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB")
959	}
960	return protocol.ExitOK
961}