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