internal/control/repo.go

1532 lines · 53532 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.emitPageView(p, ds, next, func(w io.Writer) {
 364		tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
 365		for _, d := range ds {
 366			cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)}
 367			if d.Archived {
 368				cells = c.note(cells, 1, "[archived]", "archived")
 369			}
 370			tb.row(cells...)
 371		}
 372		tb.flush()
 373	}, func() screen {
 374		rows := make([]row, len(ds))
 375		for i, d := range ds {
 376			state := d.Visibility
 377			if d.Archived {
 378				state += ", archived"
 379			}
 380			rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(state), cFlex(d.Description))
 381		}
 382		s := listScreen("Repositories", rows)
 383		if len(ds) > 0 {
 384			s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}}
 385		}
 386		return s
 387	})
 388}
 389
 390// repoMirrorOut is one mirror as repo show emits it, for admins.
 391type repoMirrorOut struct {
 392	Direction string `json:"direction"`
 393	URL       string `json:"url"`
 394	Pending   bool   `json:"pending"`
 395	LastSync  string `json:"last_sync,omitempty"`
 396	LastError string `json:"last_error,omitempty"`
 397}
 398
 399// repoShowOut is what repo show emits.
 400type repoShowOut struct {
 401	Path              string          `json:"path"`
 402	Description       string          `json:"description,omitempty"`
 403	Website           string          `json:"website,omitempty"`
 404	Visibility        string          `json:"visibility"`
 405	DefaultBranch     string          `json:"default_branch"`
 406	ProtectedBranches []string        `json:"protected_branches,omitempty"`
 407	Archived          bool            `json:"archived,omitempty"`
 408	Topics            []string        `json:"topics,omitempty"`
 409	Domains           []string        `json:"domains,omitempty"`
 410	Mirrors           []repoMirrorOut `json:"mirrors,omitempty"`
 411	// ForkOf names the parent only when the caller can read it: a
 412	// private parent is not confirmed to exist, here as anywhere.
 413	ForkOf string `json:"fork_of,omitempty"`
 414	// Watch and Bookmarked are the caller's own state, so a client
 415	// can draw a toggle rather than two stateless buttons (#178).
 416	Watch      string `json:"watch,omitempty"` // watching, muted, or absent
 417	Bookmarked bool   `json:"bookmarked,omitempty"`
 418}
 419
 420func runRepoShow(c *Ctx, args []string) int {
 421	if len(args) != 1 {
 422		return c.usage()
 423	}
 424	repo, code := resolveRepo(c, args[0], policy.CanRead)
 425	if code >= 0 {
 426		return code
 427	}
 428	desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
 429	topics, err := c.Store.ListTopics(repo.ID)
 430	if err != nil {
 431		return c.fail(protocol.ExitFailure, "%v", err)
 432	}
 433	var domains []string
 434	if ds, err := c.Store.ListPageDomains(repo.ID); err == nil {
 435		for _, pd := range ds {
 436			if pd.Verified() {
 437				domains = append(domains, pd.Domain)
 438			}
 439		}
 440	}
 441	d := repoShowOut{Path: repo.Path(), Description: desc, Website: repo.Settings.Website, Visibility: repo.Visibility,
 442		DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches,
 443		Archived: repo.Settings.Archived, Topics: topics, Domains: domains}
 444	if repo.ForkOf != 0 {
 445		if parent, err := c.Store.RepoByID(repo.ForkOf); err == nil {
 446			if grant, err := c.Store.AccessRole(parent.ID, c.User.ID); err == nil && policy.CanRead(c.User, parent, grant) {
 447				d.ForkOf = parent.Path()
 448			}
 449		}
 450	}
 451	if c.User.ID != 0 {
 452		d.Watch = c.Store.RepoWatchState(repo.ID, c.User.ID)
 453		d.Bookmarked = c.Store.IsBookmarked(c.User.ID, repo.ID)
 454	}
 455	// Mirror status is admin-only, like repo mirror list. The token never
 456	// leaves the server.
 457	if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
 458		ms, err := c.Store.ListMirrors(repo.ID)
 459		if err != nil {
 460			return c.fail(protocol.ExitFailure, "%v", err)
 461		}
 462		for _, m := range ms {
 463			d.Mirrors = append(d.Mirrors, repoMirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
 464		}
 465	}
 466	var glance repoGlance
 467	var mrs []store.MR
 468	var issues []store.Issue
 469	var commits []CommitOut
 470	if c.Term.Cols > 0 && !c.JSON {
 471		glance = repoAtAGlance(c, repo)
 472		mrs, _ = c.Store.ListMRs(repo.ID, "open", 5, 0)
 473		issues, _ = c.Store.QueryIssues(repo.ID, store.IssueFilter{State: "open", Limit: 5})
 474		dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 475		if shas, err := gitutil.RevList(dir, "refs/heads/"+repo.DefaultBranch, 5); err == nil {
 476			subjects := gitutil.Subjects(dir, shas)
 477			for _, sha := range shas {
 478				commits = append(commits, CommitOut{sha, subjects[sha]})
 479			}
 480		}
 481	}
 482	return c.emitView(d, func(w io.Writer) {
 483		bookmarked, archived := "", ""
 484		if d.Bookmarked {
 485			bookmarked = "yes"
 486		}
 487		if d.Archived {
 488			archived = "yes"
 489		}
 490		v := c.view(w)
 491		v.title(d.Path, d.Description, d.Visibility)
 492		v.fields(
 493			"default branch", d.DefaultBranch,
 494			"website", d.Website,
 495			"topics", strings.Join(d.Topics, ", "),
 496			"protected", strings.Join(d.ProtectedBranches, ", "),
 497			"pages domains", strings.Join(d.Domains, ", "),
 498			"fork of", d.ForkOf,
 499			"watch", d.Watch,
 500			"bookmarked", bookmarked,
 501			"archived", archived,
 502			"url", c.siteURL(d.Path),
 503		)
 504		if len(d.Mirrors) > 0 {
 505			v.section("mirror")
 506			tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS")
 507			for _, m := range d.Mirrors {
 508				status := "ok"
 509				if m.Pending {
 510					status = "pending"
 511				}
 512				if m.LastError != "" {
 513					status = "error: " + m.LastError
 514				}
 515				tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(c.when(m.LastSync))), cState(status))
 516			}
 517			tb.flush()
 518		}
 519	}, func() screen { return repoShowScreen(c, d, glance, mrs, issues, commits) })
 520}
 521
 522// repoGlance is what repo show adds at a terminal: how to clone it and
 523// what is going on in it.
 524type repoGlance struct {
 525	clone, release, checks string
 526	issuesN, mrsN          int
 527}
 528
 529// repoAtAGlance reads the glance fields. Each is left blank when it
 530// cannot be read: they are a summary, not the command's result.
 531func repoAtAGlance(c *Ctx, repo store.Repo) repoGlance {
 532	host := c.Cfg.SiteHost()
 533	if c.Cfg.SSH.Port != 22 {
 534		host += ":" + strconv.Itoa(c.Cfg.SSH.Port)
 535	}
 536	g := repoGlance{clone: "ssh://git@" + host + "/" + repo.Path() + ".git"}
 537	g.issuesN, g.mrsN = c.Store.OpenCounts(repo.ID)
 538	if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 {
 539		g.release = rs[0].Tag + ", " + relAge(rs[0].CreatedAt, termNow())
 540	}
 541	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 542	if tip, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
 543		if sts, err := c.Store.ListCommitStatuses(repo.ID, tip); err == nil {
 544			if m := checksMark(sts); m.s != "" {
 545				g.checks = m.s + " on " + repo.DefaultBranch
 546			}
 547		}
 548	}
 549	return g
 550}
 551
 552func runRepoTransfer(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	newOwner := args[1]
 561	if newOwner == repo.OwnerName {
 562		return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
 563	}
 564
 565	// Target: yourself, or an org you admin — same rule as repo create.
 566	newKind, newID := "", int64(0)
 567	if newOwner == c.User.Username {
 568		newKind, newID = "user", c.User.ID
 569	} else if org, err := c.Store.OrgByName(newOwner); err == nil {
 570		role, err := c.Store.OrgRole(org.ID, c.User.ID)
 571		if err != nil {
 572			return c.fail(protocol.ExitFailure, "%v", err)
 573		}
 574		if role != "admin" {
 575			return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
 576		}
 577		newKind, newID = "org", org.ID
 578	} else {
 579		return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
 580	}
 581
 582	oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 583	newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
 584	if _, err := os.Stat(newDir); err == nil {
 585		return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
 586	}
 587	release, lockCode := holdOffBackup(c)
 588	if lockCode >= 0 {
 589		return lockCode
 590	}
 591	defer release()
 592	// The directory moves before the record changes: a move that fails
 593	// leaves nothing to undo, whereas the record's change into an org
 594	// folds labels and milestones into the org's rows, which a revert
 595	// cannot unfold (#212). A record that then fails moves the directory
 596	// back, and says so if even that fails, since the operator then has
 597	// a row pointing at a directory that is not there.
 598	if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
 599		return c.fail(protocol.ExitFailure, "%v", err)
 600	}
 601	if err := os.Rename(oldDir, newDir); err != nil {
 602		return c.fail(protocol.ExitFailure, "moving repository: %v", err)
 603	}
 604	if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
 605		if rerr := os.Rename(newDir, oldDir); rerr != nil {
 606			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)
 607		}
 608		return c.failErr(err)
 609	}
 610	newPath := newOwner + "/" + repo.Name
 611	return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
 612		fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
 613	})
 614}
 615
 616func runRepoRename(c *Ctx, args []string) int {
 617	if len(args) != 2 {
 618		return c.usage()
 619	}
 620	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 621	if code >= 0 {
 622		return code
 623	}
 624	newName := args[1]
 625	if newName == repo.Name {
 626		return c.fail(protocol.ExitUsage, "%s is already named %s", repo.Path(), newName)
 627	}
 628	if err := policyValidateRepoName(newName); err != nil {
 629		return c.failInput(err)
 630	}
 631	oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 632	newDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, newName)
 633	if _, err := os.Stat(newDir); err == nil {
 634		return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", repo.OwnerName, newName)
 635	}
 636	release, lockCode := holdOffBackup(c)
 637	if lockCode >= 0 {
 638		return lockCode
 639	}
 640	defer release()
 641	if err := c.Store.RenameRepo(repo.ID, newName); err != nil {
 642		return c.failErr(err)
 643	}
 644	if err := os.Rename(oldDir, newDir); err != nil {
 645		// Same rule as transfer: keep name and disk consistent, and say so
 646		// if even the revert fails.
 647		if rerr := c.Store.RenameRepo(repo.ID, repo.Name); rerr != nil {
 648			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())
 649		}
 650		return c.fail(protocol.ExitFailure, "moving repository: %v", err)
 651	}
 652	newPath := repo.OwnerName + "/" + newName
 653	return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
 654		fmt.Fprintf(w, "renamed %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
 655	})
 656}
 657
 658func runRepoDelete(c *Ctx, args []string) int {
 659	var path string
 660	var yes bool
 661	for _, a := range args {
 662		if a == "--yes" {
 663			yes = true
 664		} else if path == "" {
 665			path = a
 666		} else {
 667			return c.usage()
 668		}
 669	}
 670	if path == "" {
 671		return c.usage()
 672	}
 673	repo, code := resolveRepo(c, path, policy.CanAdmin)
 674	if code >= 0 {
 675		return code
 676	}
 677	if !yes {
 678		return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
 679	}
 680	return deleteRepo(c, repo)
 681}
 682
 683// deleteRepo removes a repository the caller has already been cleared to
 684// delete: the database row, then the directory.
 685//
 686// There is deliberately no repo.deleted event. events.repo_id and
 687// webhooks.repo_id both cascade from repos, so recording one would delete
 688// it, and every webhook that could have subscribed, in the same
 689// statement. A repository's deletion is not observable through its own
 690// webhooks; an instance that needs to hear about it wants the audit log
 691// (#112).
 692func deleteRepo(c *Ctx, repo store.Repo) int {
 693	release, lockCode := holdOffBackup(c)
 694	if lockCode >= 0 {
 695		return lockCode
 696	}
 697	defer release()
 698	// Open MRs sourced from this repo keep working (targets own the
 699	// objects) but must show that the source is gone.
 700	if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
 701		return c.fail(protocol.ExitFailure, "%v", err)
 702	}
 703	if err := c.Store.DeleteRepo(repo.ID); err != nil {
 704		return c.fail(protocol.ExitFailure, "%v", err)
 705	}
 706	if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
 707		return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
 708	}
 709	return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
 710		fmt.Fprintf(w, "deleted %s\n", repo.Path())
 711	})
 712}
 713
 714// holdOffBackup keeps a full backup from starting while a repository
 715// directory moves or goes, and refuses while one runs: the backup's
 716// database snapshot names every repository its walk then archives
 717// (#259). The caller defers the returned release.
 718func holdOffBackup(c *Ctx) (func(), int) {
 719	release, err := backuplock.TryShared(c.Cfg.Server.Root)
 720	if err != nil {
 721		return nil, c.fail(protocol.ExitFailure, "%v", err)
 722	}
 723	return release, -1
 724}
 725
 726func runAccessGrant(c *Ctx, args []string) int {
 727	if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
 728		return c.usage()
 729	}
 730	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 731	if code >= 0 {
 732		return code
 733	}
 734	target, err := c.Store.UserByUsername(args[1])
 735	if err != nil {
 736		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
 737	}
 738	if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
 739		return c.fail(protocol.ExitFailure, "%v", err)
 740	}
 741	return c.emit(map[string]string{"granted": args[2], "user": target.Username},
 742		func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
 743}
 744
 745func runAccessRevoke(c *Ctx, args []string) int {
 746	if len(args) != 2 {
 747		return c.usage()
 748	}
 749	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 750	if code >= 0 {
 751		return code
 752	}
 753	target, err := c.Store.UserByUsername(args[1])
 754	if err != nil {
 755		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
 756	}
 757	if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
 758		if errors.Is(err, store.ErrNotFound) {
 759			return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
 760		}
 761		return c.fail(protocol.ExitFailure, "%v", err)
 762	}
 763	return c.emit(map[string]string{"revoked": target.Username},
 764		func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
 765}
 766
 767func runAccessList(c *Ctx, args []string) int {
 768	if len(args) != 1 {
 769		return c.usage()
 770	}
 771	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 772	if code >= 0 {
 773		return code
 774	}
 775	entries, err := c.Store.EffectiveAccess(repo.ID)
 776	if err != nil {
 777		return c.fail(protocol.ExitFailure, "%v", err)
 778	}
 779	type out struct {
 780		User   string `json:"user"`
 781		Role   string `json:"role"`
 782		Source string `json:"source"`
 783	}
 784	var ds []out
 785	for _, e := range entries {
 786		ds = append(ds, out{e.Username, e.Role, e.Source})
 787	}
 788	return c.emitView(ds, func(w io.Writer) {
 789		tb := c.table(w, "USER", "ROLE", "SOURCE")
 790		for _, d := range ds {
 791			tb.row(cRef(d.User), cState(d.Role), cText("via "+d.Source))
 792		}
 793		tb.flush()
 794	}, func() screen {
 795		rows := make([]row, len(ds))
 796		for i, d := range ds {
 797			rows[i] = rowOf(cRef(d.User), cState(d.Role), cMeta("via "+d.Source))
 798		}
 799		return listScreen("Access", rows,
 800			action{"Access", []string{"repo", "access", "grant", repo.Path(), "<user>", "write"}},
 801			action{"Access", []string{"repo", "access", "revoke", repo.Path(), "<user>"}},
 802		)
 803	})
 804}
 805
 806func runSettingsShow(c *Ctx, args []string) int {
 807	if len(args) != 1 {
 808		return c.usage()
 809	}
 810	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 811	if code >= 0 {
 812		return code
 813	}
 814	return c.emitView(repo.Settings, func(w io.Writer) {
 815		v := c.view(w)
 816		v.title(repo.Path(), "settings", "")
 817		v.fields(
 818			"protected branches", strings.Join(repo.Settings.ProtectedBranches, ", "),
 819			"protected tags", strings.Join(repo.Settings.ProtectedTags, ", "),
 820			"require mr", strconv.FormatBool(repo.Settings.RequireMR),
 821			"require checks", strconv.FormatBool(repo.Settings.RequireChecks),
 822			"required contexts", strings.Join(repo.Settings.RequiredContexts, ", "),
 823			"require signed commits", strconv.FormatBool(repo.Settings.RequireSignedCommits),
 824			"git daemon", strconv.FormatBool(repo.Settings.GitDaemon),
 825			"archived", strconv.FormatBool(repo.Settings.Archived),
 826		)
 827	}, func() screen {
 828		set := repo.Settings
 829		onOff := func(b bool) cell {
 830			if b {
 831				return cText("on")
 832			}
 833			return cMeta("off")
 834		}
 835		approvals := cMeta("off")
 836		if set.RequireApprovals > 0 {
 837			approvals = cText(strconv.Itoa(set.RequireApprovals))
 838		}
 839		list := func(xs []string) cell {
 840			if len(xs) == 0 {
 841				return cMeta("none")
 842			}
 843			return cText(strings.Join(xs, ", "))
 844		}
 845		mr := "on"
 846		if set.RequireMR {
 847			mr = "off"
 848		}
 849		s := screen{fields: []field{
 850			{"Repo", []cell{cLink(repo.Path(), c.siteURL(repo.Path())), cMeta("settings")}},
 851			{"Protected", []cell{list(set.ProtectedBranches)}},
 852			{"Protected tags", []cell{list(set.ProtectedTags)}},
 853			{"Require MR", []cell{onOff(set.RequireMR)}},
 854			{"Require checks", []cell{onOff(set.RequireChecks)}},
 855			{"Contexts", []cell{list(set.RequiredContexts)}},
 856			{"Approvals", []cell{approvals}},
 857			{"Resolved threads", []cell{onOff(set.RequireResolved)}},
 858			{"Code owners", []cell{onOff(set.RequireCodeowners)}},
 859			{"Signed commits", []cell{onOff(set.RequireSignedCommits)}},
 860			{"Git daemon", []cell{onOff(set.GitDaemon)}},
 861			{"Archived", []cell{onOff(set.Archived)}},
 862		}, actions: []action{
 863			{"Settings", []string{"repo", "settings", "protect", repo.Path(), "<branch>"}},
 864			{"Settings", []string{"repo", "settings", "require-mr", repo.Path(), mr}},
 865		}}
 866		if set.Website != "" {
 867			s.fields = append(s.fields, field{"Website", []cell{cText(set.Website)}})
 868		}
 869		return s
 870	})
 871}
 872
 873func runSetDescription(c *Ctx, args []string) int {
 874	if len(args) != 2 {
 875		return c.usage()
 876	}
 877	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 878	if code >= 0 {
 879		return code
 880	}
 881	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 882	if err := gitutil.WriteDescription(dir, args[1]); err != nil {
 883		return c.fail(protocol.ExitFailure, "%v", err)
 884	}
 885	return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
 886		fmt.Fprintf(w, "description set on %s\n", repo.Path())
 887	})
 888}
 889
 890func runSetDefaultBranch(c *Ctx, args []string) int {
 891	if len(args) != 2 {
 892		return c.usage()
 893	}
 894	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 895	if code >= 0 {
 896		return code
 897	}
 898	branch := args[1]
 899	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 900	if _, err := gitutil.ResolveRef(dir, "refs/heads/"+branch); err != nil {
 901		return c.fail(protocol.ExitFailure, "no branch named %q on %s", branch, repo.Path())
 902	}
 903	if err := gitutil.SetHead(dir, branch); err != nil {
 904		return c.fail(protocol.ExitFailure, "%v", err)
 905	}
 906	if err := c.Store.UpdateDefaultBranch(repo.ID, branch); err != nil {
 907		return c.fail(protocol.ExitFailure, "%v", err)
 908	}
 909	c.Store.RequestSymbolIndex(repo.ID, false)
 910	return c.emit(map[string]string{"default_branch": branch}, func(w io.Writer) {
 911		fmt.Fprintf(w, "default branch of %s is now %s\n", repo.Path(), branch)
 912	})
 913}
 914
 915func runSetWebsite(c *Ctx, args []string) int {
 916	if len(args) != 2 {
 917		return c.usage()
 918	}
 919	site := strings.TrimSpace(args[1])
 920	if err := validateWebsite(site); err != nil {
 921		return c.failInput(err)
 922	}
 923	if len(site) > 256 {
 924		return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
 925	}
 926	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 927	if code >= 0 {
 928		return code
 929	}
 930	if _, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Website = site }); err != nil {
 931		return c.fail(protocol.ExitFailure, "%v", err)
 932	}
 933	return c.emit(map[string]string{"website": site}, func(w io.Writer) {
 934		if site == "" {
 935			fmt.Fprintf(w, "website cleared on %s\n", repo.Path())
 936		} else {
 937			fmt.Fprintf(w, "website set on %s\n", repo.Path())
 938		}
 939	})
 940}
 941
 942func runSetVisibility(c *Ctx, args []string) int {
 943	if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
 944		return c.usage()
 945	}
 946	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 947	if code >= 0 {
 948		return code
 949	}
 950	return setRepoVisibility(c, repo, args[1])
 951}
 952
 953// setRepoVisibility applies a visibility change the caller has already
 954// been cleared to make.
 955func setRepoVisibility(c *Ctx, repo store.Repo, visibility string) int {
 956	if repo.Visibility == visibility {
 957		return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
 958			fmt.Fprintf(w, "%s is already %s\n", repo.Path(), visibility)
 959		})
 960	}
 961	if err := c.Store.SetRepoVisibility(repo.ID, visibility); err != nil {
 962		return c.fail(protocol.ExitFailure, "%v", err)
 963	}
 964	// Going private takes the repository off every anonymous surface, so
 965	// git:// exposure cannot outlive the change.
 966	if visibility == "private" && repo.Settings.GitDaemon {
 967		c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = false })
 968	}
 969	c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": visibility})
 970	return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
 971		fmt.Fprintf(w, "%s is now %s\n", repo.Path(), visibility)
 972	})
 973}
 974
 975func runGitDaemon(c *Ctx, args []string) int {
 976	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 977		return c.usage()
 978	}
 979	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 980	if code >= 0 {
 981		return code
 982	}
 983	on := args[1] == "on"
 984	if on && repo.Visibility != "public" {
 985		return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
 986	}
 987	if on && !c.Cfg.GitDaemon.Enabled {
 988		return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
 989	}
 990	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = on })
 991	if err != nil {
 992		return c.fail(protocol.ExitFailure, "%v", err)
 993	}
 994	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
 995}
 996
 997func runArchive(c *Ctx, args []string) int   { return setArchived(c, args, true) }
 998func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
 999
1000func setArchived(c *Ctx, args []string, archived bool) int {
1001	if len(args) != 1 {
1002		return c.usage()
1003	}
1004	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1005	if code >= 0 {
1006		return code
1007	}
1008	return archiveRepo(c, repo, archived)
1009}
1010
1011// archiveRepo flips the archived flag on a repository the caller has
1012// already been cleared to manage.
1013func archiveRepo(c *Ctx, repo store.Repo, archived bool) int {
1014	verb := "archive"
1015	if !archived {
1016		verb = "unarchive"
1017	}
1018	if repo.Settings.Archived == archived {
1019		return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
1020	}
1021	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Archived = archived })
1022	if err != nil {
1023		return c.fail(protocol.ExitFailure, "%v", err)
1024	}
1025	c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
1026	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
1027}
1028
1029// topicsScreen is a repository's topics at a terminal, after a read or
1030// an edit.
1031func topicsScreen(repo store.Repo, topics []string) screen {
1032	rows := make([]row, len(topics))
1033	for i, t := range topics {
1034		rows[i] = rowOf(cRef(t))
1035	}
1036	return listScreen("Topics", rows,
1037		action{"Edit", []string{"repo", "topics", "add", repo.Path(), "<topic>"}},
1038		action{"Edit", []string{"repo", "topics", "remove", repo.Path(), "<topic>"}},
1039	)
1040}
1041
1042func runTopicsList(c *Ctx, args []string) int {
1043	if len(args) != 1 {
1044		return c.usage()
1045	}
1046	repo, code := resolveRepo(c, args[0], policy.CanRead)
1047	if code >= 0 {
1048		return code
1049	}
1050	topics, err := c.Store.ListTopics(repo.ID)
1051	if err != nil {
1052		return c.fail(protocol.ExitFailure, "%v", err)
1053	}
1054	return c.emitView(topics, func(w io.Writer) {
1055		tb := c.table(w, "TOPIC")
1056		for _, t := range topics {
1057			tb.row(cRef(t))
1058		}
1059		tb.flush()
1060	}, func() screen {
1061		return topicsScreen(repo, topics)
1062	})
1063}
1064
1065func runTopicsAdd(c *Ctx, args []string) int    { return editTopics(c, args, true) }
1066func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) }
1067
1068func editTopics(c *Ctx, args []string, add bool) int {
1069	if len(args) < 2 {
1070		return c.usage()
1071	}
1072	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1073	if code >= 0 {
1074		return code
1075	}
1076	topics := args[1:]
1077	if add {
1078		for _, t := range topics {
1079			if err := policy.ValidateTopic(t); err != nil {
1080				return c.failInput(err)
1081			}
1082		}
1083		have, err := c.Store.ListTopics(repo.ID)
1084		if err != nil {
1085			return c.fail(protocol.ExitFailure, "%v", err)
1086		}
1087		added := 0
1088		for _, t := range topics {
1089			if !slices.Contains(have, t) {
1090				added++
1091			}
1092		}
1093		if len(have)+added > policy.MaxTopics {
1094			return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics)
1095		}
1096		for _, t := range topics {
1097			if err := c.Store.AddTopic(repo.ID, t); err != nil {
1098				return c.fail(protocol.ExitFailure, "%v", err)
1099			}
1100		}
1101	} else {
1102		for _, t := range topics {
1103			if err := c.Store.RemoveTopic(repo.ID, t); err != nil {
1104				if errors.Is(err, store.ErrNotFound) {
1105					return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t)
1106				}
1107				return c.fail(protocol.ExitFailure, "%v", err)
1108			}
1109		}
1110	}
1111	now, err := c.Store.ListTopics(repo.ID)
1112	if err != nil {
1113		return c.fail(protocol.ExitFailure, "%v", err)
1114	}
1115	return c.emitView(now, func(w io.Writer) {
1116		tb := c.table(w, "TOPIC")
1117		for _, t := range now {
1118			tb.row(cRef(t))
1119		}
1120		tb.flush()
1121	}, func() screen {
1122		return topicsScreen(repo, now)
1123	})
1124}
1125
1126// runRepoSearch matches the query against name, owner/name, description,
1127// and topics of every repository the caller can see.
1128func runRepoSearch(c *Ctx, args []string) int {
1129	if len(args) != 1 {
1130		return c.usage()
1131	}
1132	if err := validQuery(args[0]); err != nil {
1133		return c.failInput(err)
1134	}
1135	q := strings.ToLower(args[0])
1136
1137	public, err := c.Store.ListPublicRepos()
1138	if err != nil {
1139		return c.fail(protocol.ExitFailure, "%v", err)
1140	}
1141	own, err := c.Store.ListReposForUser(c.User.ID, 0, "")
1142	if err != nil {
1143		return c.fail(protocol.ExitFailure, "%v", err)
1144	}
1145	seen := map[int64]bool{}
1146	type out struct {
1147		Path        string   `json:"path"`
1148		Visibility  string   `json:"visibility"`
1149		Description string   `json:"description,omitempty"`
1150		Topics      []string `json:"topics,omitempty"`
1151	}
1152	var ds []out
1153	for _, r := range append(public, own...) {
1154		if seen[r.ID] {
1155			continue
1156		}
1157		seen[r.ID] = true
1158		desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
1159		topics, _ := c.Store.ListTopics(r.ID)
1160		if !MatchesRepo(q, r.Path(), desc, topics) {
1161			continue
1162		}
1163		ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
1164	}
1165	return c.emitView(ds, func(w io.Writer) {
1166		tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
1167		for _, d := range ds {
1168			tb.row(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description))
1169		}
1170		tb.flush()
1171	}, func() screen {
1172		rows := make([]row, len(ds))
1173		for i, d := range ds {
1174			rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description), cMeta(strings.Join(d.Topics, ", ")))
1175		}
1176		s := listScreen(fmt.Sprintf("Repositories matching %q", args[0]), rows)
1177		if len(ds) > 0 {
1178			s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}}
1179		}
1180		return s
1181	})
1182}
1183
1184// MatchesRepo is the one rule for matching a repository against a text
1185// query: its path, its description, or any of its topics. The web's
1186// /explore filter and /search page call it too, so the three surfaces
1187// cannot answer the same query differently.
1188func MatchesRepo(q, path, desc string, topics []string) bool {
1189	q = strings.ToLower(q)
1190	if strings.Contains(strings.ToLower(path), q) ||
1191		strings.Contains(strings.ToLower(desc), q) {
1192		return true
1193	}
1194	for _, t := range topics {
1195		if strings.Contains(strings.ToLower(t), q) {
1196			return true
1197		}
1198	}
1199	return false
1200}
1201
1202func runRepoGrep(c *Ctx, args []string) int {
1203	f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep <owner/name> <query> [--ref <ref>]"})
1204	if err != nil {
1205		return c.fail(protocol.ExitUsage, "%v", err)
1206	}
1207	path, query, ref := f.pos(0), f.pos(1), f.Value("--ref")
1208	if path == "" || query == "" {
1209		return c.usage()
1210	}
1211	if err := validQuery(query); err != nil {
1212		return c.failInput(err)
1213	}
1214	repo, code := resolveRepo(c, path, policy.CanRead)
1215	if code >= 0 {
1216		return code
1217	}
1218	if ref == "" {
1219		ref = repo.DefaultBranch
1220	}
1221	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1222	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
1223		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
1224	}
1225	matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches)
1226	if err != nil {
1227		return c.fail(protocol.ExitFailure, "%v", err)
1228	}
1229	type out struct {
1230		Path string `json:"path"`
1231		Line int    `json:"line"`
1232		Text string `json:"text"`
1233	}
1234	var ds []out
1235	for _, m := range matches {
1236		ds = append(ds, out{m.Path, m.Line, m.Text})
1237	}
1238	return c.emit(ds, func(w io.Writer) {
1239		for _, d := range ds {
1240			fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text)
1241		}
1242	})
1243}
1244
1245func runRepoPin(c *Ctx, args []string) int   { return setPinned(c, args, true) }
1246func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) }
1247
1248func setPinned(c *Ctx, args []string, pin bool) int {
1249	verb := "pin"
1250	if !pin {
1251		verb = "unpin"
1252	}
1253	if len(args) != 1 {
1254		return c.usage()
1255	}
1256	repo, code := resolveRepo(c, args[0], policy.CanRead)
1257	if code >= 0 {
1258		return code
1259	}
1260	if pin {
1261		if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil {
1262			return c.fail(protocol.ExitFailure, "%v", err)
1263		}
1264	} else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil {
1265		if errors.Is(err, store.ErrNotFound) {
1266			return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path())
1267		}
1268		return c.fail(protocol.ExitFailure, "%v", err)
1269	}
1270	return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) {
1271		fmt.Fprintf(w, "%sned %s\n", verb, repo.Path())
1272	})
1273}
1274
1275func runRepoBookmark(c *Ctx, args []string) int   { return setBookmarked(c, args, true) }
1276func runRepoUnbookmark(c *Ctx, args []string) int { return setBookmarked(c, args, false) }
1277
1278// setBookmarked mirrors setPinned. A bookmark needs only read access —
1279// bookmarking is something you do to someone else's repository, which is
1280// the whole point of it — and a private repository you cannot read is
1281// not found, as everywhere.
1282func setBookmarked(c *Ctx, args []string, on bool) int {
1283	verb := "bookmark"
1284	if !on {
1285		verb = "unbookmark"
1286	}
1287	if len(args) != 1 {
1288		return c.usage()
1289	}
1290	repo, code := resolveRepo(c, args[0], policy.CanRead)
1291	if code >= 0 {
1292		return code
1293	}
1294	if on {
1295		if err := c.Store.BookmarkRepo(c.User.ID, repo.ID); err != nil {
1296			return c.fail(protocol.ExitFailure, "%v", err)
1297		}
1298	} else if err := c.Store.UnbookmarkRepo(c.User.ID, repo.ID); err != nil {
1299		if errors.Is(err, store.ErrNotFound) {
1300			return c.fail(protocol.ExitNotFound, "%s is not bookmarked", repo.Path())
1301		}
1302		return c.fail(protocol.ExitFailure, "%v", err)
1303	}
1304	return c.emit(map[string]string{verb + "ed": repo.Path()}, func(w io.Writer) {
1305		fmt.Fprintf(w, "%sed %s\n", verb, repo.Path())
1306	})
1307}
1308
1309// BookmarkOut is one row of `repo bookmarks`: the repository and how many
1310// people have bookmarked it.
1311type BookmarkOut struct {
1312	Path        string `json:"path"`
1313	Description string `json:"description,omitempty"`
1314	Visibility  string `json:"visibility"`
1315	Bookmarks   int    `json:"bookmarks"`
1316}
1317
1318func runRepoBookmarks(c *Ctx, args []string) int {
1319	if len(args) != 0 {
1320		return c.usage()
1321	}
1322	repos, err := c.Store.ListBookmarks(c.User.ID)
1323	if err != nil {
1324		return c.fail(protocol.ExitFailure, "%v", err)
1325	}
1326	out := []BookmarkOut{}
1327	for _, r := range repos {
1328		// A repository bookmarked while public and since made private
1329		// stays in the table and drops out of the listing, the same way
1330		// it disappears from every other surface.
1331		grant, err := c.Store.AccessRole(r.ID, c.User.ID)
1332		if err != nil {
1333			return c.fail(protocol.ExitFailure, "%v", err)
1334		}
1335		if !policy.CanRead(c.User, r, grant) {
1336			continue
1337		}
1338		out = append(out, BookmarkOut{
1339			Path:        r.Path(),
1340			Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)),
1341			Visibility:  r.Visibility,
1342			Bookmarks:   c.Store.BookmarkCount(r.ID),
1343		})
1344	}
1345	return c.emitView(out, func(w io.Writer) {
1346		tb := c.table(w, "PATH", "COUNT", "DESCRIPTION")
1347		for _, b := range out {
1348			tb.row(cRef(b.Path), cNum(int64(b.Bookmarks)), cFlex(b.Description))
1349		}
1350		tb.flush()
1351	}, func() screen {
1352		rows := make([]row, len(out))
1353		for i, b := range out {
1354			n := fmt.Sprintf("%d bookmarks", b.Bookmarks)
1355			if b.Bookmarks == 1 {
1356				n = "1 bookmark"
1357			}
1358			rows[i] = rowOf(cLink(b.Path, c.siteURL(b.Path)), cState(b.Visibility), cFlex(b.Description), cMeta(n))
1359		}
1360		s := listScreen("Bookmarks", rows)
1361		if len(out) > 0 {
1362			s.actions = []action{{"Read", []string{"repo", "show", out[0].Path}}}
1363		}
1364		return s
1365	})
1366}
1367
1368func runProtectTag(c *Ctx, args []string) int   { return setProtectTag(c, args, true) }
1369func runUnprotectTag(c *Ctx, args []string) int { return setProtectTag(c, args, false) }
1370
1371func setProtectTag(c *Ctx, args []string, protect bool) int {
1372	if len(args) != 2 {
1373		return c.usage()
1374	}
1375	glob := args[1]
1376	if _, err := path.Match(glob, "x"); err != nil || glob == "" {
1377		return c.fail(protocol.ExitUsage, "bad glob %q", glob)
1378	}
1379	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1380	if code >= 0 {
1381		return code
1382	}
1383	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1384		has := slices.Contains(s.ProtectedTags, glob)
1385		if protect && !has {
1386			s.ProtectedTags = append(s.ProtectedTags, glob)
1387			slices.Sort(s.ProtectedTags)
1388		}
1389		if !protect && has {
1390			s.ProtectedTags = slices.DeleteFunc(s.ProtectedTags, func(g string) bool { return g == glob })
1391		}
1392	})
1393	if err != nil {
1394		return c.fail(protocol.ExitFailure, "%v", err)
1395	}
1396	verb := "protected"
1397	if !protect {
1398		verb = "unprotected"
1399	}
1400	return c.emit(s, func(w io.Writer) {
1401		fmt.Fprintf(w, "tags %s %s on %s\n", glob, verb, repo.Path())
1402	})
1403}
1404
1405func runProtect(c *Ctx, args []string) int   { return setProtect(c, args, true) }
1406func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
1407
1408func setProtect(c *Ctx, args []string, protect bool) int {
1409	if len(args) != 2 {
1410		return c.usage()
1411	}
1412	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1413	if code >= 0 {
1414		return code
1415	}
1416	branch := args[1]
1417	// The list is read and rewritten inside the update, so two admins
1418	// protecting different branches at once both land.
1419	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1420		has := slices.Contains(s.ProtectedBranches, branch)
1421		if protect && !has {
1422			s.ProtectedBranches = append(s.ProtectedBranches, branch)
1423			slices.Sort(s.ProtectedBranches)
1424		}
1425		if !protect && has {
1426			s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
1427		}
1428	})
1429	if err != nil {
1430		return c.fail(protocol.ExitFailure, "%v", err)
1431	}
1432	verb := "protected"
1433	if !protect {
1434		verb = "unprotected"
1435	}
1436	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
1437}
1438
1439// runRepoDiff is the compare view's command: what head adds on top of
1440// base, measured from their merge base the way a merge request diff is,
1441// so a base that moved on does not show up as removals (#118).
1442func runRepoDiff(c *Ctx, args []string) int {
1443	f, err := c.parseArgs(args, flagSpec{MaxPos: 3, Usage: "repo diff <owner/name> <base> <head>"})
1444	if err != nil || len(f.Pos) != 3 {
1445		return c.usage()
1446	}
1447	repo, code := resolveRepo(c, f.pos(0), policy.CanRead)
1448	if code >= 0 {
1449		return code
1450	}
1451	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1452	base, err := gitutil.ResolveRef(dir, f.pos(1))
1453	if err != nil {
1454		return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(1), repo.Path())
1455	}
1456	head, err := gitutil.ResolveRef(dir, f.pos(2))
1457	if err != nil {
1458		return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(2), repo.Path())
1459	}
1460	mergeBase, err := gitutil.MergeBase(dir, base, head)
1461	if err != nil {
1462		return c.fail(protocol.ExitUsage, "%v", err)
1463	}
1464	patch, truncated, err := gitutil.Diff(dir, mergeBase, head, 4<<20)
1465	if err != nil {
1466		return c.fail(protocol.ExitFailure, "%v", err)
1467	}
1468	if c.JSON {
1469		return c.emit(map[string]any{"base": base, "head": head, "merge_base": mergeBase, "patch": patch, "truncated": truncated}, nil)
1470	}
1471	fmt.Fprint(c.Stdout, c.Term.diff(patch))
1472	if truncated {
1473		fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB")
1474	}
1475	return protocol.ExitOK
1476}
1477
1478// repoShowScreen is repo show at a terminal: how to clone it, where the
1479// default branch stands, what is open, and the latest commits.
1480func repoShowScreen(c *Ctx, d repoShowOut, g repoGlance, mrs []store.MR, issues []store.Issue, commits []CommitOut) screen {
1481	s := screen{body: d.Description, format: "text"}
1482	name := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility)}
1483	if d.Archived {
1484		name[1].s += ", archived"
1485	}
1486	s.fields = append(s.fields, field{"Repo", name})
1487	if g.clone != "" {
1488		s.fields = append(s.fields, field{"Clone", []cell{cText(g.clone)}})
1489	}
1490	head := []cell{cText(d.DefaultBranch)}
1491	if g.checks != "" {
1492		head = []cell{cText(d.DefaultBranch), cText(strings.TrimSuffix(g.checks, " on "+d.DefaultBranch))}
1493	}
1494	s.fields = append(s.fields, field{"Head", head})
1495	if g.release != "" {
1496		s.fields = append(s.fields, field{"Release", []cell{cText(g.release)}})
1497	}
1498	if len(d.Topics) > 0 {
1499		s.fields = append(s.fields, field{"Topics", []cell{cText(strings.Join(d.Topics, ", "))}})
1500	}
1501	if d.ForkOf != "" {
1502		s.fields = append(s.fields, field{"Fork of", []cell{cRef(d.ForkOf)}})
1503	}
1504	for _, m := range d.Mirrors {
1505		if m.LastError != "" {
1506			s.fields = append(s.fields, field{"Mirror", []cell{cGlyph("failed"), cText(m.Direction + " " + m.URL + ": " + m.LastError)}})
1507		}
1508	}
1509	if !c.Term.Links {
1510		s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(d.Path))}})
1511	}
1512
1513	ms := section{title: "Open merge requests", n: g.mrsN, more: []string{"mr", "list", d.Path}}
1514	for _, m := range mrs {
1515		ms.rows = append(ms.rows, rowOf(cRef(fmt.Sprintf("!%d", m.Number)), cFlex(m.Title), cAge(m.UpdatedAt)))
1516	}
1517	is := section{title: "Open issues", n: g.issuesN, more: []string{"issue", "list", d.Path}}
1518	for _, i := range issues {
1519		is.rows = append(is.rows, rowOf(cRef(fmt.Sprintf("#%d", i.Number)), cFlex(i.Title), cAge(i.UpdatedAt)))
1520	}
1521	cs := section{title: "Recent commits", n: len(commits), more: []string{"repo", "log", d.Path}}
1522	for _, cm := range commits {
1523		cs.rows = append(cs.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject)))
1524	}
1525	s.sections = []section{ms, is, cs}
1526	s.actions = []action{
1527		{"Contribute", []string{"mr", "create", d.Path}},
1528		{"Contribute", []string{"issue", "create", d.Path}},
1529		{"Read", []string{"repo", "log", d.Path}},
1530	}
1531	return s
1532}