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