internal/control/repo.go

8dcfa45a8ac03a5ff9c36828274d05acadcf846c
gitbay/internal/control/repo.go history · blame · raw

1312 lines · 45276 bytes

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