internal/control/repo.go

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