internal/control/repo.go

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