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