internal/control/repo.go

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