internal/control/repo.go

419f6dfdc5489a0c6374e36dd1ebbfca68040056
gitbay/internal/control/repo.go history · blame · raw

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