internal/control/repo.go

9df917e73a67d15adecc3f45976690f6fcd4e47a
gitbay/internal/control/repo.go history · blame · raw

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