internal/control/repo.go

c90a10435ba3187b80e54031d1ef7219d0b436ee
gitbay/internal/control/repo.go history · blame · raw

589 lines · 21121 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: repo create <owner/name> [--private]", Run: runRepoCreate})
 29	register(Command{Path: []string{"repo", "list"},
 30		Summary: "list repositories you own or can access", ReadOnly: true, Run: runRepoList})
 31	register(Command{Path: []string{"repo", "show"},
 32		Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow})
 33	register(Command{Path: []string{"repo", "transfer"},
 34		Summary: "move a repository to another owner: repo transfer <owner/name> <new-owner> (clone URLs change)", Run: runRepoTransfer})
 35	register(Command{Path: []string{"repo", "delete"},
 36		Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete})
 37	register(Command{Path: []string{"repo", "access", "grant"},
 38		Summary: "grant access: repo access grant <owner/name> <user> read|write|admin", Run: runAccessGrant})
 39	register(Command{Path: []string{"repo", "access", "revoke"},
 40		Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke})
 41	register(Command{Path: []string{"repo", "access", "list"},
 42		Summary: "list access grants: repo access list <owner/name>", ReadOnly: true, Run: runAccessList})
 43	register(Command{Path: []string{"repo", "settings", "show"},
 44		Summary: "show settings: repo settings show <owner/name>", ReadOnly: true, Run: runSettingsShow})
 45	register(Command{Path: []string{"repo", "settings", "protect"},
 46		Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
 47	register(Command{Path: []string{"repo", "settings", "unprotect"},
 48		Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
 49	register(Command{Path: []string{"repo", "settings", "description"},
 50		Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription})
 51	register(Command{Path: []string{"repo", "settings", "git-daemon"},
 52		Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
 53	register(Command{Path: []string{"repo", "archive"},
 54		Summary: "archive a repository (read-only: pushes and issue/MR writes refused): repo archive <owner/name>", Run: runArchive})
 55	register(Command{Path: []string{"repo", "unarchive"},
 56		Summary: "unarchive a repository: repo unarchive <owner/name>", Run: runUnarchive})
 57	register(Command{Path: []string{"repo", "topics"},
 58		Summary: "list topics: repo topics <owner/name>", ReadOnly: true, Run: runTopicsList})
 59	register(Command{Path: []string{"repo", "topics", "add"},
 60		Summary: "add topics: repo topics add <owner/name> <topic>...", Run: runTopicsAdd})
 61	register(Command{Path: []string{"repo", "topics", "remove"},
 62		Summary: "remove topics: repo topics remove <owner/name> <topic>...", Run: runTopicsRemove})
 63}
 64
 65// refuseArchived blocks content writes (pushes are refused in the transport
 66// layer) on archived repositories. Settings, access, and lifecycle commands
 67// stay available so an archived repo can be managed and unarchived.
 68func refuseArchived(c *Ctx, repo store.Repo) int {
 69	if repo.Settings.Archived {
 70		return c.fail(protocol.ExitDenied, "%s is archived and read-only", repo.Path())
 71	}
 72	return -1
 73}
 74
 75// resolveRepo loads a repo and checks the given permission for c.User.
 76func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
 77	repo, err := c.Store.RepoByPath(path)
 78	if err != nil {
 79		if errors.Is(err, store.ErrNotFound) {
 80			// Same message whether it doesn't exist or is invisible.
 81			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
 82		}
 83		return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
 84	}
 85	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
 86	if err != nil {
 87		return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
 88	}
 89	if !check(c.User, repo, grant) {
 90		if !policy.CanRead(c.User, repo, grant) {
 91			// Invisible repos 404, per the enumeration rule.
 92			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
 93		}
 94		return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
 95	}
 96	return repo, -1
 97}
 98
 99func runRepoCreate(c *Ctx, args []string) int {
100	visibility := "public"
101	var path, description string
102	for i := 0; i < len(args); i++ {
103		switch args[i] {
104		case "--private":
105			visibility = "private"
106		case "--description":
107			if i+1 >= len(args) {
108				return c.fail(protocol.ExitUsage, "--description requires a value")
109			}
110			description = args[i+1]
111			i++
112		default:
113			if path != "" {
114				return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private] [--description <text>]")
115			}
116			path = args[i]
117		}
118	}
119	owner, name, ok := strings.Cut(path, "/")
120	if !ok {
121		return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
122	}
123	if err := policyValidateRepoName(name); err != nil {
124		return c.fail(protocol.ExitUsage, "%v", err)
125	}
126	ownerKind, ownerID := "user", c.User.ID
127	if owner != c.User.Username {
128		org, err := c.Store.OrgByName(owner)
129		if err != nil {
130			return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
131		}
132		role, err := c.Store.OrgRole(org.ID, c.User.ID)
133		if err != nil {
134			return c.fail(protocol.ExitFailure, "%v", err)
135		}
136		if role != "admin" {
137			return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
138		}
139		ownerKind, ownerID = "org", org.ID
140	}
141	id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
142	if err != nil {
143		return c.fail(protocol.ExitFailure, "%v", err)
144	}
145	dir := RepoDir(c.Cfg.Server.Root, owner, name)
146	if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
147		c.Store.DeleteRepo(id)
148		return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
149	}
150	if description != "" {
151		if err := gitutil.WriteDescription(dir, description); err != nil {
152			return c.fail(protocol.ExitFailure, "writing description: %v", err)
153		}
154	}
155	type out struct {
156		Path       string `json:"path"`
157		Visibility string `json:"visibility"`
158		SSHURL     string `json:"ssh_url"`
159	}
160	d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
161	return c.emit(d, func(w io.Writer) {
162		fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
163	})
164}
165
166func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
167
168func hostOf(siteURL string) string {
169	s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
170	return strings.TrimSuffix(s, "/")
171}
172
173func runRepoList(c *Ctx, args []string) int {
174	repos, err := c.Store.ListReposForUser(c.User.ID)
175	if err != nil {
176		return c.fail(protocol.ExitFailure, "%v", err)
177	}
178	type out struct {
179		Path        string `json:"path"`
180		Visibility  string `json:"visibility"`
181		Description string `json:"description,omitempty"`
182		Archived    bool   `json:"archived,omitempty"`
183	}
184	var ds []out
185	for _, r := range repos {
186		desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
187		ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
188	}
189	return c.emit(ds, func(w io.Writer) {
190		for _, d := range ds {
191			mark := ""
192			if d.Archived {
193				mark = "\t[archived]"
194			}
195			fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark)
196		}
197	})
198}
199
200func runRepoShow(c *Ctx, args []string) int {
201	if len(args) != 1 {
202		return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
203	}
204	repo, code := resolveRepo(c, args[0], policy.CanRead)
205	if code >= 0 {
206		return code
207	}
208	type out struct {
209		Path              string   `json:"path"`
210		Description       string   `json:"description,omitempty"`
211		Visibility        string   `json:"visibility"`
212		DefaultBranch     string   `json:"default_branch"`
213		ProtectedBranches []string `json:"protected_branches,omitempty"`
214		Archived          bool     `json:"archived,omitempty"`
215		Topics            []string `json:"topics,omitempty"`
216	}
217	desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
218	topics, err := c.Store.ListTopics(repo.ID)
219	if err != nil {
220		return c.fail(protocol.ExitFailure, "%v", err)
221	}
222	d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches,
223		repo.Settings.Archived, topics}
224	return c.emit(d, func(w io.Writer) {
225		line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch)
226		if d.Archived {
227			line += "\t[archived]"
228		}
229		fmt.Fprintln(w, line)
230		if d.Description != "" {
231			fmt.Fprintf(w, "%s\n", d.Description)
232		}
233		if len(d.Topics) > 0 {
234			fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
235		}
236		if len(d.ProtectedBranches) > 0 {
237			fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
238		}
239	})
240}
241
242func runRepoTransfer(c *Ctx, args []string) int {
243	if len(args) != 2 {
244		return c.fail(protocol.ExitUsage, "usage: repo transfer <owner/name> <new-owner>")
245	}
246	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
247	if code >= 0 {
248		return code
249	}
250	newOwner := args[1]
251	if newOwner == repo.OwnerName {
252		return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
253	}
254
255	// Target: yourself, or an org you admin — same rule as repo create.
256	newKind, newID := "", int64(0)
257	if newOwner == c.User.Username {
258		newKind, newID = "user", c.User.ID
259	} else if org, err := c.Store.OrgByName(newOwner); err == nil {
260		role, err := c.Store.OrgRole(org.ID, c.User.ID)
261		if err != nil {
262			return c.fail(protocol.ExitFailure, "%v", err)
263		}
264		if role != "admin" {
265			return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
266		}
267		newKind, newID = "org", org.ID
268	} else {
269		return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
270	}
271
272	oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
273	newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
274	if _, err := os.Stat(newDir); err == nil {
275		return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
276	}
277	if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
278		return c.fail(protocol.ExitUsage, "%v", err)
279	}
280	if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
281		c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
282		return c.fail(protocol.ExitFailure, "%v", err)
283	}
284	if err := os.Rename(oldDir, newDir); err != nil {
285		// Keep name and disk consistent: revert the database change.
286		c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
287		return c.fail(protocol.ExitFailure, "moving repository: %v", err)
288	}
289	newPath := newOwner + "/" + repo.Name
290	return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
291		fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
292	})
293}
294
295func runRepoDelete(c *Ctx, args []string) int {
296	var path string
297	var yes bool
298	for _, a := range args {
299		if a == "--yes" {
300			yes = true
301		} else if path == "" {
302			path = a
303		} else {
304			return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
305		}
306	}
307	if path == "" {
308		return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
309	}
310	repo, code := resolveRepo(c, path, policy.CanAdmin)
311	if code >= 0 {
312		return code
313	}
314	if !yes {
315		return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
316	}
317	// Open MRs sourced from this repo keep working (targets own the
318	// objects) but must show that the source is gone.
319	if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
320		return c.fail(protocol.ExitFailure, "%v", err)
321	}
322	if err := c.Store.DeleteRepo(repo.ID); err != nil {
323		return c.fail(protocol.ExitFailure, "%v", err)
324	}
325	if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
326		return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
327	}
328	return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
329		fmt.Fprintf(w, "deleted %s\n", repo.Path())
330	})
331}
332
333func runAccessGrant(c *Ctx, args []string) int {
334	if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
335		return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
336	}
337	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
338	if code >= 0 {
339		return code
340	}
341	target, err := c.Store.UserByUsername(args[1])
342	if err != nil {
343		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
344	}
345	if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
346		return c.fail(protocol.ExitFailure, "%v", err)
347	}
348	return c.emit(map[string]string{"granted": args[2], "user": target.Username},
349		func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
350}
351
352func runAccessRevoke(c *Ctx, args []string) int {
353	if len(args) != 2 {
354		return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
355	}
356	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
357	if code >= 0 {
358		return code
359	}
360	target, err := c.Store.UserByUsername(args[1])
361	if err != nil {
362		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
363	}
364	if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
365		if errors.Is(err, store.ErrNotFound) {
366			return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
367		}
368		return c.fail(protocol.ExitFailure, "%v", err)
369	}
370	return c.emit(map[string]string{"revoked": target.Username},
371		func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
372}
373
374func runAccessList(c *Ctx, args []string) int {
375	if len(args) != 1 {
376		return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
377	}
378	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
379	if code >= 0 {
380		return code
381	}
382	entries, err := c.Store.ListAccess(repo.ID)
383	if err != nil {
384		return c.fail(protocol.ExitFailure, "%v", err)
385	}
386	type out struct {
387		User string `json:"user"`
388		Role string `json:"role"`
389	}
390	var ds []out
391	for _, e := range entries {
392		ds = append(ds, out{e.Username, e.Role})
393	}
394	return c.emit(ds, func(w io.Writer) {
395		for _, d := range ds {
396			fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
397		}
398	})
399}
400
401func runSettingsShow(c *Ctx, args []string) int {
402	if len(args) != 1 {
403		return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
404	}
405	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
406	if code >= 0 {
407		return code
408	}
409	return c.emit(repo.Settings, func(w io.Writer) {
410		fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
411			strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
412	})
413}
414
415func runSetDescription(c *Ctx, args []string) int {
416	if len(args) != 2 {
417		return c.fail(protocol.ExitUsage, "usage: repo settings description <owner/name> <text>")
418	}
419	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
420	if code >= 0 {
421		return code
422	}
423	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
424	if err := gitutil.WriteDescription(dir, args[1]); err != nil {
425		return c.fail(protocol.ExitFailure, "%v", err)
426	}
427	return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
428		fmt.Fprintf(w, "description set on %s\n", repo.Path())
429	})
430}
431
432func runGitDaemon(c *Ctx, args []string) int {
433	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
434		return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
435	}
436	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
437	if code >= 0 {
438		return code
439	}
440	on := args[1] == "on"
441	if on && repo.Visibility != "public" {
442		return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
443	}
444	if on && !c.Cfg.GitDaemon.Enabled {
445		return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
446	}
447	s := repo.Settings
448	s.GitDaemon = on
449	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
450		return c.fail(protocol.ExitFailure, "%v", err)
451	}
452	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
453}
454
455func runArchive(c *Ctx, args []string) int   { return setArchived(c, args, true) }
456func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
457
458func setArchived(c *Ctx, args []string, archived bool) int {
459	verb := "archive"
460	if !archived {
461		verb = "unarchive"
462	}
463	if len(args) != 1 {
464		return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
465	}
466	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
467	if code >= 0 {
468		return code
469	}
470	if repo.Settings.Archived == archived {
471		return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
472	}
473	s := repo.Settings
474	s.Archived = archived
475	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
476		return c.fail(protocol.ExitFailure, "%v", err)
477	}
478	c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
479	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
480}
481
482func runTopicsList(c *Ctx, args []string) int {
483	if len(args) != 1 {
484		return c.fail(protocol.ExitUsage, "usage: repo topics <owner/name>")
485	}
486	repo, code := resolveRepo(c, args[0], policy.CanRead)
487	if code >= 0 {
488		return code
489	}
490	topics, err := c.Store.ListTopics(repo.ID)
491	if err != nil {
492		return c.fail(protocol.ExitFailure, "%v", err)
493	}
494	return c.emit(topics, func(w io.Writer) {
495		for _, t := range topics {
496			fmt.Fprintln(w, t)
497		}
498	})
499}
500
501func runTopicsAdd(c *Ctx, args []string) int    { return editTopics(c, args, true) }
502func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) }
503
504func editTopics(c *Ctx, args []string, add bool) int {
505	verb := "add"
506	if !add {
507		verb = "remove"
508	}
509	if len(args) < 2 {
510		return c.fail(protocol.ExitUsage, "usage: repo topics %s <owner/name> <topic>...", verb)
511	}
512	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
513	if code >= 0 {
514		return code
515	}
516	topics := args[1:]
517	if add {
518		for _, t := range topics {
519			if err := policy.ValidateTopic(t); err != nil {
520				return c.fail(protocol.ExitUsage, "%v", err)
521			}
522		}
523		have, err := c.Store.ListTopics(repo.ID)
524		if err != nil {
525			return c.fail(protocol.ExitFailure, "%v", err)
526		}
527		added := 0
528		for _, t := range topics {
529			if !slices.Contains(have, t) {
530				added++
531			}
532		}
533		if len(have)+added > policy.MaxTopics {
534			return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics)
535		}
536		for _, t := range topics {
537			if err := c.Store.AddTopic(repo.ID, t); err != nil {
538				return c.fail(protocol.ExitFailure, "%v", err)
539			}
540		}
541	} else {
542		for _, t := range topics {
543			if err := c.Store.RemoveTopic(repo.ID, t); err != nil {
544				if errors.Is(err, store.ErrNotFound) {
545					return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t)
546				}
547				return c.fail(protocol.ExitFailure, "%v", err)
548			}
549		}
550	}
551	now, err := c.Store.ListTopics(repo.ID)
552	if err != nil {
553		return c.fail(protocol.ExitFailure, "%v", err)
554	}
555	return c.emit(now, func(w io.Writer) {
556		fmt.Fprintf(w, "topics on %s: %s\n", repo.Path(), strings.Join(now, ", "))
557	})
558}
559
560func runProtect(c *Ctx, args []string) int   { return setProtect(c, args, true) }
561func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
562
563func setProtect(c *Ctx, args []string, protect bool) int {
564	if len(args) != 2 {
565		return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
566	}
567	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
568	if code >= 0 {
569		return code
570	}
571	branch := args[1]
572	s := repo.Settings
573	has := slices.Contains(s.ProtectedBranches, branch)
574	if protect && !has {
575		s.ProtectedBranches = append(s.ProtectedBranches, branch)
576		slices.Sort(s.ProtectedBranches)
577	}
578	if !protect && has {
579		s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
580	}
581	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
582		return c.fail(protocol.ExitFailure, "%v", err)
583	}
584	verb := "protected"
585	if !protect {
586		verb = "unprotected"
587	}
588	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
589}