internal/control/repo.go
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}