internal/control/repo.go

3797906826d106b7e052e9d1a95958af851ec052
gitbay/internal/control/repo.go history · blame · raw

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