internal/control/repo.go
1376 lines · 47589 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{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)}
367 if d.Archived {
368 cells = c.note(cells, 1, "[archived]", "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 var glance repoGlance
449 if c.Term.Cols > 0 && !c.JSON {
450 glance = repoAtAGlance(c, repo)
451 }
452 return c.emit(d, func(w io.Writer) {
453 bookmarked, archived := "", ""
454 if d.Bookmarked {
455 bookmarked = "yes"
456 }
457 if d.Archived {
458 archived = "yes"
459 }
460 v := c.view(w)
461 if c.Term.Cols > 0 {
462 v.title(d.Path, "", d.Visibility)
463 v.text(d.Description)
464 v.fields(
465 "clone", glance.clone,
466 "issues", glance.issues,
467 "merge requests", glance.mrs,
468 "release", glance.release,
469 "checks", glance.checks,
470 "default branch", d.DefaultBranch,
471 "website", d.Website,
472 "topics", strings.Join(d.Topics, ", "),
473 "protected", strings.Join(d.ProtectedBranches, ", "),
474 "pages domains", strings.Join(d.Domains, ", "),
475 "fork of", d.ForkOf,
476 "watch", d.Watch,
477 "bookmarked", bookmarked,
478 "archived", archived,
479 "url", c.siteURL(d.Path),
480 )
481 } else {
482 v.title(d.Path, d.Description, d.Visibility)
483 v.fields(
484 "default branch", d.DefaultBranch,
485 "website", d.Website,
486 "topics", strings.Join(d.Topics, ", "),
487 "protected", strings.Join(d.ProtectedBranches, ", "),
488 "pages domains", strings.Join(d.Domains, ", "),
489 "fork of", d.ForkOf,
490 "watch", d.Watch,
491 "bookmarked", bookmarked,
492 "archived", archived,
493 "url", c.siteURL(d.Path),
494 )
495 }
496 if len(d.Mirrors) > 0 {
497 v.section("mirror")
498 tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS")
499 for _, m := range d.Mirrors {
500 status := "ok"
501 if m.Pending {
502 status = "pending"
503 }
504 if m.LastError != "" {
505 status = "error: " + m.LastError
506 }
507 tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(c.when(m.LastSync))), cState(status))
508 }
509 tb.flush()
510 }
511 })
512}
513
514// repoGlance is what repo show adds at a terminal: how to clone it and
515// what is going on in it.
516type repoGlance struct {
517 clone, issues, mrs, release, checks string
518}
519
520// repoAtAGlance reads the glance fields. Each is left blank when it
521// cannot be read: they are a summary, not the command's result.
522func repoAtAGlance(c *Ctx, repo store.Repo) repoGlance {
523 host := c.Cfg.SiteHost()
524 if c.Cfg.SSH.Port != 22 {
525 host += ":" + strconv.Itoa(c.Cfg.SSH.Port)
526 }
527 g := repoGlance{clone: "ssh://git@" + host + "/" + repo.Path() + ".git"}
528 issues, mrs := c.Store.OpenCounts(repo.ID)
529 g.issues = fmt.Sprintf("%d open", issues)
530 g.mrs = fmt.Sprintf("%d open", mrs)
531 if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 {
532 g.release = rs[0].Tag + ", " + relAge(rs[0].CreatedAt, termNow())
533 }
534 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
535 if tip, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
536 if sts, err := c.Store.ListCommitStatuses(repo.ID, tip); err == nil {
537 if m := checksMark(sts); m.s != "" {
538 g.checks = m.s + " on " + repo.DefaultBranch
539 }
540 }
541 }
542 return g
543}
544
545func runRepoTransfer(c *Ctx, args []string) int {
546 if len(args) != 2 {
547 return c.usage()
548 }
549 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
550 if code >= 0 {
551 return code
552 }
553 newOwner := args[1]
554 if newOwner == repo.OwnerName {
555 return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
556 }
557
558 // Target: yourself, or an org you admin — same rule as repo create.
559 newKind, newID := "", int64(0)
560 if newOwner == c.User.Username {
561 newKind, newID = "user", c.User.ID
562 } else if org, err := c.Store.OrgByName(newOwner); err == nil {
563 role, err := c.Store.OrgRole(org.ID, c.User.ID)
564 if err != nil {
565 return c.fail(protocol.ExitFailure, "%v", err)
566 }
567 if role != "admin" {
568 return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
569 }
570 newKind, newID = "org", org.ID
571 } else {
572 return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
573 }
574
575 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
576 newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
577 if _, err := os.Stat(newDir); err == nil {
578 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
579 }
580 release, lockCode := holdOffBackup(c)
581 if lockCode >= 0 {
582 return lockCode
583 }
584 defer release()
585 // The directory moves before the record changes: a move that fails
586 // leaves nothing to undo, whereas the record's change into an org
587 // folds labels and milestones into the org's rows, which a revert
588 // cannot unfold (#212). A record that then fails moves the directory
589 // back, and says so if even that fails, since the operator then has
590 // a row pointing at a directory that is not there.
591 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
592 return c.fail(protocol.ExitFailure, "%v", err)
593 }
594 if err := os.Rename(oldDir, newDir); err != nil {
595 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
596 }
597 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
598 if rerr := os.Rename(newDir, oldDir); rerr != nil {
599 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)
600 }
601 return c.failErr(err)
602 }
603 newPath := newOwner + "/" + repo.Name
604 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
605 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
606 })
607}
608
609func runRepoRename(c *Ctx, args []string) int {
610 if len(args) != 2 {
611 return c.usage()
612 }
613 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
614 if code >= 0 {
615 return code
616 }
617 newName := args[1]
618 if newName == repo.Name {
619 return c.fail(protocol.ExitUsage, "%s is already named %s", repo.Path(), newName)
620 }
621 if err := policyValidateRepoName(newName); err != nil {
622 return c.failInput(err)
623 }
624 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
625 newDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, newName)
626 if _, err := os.Stat(newDir); err == nil {
627 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", repo.OwnerName, newName)
628 }
629 release, lockCode := holdOffBackup(c)
630 if lockCode >= 0 {
631 return lockCode
632 }
633 defer release()
634 if err := c.Store.RenameRepo(repo.ID, newName); err != nil {
635 return c.failErr(err)
636 }
637 if err := os.Rename(oldDir, newDir); err != nil {
638 // Same rule as transfer: keep name and disk consistent, and say so
639 // if even the revert fails.
640 if rerr := c.Store.RenameRepo(repo.ID, repo.Name); rerr != nil {
641 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())
642 }
643 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
644 }
645 newPath := repo.OwnerName + "/" + newName
646 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
647 fmt.Fprintf(w, "renamed %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
648 })
649}
650
651func runRepoDelete(c *Ctx, args []string) int {
652 var path string
653 var yes bool
654 for _, a := range args {
655 if a == "--yes" {
656 yes = true
657 } else if path == "" {
658 path = a
659 } else {
660 return c.usage()
661 }
662 }
663 if path == "" {
664 return c.usage()
665 }
666 repo, code := resolveRepo(c, path, policy.CanAdmin)
667 if code >= 0 {
668 return code
669 }
670 if !yes {
671 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
672 }
673 return deleteRepo(c, repo)
674}
675
676// deleteRepo removes a repository the caller has already been cleared to
677// delete: the database row, then the directory.
678//
679// There is deliberately no repo.deleted event. events.repo_id and
680// webhooks.repo_id both cascade from repos, so recording one would delete
681// it, and every webhook that could have subscribed, in the same
682// statement. A repository's deletion is not observable through its own
683// webhooks; an instance that needs to hear about it wants the audit log
684// (#112).
685func deleteRepo(c *Ctx, repo store.Repo) int {
686 release, lockCode := holdOffBackup(c)
687 if lockCode >= 0 {
688 return lockCode
689 }
690 defer release()
691 // Open MRs sourced from this repo keep working (targets own the
692 // objects) but must show that the source is gone.
693 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
694 return c.fail(protocol.ExitFailure, "%v", err)
695 }
696 if err := c.Store.DeleteRepo(repo.ID); err != nil {
697 return c.fail(protocol.ExitFailure, "%v", err)
698 }
699 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
700 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
701 }
702 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
703 fmt.Fprintf(w, "deleted %s\n", repo.Path())
704 })
705}
706
707// holdOffBackup keeps a full backup from starting while a repository
708// directory moves or goes, and refuses while one runs: the backup's
709// database snapshot names every repository its walk then archives
710// (#259). The caller defers the returned release.
711func holdOffBackup(c *Ctx) (func(), int) {
712 release, err := backuplock.TryShared(c.Cfg.Server.Root)
713 if err != nil {
714 return nil, c.fail(protocol.ExitFailure, "%v", err)
715 }
716 return release, -1
717}
718
719func runAccessGrant(c *Ctx, args []string) int {
720 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
721 return c.usage()
722 }
723 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
724 if code >= 0 {
725 return code
726 }
727 target, err := c.Store.UserByUsername(args[1])
728 if err != nil {
729 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
730 }
731 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
732 return c.fail(protocol.ExitFailure, "%v", err)
733 }
734 return c.emit(map[string]string{"granted": args[2], "user": target.Username},
735 func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
736}
737
738func runAccessRevoke(c *Ctx, args []string) int {
739 if len(args) != 2 {
740 return c.usage()
741 }
742 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
743 if code >= 0 {
744 return code
745 }
746 target, err := c.Store.UserByUsername(args[1])
747 if err != nil {
748 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
749 }
750 if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
751 if errors.Is(err, store.ErrNotFound) {
752 return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
753 }
754 return c.fail(protocol.ExitFailure, "%v", err)
755 }
756 return c.emit(map[string]string{"revoked": target.Username},
757 func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
758}
759
760func runAccessList(c *Ctx, args []string) int {
761 if len(args) != 1 {
762 return c.usage()
763 }
764 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
765 if code >= 0 {
766 return code
767 }
768 entries, err := c.Store.EffectiveAccess(repo.ID)
769 if err != nil {
770 return c.fail(protocol.ExitFailure, "%v", err)
771 }
772 type out struct {
773 User string `json:"user"`
774 Role string `json:"role"`
775 Source string `json:"source"`
776 }
777 var ds []out
778 for _, e := range entries {
779 ds = append(ds, out{e.Username, e.Role, e.Source})
780 }
781 return c.emit(ds, func(w io.Writer) {
782 tb := c.table(w, "USER", "ROLE", "SOURCE")
783 for _, d := range ds {
784 tb.row(cRef(d.User), cState(d.Role), cText("via "+d.Source))
785 }
786 tb.flush()
787 })
788}
789
790func runSettingsShow(c *Ctx, args []string) int {
791 if len(args) != 1 {
792 return c.usage()
793 }
794 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
795 if code >= 0 {
796 return code
797 }
798 return c.emit(repo.Settings, func(w io.Writer) {
799 v := c.view(w)
800 v.title(repo.Path(), "settings", "")
801 v.fields(
802 "protected branches", strings.Join(repo.Settings.ProtectedBranches, ", "),
803 "protected tags", strings.Join(repo.Settings.ProtectedTags, ", "),
804 "require mr", strconv.FormatBool(repo.Settings.RequireMR),
805 "require checks", strconv.FormatBool(repo.Settings.RequireChecks),
806 "required contexts", strings.Join(repo.Settings.RequiredContexts, ", "),
807 "require signed commits", strconv.FormatBool(repo.Settings.RequireSignedCommits),
808 "git daemon", strconv.FormatBool(repo.Settings.GitDaemon),
809 "archived", strconv.FormatBool(repo.Settings.Archived),
810 )
811 })
812}
813
814func runSetDescription(c *Ctx, args []string) int {
815 if len(args) != 2 {
816 return c.usage()
817 }
818 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
819 if code >= 0 {
820 return code
821 }
822 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
823 if err := gitutil.WriteDescription(dir, args[1]); err != nil {
824 return c.fail(protocol.ExitFailure, "%v", err)
825 }
826 return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
827 fmt.Fprintf(w, "description set on %s\n", repo.Path())
828 })
829}
830
831func runSetDefaultBranch(c *Ctx, args []string) int {
832 if len(args) != 2 {
833 return c.usage()
834 }
835 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
836 if code >= 0 {
837 return code
838 }
839 branch := args[1]
840 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
841 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+branch); err != nil {
842 return c.fail(protocol.ExitFailure, "no branch named %q on %s", branch, repo.Path())
843 }
844 if err := gitutil.SetHead(dir, branch); err != nil {
845 return c.fail(protocol.ExitFailure, "%v", err)
846 }
847 if err := c.Store.UpdateDefaultBranch(repo.ID, branch); err != nil {
848 return c.fail(protocol.ExitFailure, "%v", err)
849 }
850 c.Store.RequestSymbolIndex(repo.ID, false)
851 return c.emit(map[string]string{"default_branch": branch}, func(w io.Writer) {
852 fmt.Fprintf(w, "default branch of %s is now %s\n", repo.Path(), branch)
853 })
854}
855
856func runSetWebsite(c *Ctx, args []string) int {
857 if len(args) != 2 {
858 return c.usage()
859 }
860 site := strings.TrimSpace(args[1])
861 if err := validateWebsite(site); err != nil {
862 return c.failInput(err)
863 }
864 if len(site) > 256 {
865 return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
866 }
867 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
868 if code >= 0 {
869 return code
870 }
871 if _, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Website = site }); err != nil {
872 return c.fail(protocol.ExitFailure, "%v", err)
873 }
874 return c.emit(map[string]string{"website": site}, func(w io.Writer) {
875 if site == "" {
876 fmt.Fprintf(w, "website cleared on %s\n", repo.Path())
877 } else {
878 fmt.Fprintf(w, "website set on %s\n", repo.Path())
879 }
880 })
881}
882
883func runSetVisibility(c *Ctx, args []string) int {
884 if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
885 return c.usage()
886 }
887 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
888 if code >= 0 {
889 return code
890 }
891 return setRepoVisibility(c, repo, args[1])
892}
893
894// setRepoVisibility applies a visibility change the caller has already
895// been cleared to make.
896func setRepoVisibility(c *Ctx, repo store.Repo, visibility string) int {
897 if repo.Visibility == visibility {
898 return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
899 fmt.Fprintf(w, "%s is already %s\n", repo.Path(), visibility)
900 })
901 }
902 if err := c.Store.SetRepoVisibility(repo.ID, visibility); err != nil {
903 return c.fail(protocol.ExitFailure, "%v", err)
904 }
905 // Going private takes the repository off every anonymous surface, so
906 // git:// exposure cannot outlive the change.
907 if visibility == "private" && repo.Settings.GitDaemon {
908 c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = false })
909 }
910 c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": visibility})
911 return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
912 fmt.Fprintf(w, "%s is now %s\n", repo.Path(), visibility)
913 })
914}
915
916func runGitDaemon(c *Ctx, args []string) int {
917 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
918 return c.usage()
919 }
920 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
921 if code >= 0 {
922 return code
923 }
924 on := args[1] == "on"
925 if on && repo.Visibility != "public" {
926 return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
927 }
928 if on && !c.Cfg.GitDaemon.Enabled {
929 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
930 }
931 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = on })
932 if err != nil {
933 return c.fail(protocol.ExitFailure, "%v", err)
934 }
935 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
936}
937
938func runArchive(c *Ctx, args []string) int { return setArchived(c, args, true) }
939func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
940
941func setArchived(c *Ctx, args []string, archived bool) int {
942 if len(args) != 1 {
943 return c.usage()
944 }
945 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
946 if code >= 0 {
947 return code
948 }
949 return archiveRepo(c, repo, archived)
950}
951
952// archiveRepo flips the archived flag on a repository the caller has
953// already been cleared to manage.
954func archiveRepo(c *Ctx, repo store.Repo, archived bool) int {
955 verb := "archive"
956 if !archived {
957 verb = "unarchive"
958 }
959 if repo.Settings.Archived == archived {
960 return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
961 }
962 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Archived = archived })
963 if err != nil {
964 return c.fail(protocol.ExitFailure, "%v", err)
965 }
966 c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
967 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
968}
969
970func runTopicsList(c *Ctx, args []string) int {
971 if len(args) != 1 {
972 return c.usage()
973 }
974 repo, code := resolveRepo(c, args[0], policy.CanRead)
975 if code >= 0 {
976 return code
977 }
978 topics, err := c.Store.ListTopics(repo.ID)
979 if err != nil {
980 return c.fail(protocol.ExitFailure, "%v", err)
981 }
982 return c.emit(topics, func(w io.Writer) {
983 tb := c.table(w, "TOPIC")
984 for _, t := range topics {
985 tb.row(cRef(t))
986 }
987 tb.flush()
988 })
989}
990
991func runTopicsAdd(c *Ctx, args []string) int { return editTopics(c, args, true) }
992func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) }
993
994func editTopics(c *Ctx, args []string, add bool) int {
995 if len(args) < 2 {
996 return c.usage()
997 }
998 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
999 if code >= 0 {
1000 return code
1001 }
1002 topics := args[1:]
1003 if add {
1004 for _, t := range topics {
1005 if err := policy.ValidateTopic(t); err != nil {
1006 return c.failInput(err)
1007 }
1008 }
1009 have, err := c.Store.ListTopics(repo.ID)
1010 if err != nil {
1011 return c.fail(protocol.ExitFailure, "%v", err)
1012 }
1013 added := 0
1014 for _, t := range topics {
1015 if !slices.Contains(have, t) {
1016 added++
1017 }
1018 }
1019 if len(have)+added > policy.MaxTopics {
1020 return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics)
1021 }
1022 for _, t := range topics {
1023 if err := c.Store.AddTopic(repo.ID, t); err != nil {
1024 return c.fail(protocol.ExitFailure, "%v", err)
1025 }
1026 }
1027 } else {
1028 for _, t := range topics {
1029 if err := c.Store.RemoveTopic(repo.ID, t); err != nil {
1030 if errors.Is(err, store.ErrNotFound) {
1031 return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t)
1032 }
1033 return c.fail(protocol.ExitFailure, "%v", err)
1034 }
1035 }
1036 }
1037 now, err := c.Store.ListTopics(repo.ID)
1038 if err != nil {
1039 return c.fail(protocol.ExitFailure, "%v", err)
1040 }
1041 return c.emit(now, func(w io.Writer) {
1042 tb := c.table(w, "TOPIC")
1043 for _, t := range now {
1044 tb.row(cRef(t))
1045 }
1046 tb.flush()
1047 })
1048}
1049
1050// runRepoSearch matches the query against name, owner/name, description,
1051// and topics of every repository the caller can see.
1052func runRepoSearch(c *Ctx, args []string) int {
1053 if len(args) != 1 {
1054 return c.usage()
1055 }
1056 if err := validQuery(args[0]); err != nil {
1057 return c.failInput(err)
1058 }
1059 q := strings.ToLower(args[0])
1060
1061 public, err := c.Store.ListPublicRepos()
1062 if err != nil {
1063 return c.fail(protocol.ExitFailure, "%v", err)
1064 }
1065 own, err := c.Store.ListReposForUser(c.User.ID, 0, "")
1066 if err != nil {
1067 return c.fail(protocol.ExitFailure, "%v", err)
1068 }
1069 seen := map[int64]bool{}
1070 type out struct {
1071 Path string `json:"path"`
1072 Visibility string `json:"visibility"`
1073 Description string `json:"description,omitempty"`
1074 Topics []string `json:"topics,omitempty"`
1075 }
1076 var ds []out
1077 for _, r := range append(public, own...) {
1078 if seen[r.ID] {
1079 continue
1080 }
1081 seen[r.ID] = true
1082 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
1083 topics, _ := c.Store.ListTopics(r.ID)
1084 if !MatchesRepo(q, r.Path(), desc, topics) {
1085 continue
1086 }
1087 ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
1088 }
1089 return c.emit(ds, func(w io.Writer) {
1090 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
1091 for _, d := range ds {
1092 tb.row(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description))
1093 }
1094 tb.flush()
1095 })
1096}
1097
1098// MatchesRepo is the one rule for matching a repository against a text
1099// query: its path, its description, or any of its topics. The web's
1100// /explore filter and /search page call it too, so the three surfaces
1101// cannot answer the same query differently.
1102func MatchesRepo(q, path, desc string, topics []string) bool {
1103 q = strings.ToLower(q)
1104 if strings.Contains(strings.ToLower(path), q) ||
1105 strings.Contains(strings.ToLower(desc), q) {
1106 return true
1107 }
1108 for _, t := range topics {
1109 if strings.Contains(strings.ToLower(t), q) {
1110 return true
1111 }
1112 }
1113 return false
1114}
1115
1116func runRepoGrep(c *Ctx, args []string) int {
1117 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep <owner/name> <query> [--ref <ref>]"})
1118 if err != nil {
1119 return c.fail(protocol.ExitUsage, "%v", err)
1120 }
1121 path, query, ref := f.pos(0), f.pos(1), f.Value("--ref")
1122 if path == "" || query == "" {
1123 return c.usage()
1124 }
1125 if err := validQuery(query); err != nil {
1126 return c.failInput(err)
1127 }
1128 repo, code := resolveRepo(c, path, policy.CanRead)
1129 if code >= 0 {
1130 return code
1131 }
1132 if ref == "" {
1133 ref = repo.DefaultBranch
1134 }
1135 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1136 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
1137 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
1138 }
1139 matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches)
1140 if err != nil {
1141 return c.fail(protocol.ExitFailure, "%v", err)
1142 }
1143 type out struct {
1144 Path string `json:"path"`
1145 Line int `json:"line"`
1146 Text string `json:"text"`
1147 }
1148 var ds []out
1149 for _, m := range matches {
1150 ds = append(ds, out{m.Path, m.Line, m.Text})
1151 }
1152 return c.emit(ds, func(w io.Writer) {
1153 for _, d := range ds {
1154 fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text)
1155 }
1156 })
1157}
1158
1159func runRepoPin(c *Ctx, args []string) int { return setPinned(c, args, true) }
1160func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) }
1161
1162func setPinned(c *Ctx, args []string, pin bool) int {
1163 verb := "pin"
1164 if !pin {
1165 verb = "unpin"
1166 }
1167 if len(args) != 1 {
1168 return c.usage()
1169 }
1170 repo, code := resolveRepo(c, args[0], policy.CanRead)
1171 if code >= 0 {
1172 return code
1173 }
1174 if pin {
1175 if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil {
1176 return c.fail(protocol.ExitFailure, "%v", err)
1177 }
1178 } else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil {
1179 if errors.Is(err, store.ErrNotFound) {
1180 return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path())
1181 }
1182 return c.fail(protocol.ExitFailure, "%v", err)
1183 }
1184 return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) {
1185 fmt.Fprintf(w, "%sned %s\n", verb, repo.Path())
1186 })
1187}
1188
1189func runRepoBookmark(c *Ctx, args []string) int { return setBookmarked(c, args, true) }
1190func runRepoUnbookmark(c *Ctx, args []string) int { return setBookmarked(c, args, false) }
1191
1192// setBookmarked mirrors setPinned. A bookmark needs only read access —
1193// bookmarking is something you do to someone else's repository, which is
1194// the whole point of it — and a private repository you cannot read is
1195// not found, as everywhere.
1196func setBookmarked(c *Ctx, args []string, on bool) int {
1197 verb := "bookmark"
1198 if !on {
1199 verb = "unbookmark"
1200 }
1201 if len(args) != 1 {
1202 return c.usage()
1203 }
1204 repo, code := resolveRepo(c, args[0], policy.CanRead)
1205 if code >= 0 {
1206 return code
1207 }
1208 if on {
1209 if err := c.Store.BookmarkRepo(c.User.ID, repo.ID); err != nil {
1210 return c.fail(protocol.ExitFailure, "%v", err)
1211 }
1212 } else if err := c.Store.UnbookmarkRepo(c.User.ID, repo.ID); err != nil {
1213 if errors.Is(err, store.ErrNotFound) {
1214 return c.fail(protocol.ExitNotFound, "%s is not bookmarked", repo.Path())
1215 }
1216 return c.fail(protocol.ExitFailure, "%v", err)
1217 }
1218 return c.emit(map[string]string{verb + "ed": repo.Path()}, func(w io.Writer) {
1219 fmt.Fprintf(w, "%sed %s\n", verb, repo.Path())
1220 })
1221}
1222
1223// BookmarkOut is one row of `repo bookmarks`: the repository and how many
1224// people have bookmarked it.
1225type BookmarkOut struct {
1226 Path string `json:"path"`
1227 Description string `json:"description,omitempty"`
1228 Visibility string `json:"visibility"`
1229 Bookmarks int `json:"bookmarks"`
1230}
1231
1232func runRepoBookmarks(c *Ctx, args []string) int {
1233 if len(args) != 0 {
1234 return c.usage()
1235 }
1236 repos, err := c.Store.ListBookmarks(c.User.ID)
1237 if err != nil {
1238 return c.fail(protocol.ExitFailure, "%v", err)
1239 }
1240 out := []BookmarkOut{}
1241 for _, r := range repos {
1242 // A repository bookmarked while public and since made private
1243 // stays in the table and drops out of the listing, the same way
1244 // it disappears from every other surface.
1245 grant, err := c.Store.AccessRole(r.ID, c.User.ID)
1246 if err != nil {
1247 return c.fail(protocol.ExitFailure, "%v", err)
1248 }
1249 if !policy.CanRead(c.User, r, grant) {
1250 continue
1251 }
1252 out = append(out, BookmarkOut{
1253 Path: r.Path(),
1254 Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)),
1255 Visibility: r.Visibility,
1256 Bookmarks: c.Store.BookmarkCount(r.ID),
1257 })
1258 }
1259 return c.emit(out, func(w io.Writer) {
1260 tb := c.table(w, "PATH", "COUNT", "DESCRIPTION")
1261 for _, b := range out {
1262 tb.row(cRef(b.Path), cNum(int64(b.Bookmarks)), cFlex(b.Description))
1263 }
1264 tb.flush()
1265 })
1266}
1267
1268func runProtectTag(c *Ctx, args []string) int { return setProtectTag(c, args, true) }
1269func runUnprotectTag(c *Ctx, args []string) int { return setProtectTag(c, args, false) }
1270
1271func setProtectTag(c *Ctx, args []string, protect bool) int {
1272 if len(args) != 2 {
1273 return c.usage()
1274 }
1275 glob := args[1]
1276 if _, err := path.Match(glob, "x"); err != nil || glob == "" {
1277 return c.fail(protocol.ExitUsage, "bad glob %q", glob)
1278 }
1279 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1280 if code >= 0 {
1281 return code
1282 }
1283 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1284 has := slices.Contains(s.ProtectedTags, glob)
1285 if protect && !has {
1286 s.ProtectedTags = append(s.ProtectedTags, glob)
1287 slices.Sort(s.ProtectedTags)
1288 }
1289 if !protect && has {
1290 s.ProtectedTags = slices.DeleteFunc(s.ProtectedTags, func(g string) bool { return g == glob })
1291 }
1292 })
1293 if err != nil {
1294 return c.fail(protocol.ExitFailure, "%v", err)
1295 }
1296 verb := "protected"
1297 if !protect {
1298 verb = "unprotected"
1299 }
1300 return c.emit(s, func(w io.Writer) {
1301 fmt.Fprintf(w, "tags %s %s on %s\n", glob, verb, repo.Path())
1302 })
1303}
1304
1305func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
1306func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
1307
1308func setProtect(c *Ctx, args []string, protect bool) int {
1309 if len(args) != 2 {
1310 return c.usage()
1311 }
1312 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1313 if code >= 0 {
1314 return code
1315 }
1316 branch := args[1]
1317 // The list is read and rewritten inside the update, so two admins
1318 // protecting different branches at once both land.
1319 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1320 has := slices.Contains(s.ProtectedBranches, branch)
1321 if protect && !has {
1322 s.ProtectedBranches = append(s.ProtectedBranches, branch)
1323 slices.Sort(s.ProtectedBranches)
1324 }
1325 if !protect && has {
1326 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
1327 }
1328 })
1329 if err != nil {
1330 return c.fail(protocol.ExitFailure, "%v", err)
1331 }
1332 verb := "protected"
1333 if !protect {
1334 verb = "unprotected"
1335 }
1336 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
1337}
1338
1339// runRepoDiff is the compare view's command: what head adds on top of
1340// base, measured from their merge base the way a merge request diff is,
1341// so a base that moved on does not show up as removals (#118).
1342func runRepoDiff(c *Ctx, args []string) int {
1343 f, err := c.parseArgs(args, flagSpec{MaxPos: 3, Usage: "repo diff <owner/name> <base> <head>"})
1344 if err != nil || len(f.Pos) != 3 {
1345 return c.usage()
1346 }
1347 repo, code := resolveRepo(c, f.pos(0), policy.CanRead)
1348 if code >= 0 {
1349 return code
1350 }
1351 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1352 base, err := gitutil.ResolveRef(dir, f.pos(1))
1353 if err != nil {
1354 return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(1), repo.Path())
1355 }
1356 head, err := gitutil.ResolveRef(dir, f.pos(2))
1357 if err != nil {
1358 return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(2), repo.Path())
1359 }
1360 mergeBase, err := gitutil.MergeBase(dir, base, head)
1361 if err != nil {
1362 return c.fail(protocol.ExitUsage, "%v", err)
1363 }
1364 patch, truncated, err := gitutil.Diff(dir, mergeBase, head, 4<<20)
1365 if err != nil {
1366 return c.fail(protocol.ExitFailure, "%v", err)
1367 }
1368 if c.JSON {
1369 return c.emit(map[string]any{"base": base, "head": head, "merge_base": mergeBase, "patch": patch, "truncated": truncated}, nil)
1370 }
1371 fmt.Fprint(c.Stdout, c.Term.diff(patch))
1372 if truncated {
1373 fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB")
1374 }
1375 return protocol.ExitOK
1376}