internal/control/repo.go

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

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