internal/control/repo.go

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