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