internal/control/repo.go

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

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