internal/control/repo.go

387b381242a77e37af3a58b7301f6365b176603b
gitbay/internal/control/repo.go history · blame · raw

1069 lines · 38112 bytes

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