internal/control/repo.go

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

1181 lines · 42849 bytes

   1package control
   2
   3import (
   4	"errors"
   5	"fmt"
   6	"io"
   7	"os"
   8	"path"
   9	"path/filepath"
  10	"slices"
  11	"strings"
  12
  13	"gitbay.org/gitbay/internal/gitutil"
  14	"gitbay.org/gitbay/internal/policy"
  15	"gitbay.org/gitbay/internal/protocol"
  16	"gitbay.org/gitbay/internal/store"
  17)
  18
  19// RepoDir returns the on-disk path for a repository.
  20func RepoDir(root, owner, name string) string {
  21	return filepath.Join(root, "repos", owner, name+".git")
  22}
  23
  24// HooksDir is the shared core.hooksPath directory.
  25func HooksDir(root string) string { return filepath.Join(root, "hooks") }
  26
  27func init() {
  28	register(Command{Path: []string{"repo", "create"},
  29		Summary: "create a repository",
  30		Usage:   "repo create <owner/name> [--private]", Run: runRepoCreate})
  31	register(Command{Path: []string{"repo", "list"},
  32		Summary: "list repositories you own or can access",
  33		Usage:   "repo list [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runRepoList})
  34	register(Command{Path: []string{"repo", "show"},
  35		Summary: "show repository details",
  36		Usage:   "repo show <owner/name>", ReadOnly: true, Run: runRepoShow})
  37	register(Command{Path: []string{"repo", "transfer"},
  38		Summary: "move a repository to another owner",
  39		Usage:   "repo transfer <owner/name> <new-owner> (clone URLs change)", Run: runRepoTransfer})
  40	register(Command{Path: []string{"repo", "rename"},
  41		Summary: "rename a repository",
  42		Usage:   "repo rename <owner/name> <new-name> (clone URLs change)", Run: runRepoRename})
  43	register(Command{Path: []string{"repo", "delete"},
  44		Summary: "delete a repository",
  45		Usage:   "repo delete <owner/name> --yes", Run: runRepoDelete})
  46	register(Command{Path: []string{"repo", "access", "grant"},
  47		Summary: "grant access",
  48		Usage:   "repo access grant <owner/name> <user> read|write|admin", Run: runAccessGrant})
  49	register(Command{Path: []string{"repo", "access", "revoke"},
  50		Summary: "revoke access",
  51		Usage:   "repo access revoke <owner/name> <user>", Run: runAccessRevoke})
  52	register(Command{Path: []string{"repo", "access", "list"},
  53		Summary: "list who can reach the repository, with the role and where it comes from",
  54		Usage:   "repo access list <owner/name>", ReadOnly: true, Run: runAccessList})
  55	register(Command{Path: []string{"repo", "settings", "show"},
  56		Summary: "show settings",
  57		Usage:   "repo settings show <owner/name>", ReadOnly: true, Run: runSettingsShow})
  58	register(Command{Path: []string{"repo", "settings", "protect"},
  59		Summary: "protect a branch",
  60		Usage:   "repo settings protect <owner/name> <branch>", Run: runProtect})
  61	register(Command{Path: []string{"repo", "settings", "unprotect"},
  62		Summary: "unprotect a branch",
  63		Usage:   "repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
  64	register(Command{Path: []string{"repo", "settings", "protect-tag"},
  65		Summary: "protect tags matching a glob (created once, never moved or deleted)",
  66		Usage:   "repo settings protect-tag <owner/name> <glob>", Run: runProtectTag})
  67	register(Command{Path: []string{"repo", "settings", "unprotect-tag"},
  68		Summary: "drop a protected-tag glob",
  69		Usage:   "repo settings unprotect-tag <owner/name> <glob>", Run: runUnprotectTag})
  70	register(Command{Path: []string{"repo", "settings", "description"},
  71		Summary: "set the repository description",
  72		Usage:   "repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription})
  73	register(Command{Path: []string{"repo", "settings", "visibility"},
  74		Summary: "set repository visibility",
  75		Usage:   "repo settings visibility <owner/name> public|private", Run: runSetVisibility})
  76	register(Command{Path: []string{"repo", "settings", "website"},
  77		Summary: "set the repository website",
  78		Usage:   "repo settings website <owner/name> <url> ('' clears)", Run: runSetWebsite})
  79	register(Command{Path: []string{"repo", "settings", "default-branch"},
  80		Summary: "set the default branch",
  81		Usage:   "repo settings default-branch <owner/name> <branch>", Run: runSetDefaultBranch})
  82	register(Command{Path: []string{"repo", "settings", "git-daemon"},
  83		Summary: "expose over git://",
  84		Usage:   "repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
  85	register(Command{Path: []string{"repo", "archive"},
  86		Summary: "archive a repository (read-only: pushes and issue/MR writes refused)",
  87		Usage:   "repo archive <owner/name>", Run: runArchive})
  88	register(Command{Path: []string{"repo", "unarchive"},
  89		Summary: "unarchive a repository",
  90		Usage:   "repo unarchive <owner/name>", Run: runUnarchive})
  91	register(Command{Path: []string{"repo", "topics"},
  92		Summary: "list topics",
  93		Usage:   "repo topics <owner/name>", ReadOnly: true, Run: runTopicsList})
  94	register(Command{Path: []string{"repo", "topics", "add"},
  95		Summary: "add topics",
  96		Usage:   "repo topics add <owner/name> <topic>...", Run: runTopicsAdd})
  97	register(Command{Path: []string{"repo", "topics", "remove"},
  98		Summary: "remove topics",
  99		Usage:   "repo topics remove <owner/name> <topic>...", Run: runTopicsRemove})
 100	register(Command{Path: []string{"repo", "search"},
 101		Summary: "find repositories by name, description, or topic",
 102		Usage:   "repo search <query>", ReadOnly: true, Run: runRepoSearch})
 103	register(Command{Path: []string{"repo", "grep"},
 104		Summary: "search file contents",
 105		Usage:   "repo grep <owner/name> <query> [--ref <ref>]", ReadOnly: true, Run: runRepoGrep})
 106	register(Command{Path: []string{"repo", "diff"},
 107		Summary: "the patch between two refs, from their merge base",
 108		Usage:   "repo diff <owner/name> <base> <head>", ReadOnly: true, Run: runRepoDiff})
 109	register(Command{Path: []string{"repo", "pin"},
 110		Summary: "pin a repository to your dashboard",
 111		Usage:   "repo pin <owner/name>", Run: runRepoPin})
 112	register(Command{Path: []string{"repo", "unpin"},
 113		Summary: "unpin a repository",
 114		Usage:   "repo unpin <owner/name>", Run: runRepoUnpin})
 115	register(Command{Path: []string{"repo", "bookmark"},
 116		Summary: "bookmark a repository to come back to",
 117		Usage:   "repo bookmark <owner/name>", Run: runRepoBookmark})
 118	register(Command{Path: []string{"repo", "unbookmark"},
 119		Summary: "remove a bookmark",
 120		Usage:   "repo unbookmark <owner/name>", Run: runRepoUnbookmark})
 121	register(Command{Path: []string{"repo", "bookmarks"},
 122		Summary: "list the repositories you have bookmarked",
 123		Usage:   "repo bookmarks", ReadOnly: true, Run: runRepoBookmarks})
 124}
 125
 126const (
 127	minQueryLen    = 2
 128	maxQueryLen    = 200
 129	maxGrepMatches = 200
 130)
 131
 132func validQuery(q string) error {
 133	if len(q) < minQueryLen || len(q) > maxQueryLen {
 134		return fmt.Errorf("query must be %d to %d characters", minQueryLen, maxQueryLen)
 135	}
 136	return nil
 137}
 138
 139// refuseArchived blocks content writes (pushes are refused in the transport
 140// layer) on archived repositories. Settings, access, and lifecycle commands
 141// stay available so an archived repo can be managed and unarchived.
 142func refuseArchived(c *Ctx, repo store.Repo) int {
 143	if repo.Settings.Archived {
 144		return c.fail(protocol.ExitDenied, "%s is archived and read-only", repo.Path())
 145	}
 146	return -1
 147}
 148
 149// resolveRepo loads a repo and checks the given permission for c.User.
 150func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
 151	repo, err := c.Store.RepoByPath(path)
 152	if err != nil {
 153		if errors.Is(err, store.ErrNotFound) {
 154			// Same message whether it doesn't exist or is invisible.
 155			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
 156		}
 157		return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
 158	}
 159	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
 160	if err != nil {
 161		return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
 162	}
 163	if !check(c.User, repo, grant) {
 164		if !policy.CanRead(c.User, repo, grant) {
 165			// Invisible repos 404, per the enumeration rule.
 166			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
 167		}
 168		return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
 169	}
 170	return repo, -1
 171}
 172
 173func runRepoCreate(c *Ctx, args []string) int {
 174	f, err := parseFlags(args, flagSpec{Values: []string{"--description"}, Bools: []string{"--private"}, MaxPos: 1, Usage: "repo create <owner/name> [--private] [--description <text>]"})
 175	if err != nil {
 176		return c.fail(protocol.ExitUsage, "%v", err)
 177	}
 178	visibility, path, description := "public", f.pos(0), f.Value("--description")
 179	if f.Has("--private") {
 180		visibility = "private"
 181	}
 182	owner, name, ok := strings.Cut(path, "/")
 183	if !ok {
 184		return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
 185	}
 186	if err := policyValidateRepoName(name); err != nil {
 187		return c.failInput(err)
 188	}
 189	ownerKind, ownerID := "user", c.User.ID
 190	if owner != c.User.Username {
 191		org, err := c.Store.OrgByName(owner)
 192		if err != nil {
 193			return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
 194		}
 195		role, err := c.Store.OrgRole(org.ID, c.User.ID)
 196		if err != nil {
 197			return c.fail(protocol.ExitFailure, "%v", err)
 198		}
 199		if role != "admin" {
 200			return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
 201		}
 202		ownerKind, ownerID = "org", org.ID
 203	}
 204	repoCreateMu.Lock()
 205	if ownerKind == "user" {
 206		if code := checkRepoQuota(c); code >= 0 {
 207			repoCreateMu.Unlock()
 208			return code
 209		}
 210	}
 211	id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
 212	repoCreateMu.Unlock()
 213	if err != nil {
 214		return c.fail(protocol.ExitFailure, "%v", err)
 215	}
 216	dir := RepoDir(c.Cfg.Server.Root, owner, name)
 217	if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
 218		c.Store.DeleteRepo(id)
 219		return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
 220	}
 221	if description != "" {
 222		if err := gitutil.WriteDescription(dir, description); err != nil {
 223			return c.fail(protocol.ExitFailure, "writing description: %v", err)
 224		}
 225	}
 226	type out struct {
 227		Path       string `json:"path"`
 228		Visibility string `json:"visibility"`
 229		SSHURL     string `json:"ssh_url"`
 230	}
 231	d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
 232	return c.emit(d, func(w io.Writer) {
 233		fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
 234	})
 235}
 236
 237func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
 238
 239func hostOf(siteURL string) string {
 240	s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
 241	return strings.TrimSuffix(s, "/")
 242}
 243
 244func runRepoList(c *Ctx, args []string) int {
 245	args, p, code := parsePageFlags(c, args, "repo", false)
 246	if code >= 0 {
 247		return code
 248	}
 249	if len(args) != 0 {
 250		return c.fail(protocol.ExitUsage, "usage: repo list [--limit <n>] [--cursor <c>]")
 251	}
 252	repos, err := c.Store.ListReposForUser(c.User.ID, p.queryLimit(), p.key)
 253	if err != nil {
 254		return c.fail(protocol.ExitFailure, "%v", err)
 255	}
 256	repos, next := trimPage(p, repos, "repo", store.Repo.Path)
 257	type out struct {
 258		Path        string `json:"path"`
 259		Visibility  string `json:"visibility"`
 260		Description string `json:"description,omitempty"`
 261		Archived    bool   `json:"archived,omitempty"`
 262	}
 263	var ds []out
 264	for _, r := range repos {
 265		desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
 266		ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
 267	}
 268	return c.emitPage(p, ds, next, func(w io.Writer) {
 269		for _, d := range ds {
 270			mark := ""
 271			if d.Archived {
 272				mark = "\t[archived]"
 273			}
 274			fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark)
 275		}
 276	})
 277}
 278
 279func runRepoShow(c *Ctx, args []string) int {
 280	if len(args) != 1 {
 281		return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
 282	}
 283	repo, code := resolveRepo(c, args[0], policy.CanRead)
 284	if code >= 0 {
 285		return code
 286	}
 287	type mirrorOut struct {
 288		Direction string `json:"direction"`
 289		URL       string `json:"url"`
 290		Pending   bool   `json:"pending"`
 291		LastSync  string `json:"last_sync,omitempty"`
 292		LastError string `json:"last_error,omitempty"`
 293	}
 294	type out struct {
 295		Path              string      `json:"path"`
 296		Description       string      `json:"description,omitempty"`
 297		Website           string      `json:"website,omitempty"`
 298		Visibility        string      `json:"visibility"`
 299		DefaultBranch     string      `json:"default_branch"`
 300		ProtectedBranches []string    `json:"protected_branches,omitempty"`
 301		Archived          bool        `json:"archived,omitempty"`
 302		Topics            []string    `json:"topics,omitempty"`
 303		Domains           []string    `json:"domains,omitempty"`
 304		Mirrors           []mirrorOut `json:"mirrors,omitempty"`
 305		// ForkOf names the parent only when the caller can read it: a
 306		// private parent is not confirmed to exist, here as anywhere.
 307		ForkOf string `json:"fork_of,omitempty"`
 308		// Watch and Bookmarked are the caller's own state, so a client
 309		// can draw a toggle rather than two stateless buttons (#178).
 310		Watch      string `json:"watch,omitempty"` // watching, muted, or absent
 311		Bookmarked bool   `json:"bookmarked,omitempty"`
 312	}
 313	desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
 314	topics, err := c.Store.ListTopics(repo.ID)
 315	if err != nil {
 316		return c.fail(protocol.ExitFailure, "%v", err)
 317	}
 318	var domains []string
 319	if ds, err := c.Store.ListPageDomains(repo.ID); err == nil {
 320		for _, pd := range ds {
 321			if pd.Verified() {
 322				domains = append(domains, pd.Domain)
 323			}
 324		}
 325	}
 326	d := out{Path: repo.Path(), Description: desc, Website: repo.Settings.Website, Visibility: repo.Visibility,
 327		DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches,
 328		Archived: repo.Settings.Archived, Topics: topics, Domains: domains}
 329	if repo.ForkOf != 0 {
 330		if parent, err := c.Store.RepoByID(repo.ForkOf); err == nil {
 331			if grant, err := c.Store.AccessRole(parent.ID, c.User.ID); err == nil && policy.CanRead(c.User, parent, grant) {
 332				d.ForkOf = parent.Path()
 333			}
 334		}
 335	}
 336	if c.User.ID != 0 {
 337		d.Watch = c.Store.RepoWatchState(repo.ID, c.User.ID)
 338		d.Bookmarked = c.Store.IsBookmarked(c.User.ID, repo.ID)
 339	}
 340	// Mirror status is admin-only, like repo mirror list. The token never
 341	// leaves the server.
 342	if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
 343		ms, err := c.Store.ListMirrors(repo.ID)
 344		if err != nil {
 345			return c.fail(protocol.ExitFailure, "%v", err)
 346		}
 347		for _, m := range ms {
 348			d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
 349		}
 350	}
 351	return c.emit(d, func(w io.Writer) {
 352		line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch)
 353		if d.Archived {
 354			line += "\t[archived]"
 355		}
 356		fmt.Fprintln(w, line)
 357		if d.Description != "" {
 358			fmt.Fprintf(w, "%s\n", d.Description)
 359		}
 360		if d.Website != "" {
 361			fmt.Fprintf(w, "website: %s\n", d.Website)
 362		}
 363		if len(d.Topics) > 0 {
 364			fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
 365		}
 366		if len(d.ProtectedBranches) > 0 {
 367			fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
 368		}
 369		if len(d.Domains) > 0 {
 370			fmt.Fprintf(w, "pages domains: %s\n", strings.Join(d.Domains, ", "))
 371		}
 372		if d.ForkOf != "" {
 373			fmt.Fprintf(w, "fork of: %s\n", d.ForkOf)
 374		}
 375		if d.Watch != "" {
 376			fmt.Fprintf(w, "watch: %s\n", d.Watch)
 377		}
 378		if d.Bookmarked {
 379			fmt.Fprintln(w, "bookmarked")
 380		}
 381		for _, m := range d.Mirrors {
 382			status := "ok"
 383			if m.Pending {
 384				status = "pending"
 385			}
 386			if m.LastError != "" {
 387				status = "error: " + m.LastError
 388			}
 389			fmt.Fprintf(w, "mirror: %s %s\tlast %s\t%s\n", m.Direction, m.URL, orDash(m.LastSync), status)
 390		}
 391	})
 392}
 393
 394func runRepoTransfer(c *Ctx, args []string) int {
 395	if len(args) != 2 {
 396		return c.fail(protocol.ExitUsage, "usage: repo transfer <owner/name> <new-owner>")
 397	}
 398	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 399	if code >= 0 {
 400		return code
 401	}
 402	newOwner := args[1]
 403	if newOwner == repo.OwnerName {
 404		return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
 405	}
 406
 407	// Target: yourself, or an org you admin — same rule as repo create.
 408	newKind, newID := "", int64(0)
 409	if newOwner == c.User.Username {
 410		newKind, newID = "user", c.User.ID
 411	} else if org, err := c.Store.OrgByName(newOwner); err == nil {
 412		role, err := c.Store.OrgRole(org.ID, c.User.ID)
 413		if err != nil {
 414			return c.fail(protocol.ExitFailure, "%v", err)
 415		}
 416		if role != "admin" {
 417			return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
 418		}
 419		newKind, newID = "org", org.ID
 420	} else {
 421		return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
 422	}
 423
 424	oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 425	newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
 426	if _, err := os.Stat(newDir); err == nil {
 427		return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
 428	}
 429	if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
 430		return c.failErr(err)
 431	}
 432	if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
 433		c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
 434		return c.fail(protocol.ExitFailure, "%v", err)
 435	}
 436	if err := os.Rename(oldDir, newDir); err != nil {
 437		// Keep name and disk consistent: revert the database change, and
 438		// say so if even that fails, since the operator then has a row
 439		// pointing at a directory that is not there.
 440		if rerr := c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID); rerr != nil {
 441			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())
 442		}
 443		return c.fail(protocol.ExitFailure, "moving repository: %v", err)
 444	}
 445	newPath := newOwner + "/" + repo.Name
 446	return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
 447		fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
 448	})
 449}
 450
 451func runRepoRename(c *Ctx, args []string) int {
 452	if len(args) != 2 {
 453		return c.fail(protocol.ExitUsage, "usage: repo rename <owner/name> <new-name>")
 454	}
 455	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 456	if code >= 0 {
 457		return code
 458	}
 459	newName := args[1]
 460	if newName == repo.Name {
 461		return c.fail(protocol.ExitUsage, "%s is already named %s", repo.Path(), newName)
 462	}
 463	if err := policyValidateRepoName(newName); err != nil {
 464		return c.failInput(err)
 465	}
 466	oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 467	newDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, newName)
 468	if _, err := os.Stat(newDir); err == nil {
 469		return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", repo.OwnerName, newName)
 470	}
 471	if err := c.Store.RenameRepo(repo.ID, newName); err != nil {
 472		return c.failErr(err)
 473	}
 474	if err := os.Rename(oldDir, newDir); err != nil {
 475		// Same rule as transfer: keep name and disk consistent, and say so
 476		// if even the revert fails.
 477		if rerr := c.Store.RenameRepo(repo.ID, repo.Name); rerr != nil {
 478			return c.fail(protocol.ExitFailure, "moving repository: %v; and reverting the record failed: %v (the record now names %s/%s but the directory is still %s)", err, rerr, repo.OwnerName, newName, repo.Path())
 479		}
 480		return c.fail(protocol.ExitFailure, "moving repository: %v", err)
 481	}
 482	newPath := repo.OwnerName + "/" + newName
 483	return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
 484		fmt.Fprintf(w, "renamed %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
 485	})
 486}
 487
 488func runRepoDelete(c *Ctx, args []string) int {
 489	var path string
 490	var yes bool
 491	for _, a := range args {
 492		if a == "--yes" {
 493			yes = true
 494		} else if path == "" {
 495			path = a
 496		} else {
 497			return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
 498		}
 499	}
 500	if path == "" {
 501		return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
 502	}
 503	repo, code := resolveRepo(c, path, policy.CanAdmin)
 504	if code >= 0 {
 505		return code
 506	}
 507	if !yes {
 508		return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
 509	}
 510	return deleteRepo(c, repo)
 511}
 512
 513// deleteRepo removes a repository the caller has already been cleared to
 514// delete: the database row, then the directory.
 515//
 516// There is deliberately no repo.deleted event. events.repo_id and
 517// webhooks.repo_id both cascade from repos, so recording one would delete
 518// it, and every webhook that could have subscribed, in the same
 519// statement. A repository's deletion is not observable through its own
 520// webhooks; an instance that needs to hear about it wants the audit log
 521// (#112).
 522func deleteRepo(c *Ctx, repo store.Repo) int {
 523	// Open MRs sourced from this repo keep working (targets own the
 524	// objects) but must show that the source is gone.
 525	if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
 526		return c.fail(protocol.ExitFailure, "%v", err)
 527	}
 528	if err := c.Store.DeleteRepo(repo.ID); err != nil {
 529		return c.fail(protocol.ExitFailure, "%v", err)
 530	}
 531	if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
 532		return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
 533	}
 534	return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
 535		fmt.Fprintf(w, "deleted %s\n", repo.Path())
 536	})
 537}
 538
 539func runAccessGrant(c *Ctx, args []string) int {
 540	if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
 541		return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
 542	}
 543	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 544	if code >= 0 {
 545		return code
 546	}
 547	target, err := c.Store.UserByUsername(args[1])
 548	if err != nil {
 549		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
 550	}
 551	if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
 552		return c.fail(protocol.ExitFailure, "%v", err)
 553	}
 554	return c.emit(map[string]string{"granted": args[2], "user": target.Username},
 555		func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
 556}
 557
 558func runAccessRevoke(c *Ctx, args []string) int {
 559	if len(args) != 2 {
 560		return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
 561	}
 562	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 563	if code >= 0 {
 564		return code
 565	}
 566	target, err := c.Store.UserByUsername(args[1])
 567	if err != nil {
 568		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
 569	}
 570	if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
 571		if errors.Is(err, store.ErrNotFound) {
 572			return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
 573		}
 574		return c.fail(protocol.ExitFailure, "%v", err)
 575	}
 576	return c.emit(map[string]string{"revoked": target.Username},
 577		func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
 578}
 579
 580func runAccessList(c *Ctx, args []string) int {
 581	if len(args) != 1 {
 582		return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
 583	}
 584	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 585	if code >= 0 {
 586		return code
 587	}
 588	entries, err := c.Store.EffectiveAccess(repo.ID)
 589	if err != nil {
 590		return c.fail(protocol.ExitFailure, "%v", err)
 591	}
 592	type out struct {
 593		User   string `json:"user"`
 594		Role   string `json:"role"`
 595		Source string `json:"source"`
 596	}
 597	var ds []out
 598	for _, e := range entries {
 599		ds = append(ds, out{e.Username, e.Role, e.Source})
 600	}
 601	return c.emit(ds, func(w io.Writer) {
 602		for _, d := range ds {
 603			fmt.Fprintf(w, "%s\t%s\tvia %s\n", d.User, d.Role, d.Source)
 604		}
 605	})
 606}
 607
 608func runSettingsShow(c *Ctx, args []string) int {
 609	if len(args) != 1 {
 610		return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
 611	}
 612	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 613	if code >= 0 {
 614		return code
 615	}
 616	return c.emit(repo.Settings, func(w io.Writer) {
 617		fmt.Fprintf(w, "protected_branches: %s\nprotected_tags: %s\nrequire_mr: %v\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
 618			strings.Join(repo.Settings.ProtectedBranches, ", "), strings.Join(repo.Settings.ProtectedTags, ", "), repo.Settings.RequireMR, repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
 619	})
 620}
 621
 622func runSetDescription(c *Ctx, args []string) int {
 623	if len(args) != 2 {
 624		return c.fail(protocol.ExitUsage, "usage: repo settings description <owner/name> <text>")
 625	}
 626	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 627	if code >= 0 {
 628		return code
 629	}
 630	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 631	if err := gitutil.WriteDescription(dir, args[1]); err != nil {
 632		return c.fail(protocol.ExitFailure, "%v", err)
 633	}
 634	return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
 635		fmt.Fprintf(w, "description set on %s\n", repo.Path())
 636	})
 637}
 638
 639func runSetDefaultBranch(c *Ctx, args []string) int {
 640	if len(args) != 2 {
 641		return c.fail(protocol.ExitUsage, "usage: repo settings default-branch <owner/name> <branch>")
 642	}
 643	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 644	if code >= 0 {
 645		return code
 646	}
 647	branch := args[1]
 648	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 649	if _, err := gitutil.ResolveRef(dir, "refs/heads/"+branch); err != nil {
 650		return c.fail(protocol.ExitFailure, "no branch named %q on %s", branch, repo.Path())
 651	}
 652	if err := gitutil.SetHead(dir, branch); err != nil {
 653		return c.fail(protocol.ExitFailure, "%v", err)
 654	}
 655	if err := c.Store.UpdateDefaultBranch(repo.ID, branch); err != nil {
 656		return c.fail(protocol.ExitFailure, "%v", err)
 657	}
 658	return c.emit(map[string]string{"default_branch": branch}, func(w io.Writer) {
 659		fmt.Fprintf(w, "default branch of %s is now %s\n", repo.Path(), branch)
 660	})
 661}
 662
 663func runSetWebsite(c *Ctx, args []string) int {
 664	if len(args) != 2 {
 665		return c.fail(protocol.ExitUsage, "usage: repo settings website <owner/name> <url>")
 666	}
 667	site := strings.TrimSpace(args[1])
 668	if err := validateWebsite(site); err != nil {
 669		return c.failInput(err)
 670	}
 671	if len(site) > 256 {
 672		return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
 673	}
 674	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 675	if code >= 0 {
 676		return code
 677	}
 678	if _, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Website = site }); err != nil {
 679		return c.fail(protocol.ExitFailure, "%v", err)
 680	}
 681	return c.emit(map[string]string{"website": site}, func(w io.Writer) {
 682		if site == "" {
 683			fmt.Fprintf(w, "website cleared on %s\n", repo.Path())
 684		} else {
 685			fmt.Fprintf(w, "website set on %s\n", repo.Path())
 686		}
 687	})
 688}
 689
 690func runSetVisibility(c *Ctx, args []string) int {
 691	if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
 692		return c.fail(protocol.ExitUsage, "usage: repo settings visibility <owner/name> public|private")
 693	}
 694	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 695	if code >= 0 {
 696		return code
 697	}
 698	return setRepoVisibility(c, repo, args[1])
 699}
 700
 701// setRepoVisibility applies a visibility change the caller has already
 702// been cleared to make.
 703func setRepoVisibility(c *Ctx, repo store.Repo, visibility string) int {
 704	if repo.Visibility == visibility {
 705		return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
 706			fmt.Fprintf(w, "%s is already %s\n", repo.Path(), visibility)
 707		})
 708	}
 709	if err := c.Store.SetRepoVisibility(repo.ID, visibility); err != nil {
 710		return c.fail(protocol.ExitFailure, "%v", err)
 711	}
 712	// Going private takes the repository off every anonymous surface, so
 713	// git:// exposure cannot outlive the change.
 714	if visibility == "private" && repo.Settings.GitDaemon {
 715		c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = false })
 716	}
 717	c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": visibility})
 718	return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
 719		fmt.Fprintf(w, "%s is now %s\n", repo.Path(), visibility)
 720	})
 721}
 722
 723func runGitDaemon(c *Ctx, args []string) int {
 724	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 725		return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
 726	}
 727	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 728	if code >= 0 {
 729		return code
 730	}
 731	on := args[1] == "on"
 732	if on && repo.Visibility != "public" {
 733		return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
 734	}
 735	if on && !c.Cfg.GitDaemon.Enabled {
 736		return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
 737	}
 738	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = on })
 739	if err != nil {
 740		return c.fail(protocol.ExitFailure, "%v", err)
 741	}
 742	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
 743}
 744
 745func runArchive(c *Ctx, args []string) int   { return setArchived(c, args, true) }
 746func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
 747
 748func setArchived(c *Ctx, args []string, archived bool) int {
 749	verb := "archive"
 750	if !archived {
 751		verb = "unarchive"
 752	}
 753	if len(args) != 1 {
 754		return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
 755	}
 756	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 757	if code >= 0 {
 758		return code
 759	}
 760	return archiveRepo(c, repo, archived)
 761}
 762
 763// archiveRepo flips the archived flag on a repository the caller has
 764// already been cleared to manage.
 765func archiveRepo(c *Ctx, repo store.Repo, archived bool) int {
 766	verb := "archive"
 767	if !archived {
 768		verb = "unarchive"
 769	}
 770	if repo.Settings.Archived == archived {
 771		return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
 772	}
 773	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Archived = archived })
 774	if err != nil {
 775		return c.fail(protocol.ExitFailure, "%v", err)
 776	}
 777	c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
 778	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
 779}
 780
 781func runTopicsList(c *Ctx, args []string) int {
 782	if len(args) != 1 {
 783		return c.fail(protocol.ExitUsage, "usage: repo topics <owner/name>")
 784	}
 785	repo, code := resolveRepo(c, args[0], policy.CanRead)
 786	if code >= 0 {
 787		return code
 788	}
 789	topics, err := c.Store.ListTopics(repo.ID)
 790	if err != nil {
 791		return c.fail(protocol.ExitFailure, "%v", err)
 792	}
 793	return c.emit(topics, func(w io.Writer) {
 794		for _, t := range topics {
 795			fmt.Fprintln(w, t)
 796		}
 797	})
 798}
 799
 800func runTopicsAdd(c *Ctx, args []string) int    { return editTopics(c, args, true) }
 801func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) }
 802
 803func editTopics(c *Ctx, args []string, add bool) int {
 804	verb := "add"
 805	if !add {
 806		verb = "remove"
 807	}
 808	if len(args) < 2 {
 809		return c.fail(protocol.ExitUsage, "usage: repo topics %s <owner/name> <topic>...", verb)
 810	}
 811	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 812	if code >= 0 {
 813		return code
 814	}
 815	topics := args[1:]
 816	if add {
 817		for _, t := range topics {
 818			if err := policy.ValidateTopic(t); err != nil {
 819				return c.failInput(err)
 820			}
 821		}
 822		have, err := c.Store.ListTopics(repo.ID)
 823		if err != nil {
 824			return c.fail(protocol.ExitFailure, "%v", err)
 825		}
 826		added := 0
 827		for _, t := range topics {
 828			if !slices.Contains(have, t) {
 829				added++
 830			}
 831		}
 832		if len(have)+added > policy.MaxTopics {
 833			return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics)
 834		}
 835		for _, t := range topics {
 836			if err := c.Store.AddTopic(repo.ID, t); err != nil {
 837				return c.fail(protocol.ExitFailure, "%v", err)
 838			}
 839		}
 840	} else {
 841		for _, t := range topics {
 842			if err := c.Store.RemoveTopic(repo.ID, t); err != nil {
 843				if errors.Is(err, store.ErrNotFound) {
 844					return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t)
 845				}
 846				return c.fail(protocol.ExitFailure, "%v", err)
 847			}
 848		}
 849	}
 850	now, err := c.Store.ListTopics(repo.ID)
 851	if err != nil {
 852		return c.fail(protocol.ExitFailure, "%v", err)
 853	}
 854	return c.emit(now, func(w io.Writer) {
 855		fmt.Fprintf(w, "topics on %s: %s\n", repo.Path(), strings.Join(now, ", "))
 856	})
 857}
 858
 859// runRepoSearch matches the query against name, owner/name, description,
 860// and topics of every repository the caller can see.
 861func runRepoSearch(c *Ctx, args []string) int {
 862	if len(args) != 1 {
 863		return c.fail(protocol.ExitUsage, "usage: repo search <query>")
 864	}
 865	if err := validQuery(args[0]); err != nil {
 866		return c.failInput(err)
 867	}
 868	q := strings.ToLower(args[0])
 869
 870	public, err := c.Store.ListPublicRepos()
 871	if err != nil {
 872		return c.fail(protocol.ExitFailure, "%v", err)
 873	}
 874	own, err := c.Store.ListReposForUser(c.User.ID, 0, "")
 875	if err != nil {
 876		return c.fail(protocol.ExitFailure, "%v", err)
 877	}
 878	seen := map[int64]bool{}
 879	type out struct {
 880		Path        string   `json:"path"`
 881		Visibility  string   `json:"visibility"`
 882		Description string   `json:"description,omitempty"`
 883		Topics      []string `json:"topics,omitempty"`
 884	}
 885	var ds []out
 886	for _, r := range append(public, own...) {
 887		if seen[r.ID] {
 888			continue
 889		}
 890		seen[r.ID] = true
 891		desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
 892		topics, _ := c.Store.ListTopics(r.ID)
 893		if !MatchesRepo(q, r.Path(), desc, topics) {
 894			continue
 895		}
 896		ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
 897	}
 898	return c.emit(ds, func(w io.Writer) {
 899		for _, d := range ds {
 900			fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description)
 901		}
 902	})
 903}
 904
 905// MatchesRepo is the one rule for matching a repository against a text
 906// query: its path, its description, or any of its topics. The web's
 907// /explore filter and /search page call it too, so the three surfaces
 908// cannot answer the same query differently.
 909func MatchesRepo(q, path, desc string, topics []string) bool {
 910	q = strings.ToLower(q)
 911	if strings.Contains(strings.ToLower(path), q) ||
 912		strings.Contains(strings.ToLower(desc), q) {
 913		return true
 914	}
 915	for _, t := range topics {
 916		if strings.Contains(strings.ToLower(t), q) {
 917			return true
 918		}
 919	}
 920	return false
 921}
 922
 923func runRepoGrep(c *Ctx, args []string) int {
 924	f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep <owner/name> <query> [--ref <ref>]"})
 925	if err != nil {
 926		return c.fail(protocol.ExitUsage, "%v", err)
 927	}
 928	path, query, ref := f.pos(0), f.pos(1), f.Value("--ref")
 929	if path == "" || query == "" {
 930		return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
 931	}
 932	if err := validQuery(query); err != nil {
 933		return c.failInput(err)
 934	}
 935	repo, code := resolveRepo(c, path, policy.CanRead)
 936	if code >= 0 {
 937		return code
 938	}
 939	if ref == "" {
 940		ref = repo.DefaultBranch
 941	}
 942	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 943	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
 944		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
 945	}
 946	matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches)
 947	if err != nil {
 948		return c.fail(protocol.ExitFailure, "%v", err)
 949	}
 950	type out struct {
 951		Path string `json:"path"`
 952		Line int    `json:"line"`
 953		Text string `json:"text"`
 954	}
 955	var ds []out
 956	for _, m := range matches {
 957		ds = append(ds, out{m.Path, m.Line, m.Text})
 958	}
 959	return c.emit(ds, func(w io.Writer) {
 960		for _, d := range ds {
 961			fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text)
 962		}
 963	})
 964}
 965
 966func runRepoPin(c *Ctx, args []string) int   { return setPinned(c, args, true) }
 967func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) }
 968
 969func setPinned(c *Ctx, args []string, pin bool) int {
 970	verb := "pin"
 971	if !pin {
 972		verb = "unpin"
 973	}
 974	if len(args) != 1 {
 975		return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
 976	}
 977	repo, code := resolveRepo(c, args[0], policy.CanRead)
 978	if code >= 0 {
 979		return code
 980	}
 981	if pin {
 982		if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil {
 983			return c.fail(protocol.ExitFailure, "%v", err)
 984		}
 985	} else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil {
 986		if errors.Is(err, store.ErrNotFound) {
 987			return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path())
 988		}
 989		return c.fail(protocol.ExitFailure, "%v", err)
 990	}
 991	return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) {
 992		fmt.Fprintf(w, "%sned %s\n", verb, repo.Path())
 993	})
 994}
 995
 996func runRepoBookmark(c *Ctx, args []string) int   { return setBookmarked(c, args, true) }
 997func runRepoUnbookmark(c *Ctx, args []string) int { return setBookmarked(c, args, false) }
 998
 999// setBookmarked mirrors setPinned. A bookmark needs only read access —
1000// bookmarking is something you do to someone else's repository, which is
1001// the whole point of it — and a private repository you cannot read is
1002// not found, as everywhere.
1003func setBookmarked(c *Ctx, args []string, on bool) int {
1004	verb := "bookmark"
1005	if !on {
1006		verb = "unbookmark"
1007	}
1008	if len(args) != 1 {
1009		return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
1010	}
1011	repo, code := resolveRepo(c, args[0], policy.CanRead)
1012	if code >= 0 {
1013		return code
1014	}
1015	if on {
1016		if err := c.Store.BookmarkRepo(c.User.ID, repo.ID); err != nil {
1017			return c.fail(protocol.ExitFailure, "%v", err)
1018		}
1019	} else if err := c.Store.UnbookmarkRepo(c.User.ID, repo.ID); err != nil {
1020		if errors.Is(err, store.ErrNotFound) {
1021			return c.fail(protocol.ExitNotFound, "%s is not bookmarked", repo.Path())
1022		}
1023		return c.fail(protocol.ExitFailure, "%v", err)
1024	}
1025	return c.emit(map[string]string{verb + "ed": repo.Path()}, func(w io.Writer) {
1026		fmt.Fprintf(w, "%sed %s\n", verb, repo.Path())
1027	})
1028}
1029
1030// BookmarkOut is one row of `repo bookmarks`: the repository and how many
1031// people have bookmarked it.
1032type BookmarkOut struct {
1033	Path        string `json:"path"`
1034	Description string `json:"description,omitempty"`
1035	Visibility  string `json:"visibility"`
1036	Bookmarks   int    `json:"bookmarks"`
1037}
1038
1039func runRepoBookmarks(c *Ctx, args []string) int {
1040	if len(args) != 0 {
1041		return c.fail(protocol.ExitUsage, "usage: repo bookmarks")
1042	}
1043	repos, err := c.Store.ListBookmarks(c.User.ID)
1044	if err != nil {
1045		return c.fail(protocol.ExitFailure, "%v", err)
1046	}
1047	out := []BookmarkOut{}
1048	for _, r := range repos {
1049		// A repository bookmarked while public and since made private
1050		// stays in the table and drops out of the listing, the same way
1051		// it disappears from every other surface.
1052		grant, err := c.Store.AccessRole(r.ID, c.User.ID)
1053		if err != nil {
1054			return c.fail(protocol.ExitFailure, "%v", err)
1055		}
1056		if !policy.CanRead(c.User, r, grant) {
1057			continue
1058		}
1059		out = append(out, BookmarkOut{
1060			Path:        r.Path(),
1061			Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)),
1062			Visibility:  r.Visibility,
1063			Bookmarks:   c.Store.BookmarkCount(r.ID),
1064		})
1065	}
1066	return c.emit(out, func(w io.Writer) {
1067		for _, b := range out {
1068			fmt.Fprintf(w, "%s\t%d\t%s\n", b.Path, b.Bookmarks, b.Description)
1069		}
1070	})
1071}
1072
1073func runProtectTag(c *Ctx, args []string) int   { return setProtectTag(c, args, true) }
1074func runUnprotectTag(c *Ctx, args []string) int { return setProtectTag(c, args, false) }
1075
1076func setProtectTag(c *Ctx, args []string, protect bool) int {
1077	if len(args) != 2 {
1078		return c.fail(protocol.ExitUsage, "usage: repo settings protect-tag|unprotect-tag <owner/name> <glob>")
1079	}
1080	glob := args[1]
1081	if _, err := path.Match(glob, "x"); err != nil || glob == "" {
1082		return c.fail(protocol.ExitUsage, "bad glob %q", glob)
1083	}
1084	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1085	if code >= 0 {
1086		return code
1087	}
1088	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1089		has := slices.Contains(s.ProtectedTags, glob)
1090		if protect && !has {
1091			s.ProtectedTags = append(s.ProtectedTags, glob)
1092			slices.Sort(s.ProtectedTags)
1093		}
1094		if !protect && has {
1095			s.ProtectedTags = slices.DeleteFunc(s.ProtectedTags, func(g string) bool { return g == glob })
1096		}
1097	})
1098	if err != nil {
1099		return c.fail(protocol.ExitFailure, "%v", err)
1100	}
1101	verb := "protected"
1102	if !protect {
1103		verb = "unprotected"
1104	}
1105	return c.emit(s, func(w io.Writer) {
1106		fmt.Fprintf(w, "tags %s %s on %s\n", glob, verb, repo.Path())
1107	})
1108}
1109
1110func runProtect(c *Ctx, args []string) int   { return setProtect(c, args, true) }
1111func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
1112
1113func setProtect(c *Ctx, args []string, protect bool) int {
1114	if len(args) != 2 {
1115		return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
1116	}
1117	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1118	if code >= 0 {
1119		return code
1120	}
1121	branch := args[1]
1122	// The list is read and rewritten inside the update, so two admins
1123	// protecting different branches at once both land.
1124	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1125		has := slices.Contains(s.ProtectedBranches, branch)
1126		if protect && !has {
1127			s.ProtectedBranches = append(s.ProtectedBranches, branch)
1128			slices.Sort(s.ProtectedBranches)
1129		}
1130		if !protect && has {
1131			s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
1132		}
1133	})
1134	if err != nil {
1135		return c.fail(protocol.ExitFailure, "%v", err)
1136	}
1137	verb := "protected"
1138	if !protect {
1139		verb = "unprotected"
1140	}
1141	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
1142}
1143
1144// runRepoDiff is the compare view's command: what head adds on top of
1145// base, measured from their merge base the way a merge request diff is,
1146// so a base that moved on does not show up as removals (#118).
1147func runRepoDiff(c *Ctx, args []string) int {
1148	f, err := parseFlags(args, flagSpec{MaxPos: 3, Usage: "repo diff <owner/name> <base> <head>"})
1149	if err != nil || len(f.Pos) != 3 {
1150		return c.fail(protocol.ExitUsage, "usage: repo diff <owner/name> <base> <head>")
1151	}
1152	repo, code := resolveRepo(c, f.pos(0), policy.CanRead)
1153	if code >= 0 {
1154		return code
1155	}
1156	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1157	base, err := gitutil.ResolveRef(dir, f.pos(1))
1158	if err != nil {
1159		return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(1), repo.Path())
1160	}
1161	head, err := gitutil.ResolveRef(dir, f.pos(2))
1162	if err != nil {
1163		return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(2), repo.Path())
1164	}
1165	mergeBase, err := gitutil.MergeBase(dir, base, head)
1166	if err != nil {
1167		return c.fail(protocol.ExitUsage, "%v", err)
1168	}
1169	patch, truncated, err := gitutil.Diff(dir, mergeBase, head, 4<<20)
1170	if err != nil {
1171		return c.fail(protocol.ExitFailure, "%v", err)
1172	}
1173	if c.JSON {
1174		return c.emit(map[string]any{"base": base, "head": head, "merge_base": mergeBase, "patch": patch, "truncated": truncated}, nil)
1175	}
1176	fmt.Fprint(c.Stdout, patch)
1177	if truncated {
1178		fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB")
1179	}
1180	return protocol.ExitOK
1181}