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