internal/control/repo.go
928 lines · 31745 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", "pin"},
94 Summary: "pin a repository to your dashboard",
95 Usage: "repo pin <owner/name>", Run: runRepoPin})
96 register(Command{Path: []string{"repo", "unpin"},
97 Summary: "unpin a repository",
98 Usage: "repo unpin <owner/name>", Run: runRepoUnpin})
99}
100
101const (
102 minQueryLen = 2
103 maxQueryLen = 200
104 maxGrepMatches = 200
105)
106
107func validQuery(q string) error {
108 if len(q) < minQueryLen || len(q) > maxQueryLen {
109 return fmt.Errorf("query must be %d to %d characters", minQueryLen, maxQueryLen)
110 }
111 return nil
112}
113
114// refuseArchived blocks content writes (pushes are refused in the transport
115// layer) on archived repositories. Settings, access, and lifecycle commands
116// stay available so an archived repo can be managed and unarchived.
117func refuseArchived(c *Ctx, repo store.Repo) int {
118 if repo.Settings.Archived {
119 return c.fail(protocol.ExitDenied, "%s is archived and read-only", repo.Path())
120 }
121 return -1
122}
123
124// resolveRepo loads a repo and checks the given permission for c.User.
125func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
126 repo, err := c.Store.RepoByPath(path)
127 if err != nil {
128 if errors.Is(err, store.ErrNotFound) {
129 // Same message whether it doesn't exist or is invisible.
130 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
131 }
132 return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
133 }
134 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
135 if err != nil {
136 return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
137 }
138 if !check(c.User, repo, grant) {
139 if !policy.CanRead(c.User, repo, grant) {
140 // Invisible repos 404, per the enumeration rule.
141 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
142 }
143 return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
144 }
145 return repo, -1
146}
147
148func runRepoCreate(c *Ctx, args []string) int {
149 visibility := "public"
150 var path, description string
151 for i := 0; i < len(args); i++ {
152 switch args[i] {
153 case "--private":
154 visibility = "private"
155 case "--description":
156 if i+1 >= len(args) {
157 return c.fail(protocol.ExitUsage, "--description requires a value")
158 }
159 description = args[i+1]
160 i++
161 default:
162 if path != "" {
163 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private] [--description <text>]")
164 }
165 path = args[i]
166 }
167 }
168 owner, name, ok := strings.Cut(path, "/")
169 if !ok {
170 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
171 }
172 if err := policyValidateRepoName(name); err != nil {
173 return c.fail(protocol.ExitUsage, "%v", err)
174 }
175 ownerKind, ownerID := "user", c.User.ID
176 if owner != c.User.Username {
177 org, err := c.Store.OrgByName(owner)
178 if err != nil {
179 return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
180 }
181 role, err := c.Store.OrgRole(org.ID, c.User.ID)
182 if err != nil {
183 return c.fail(protocol.ExitFailure, "%v", err)
184 }
185 if role != "admin" {
186 return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
187 }
188 ownerKind, ownerID = "org", org.ID
189 }
190 if ownerKind == "user" {
191 if code := checkRepoQuota(c); code >= 0 {
192 return code
193 }
194 }
195 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
196 if err != nil {
197 return c.fail(protocol.ExitFailure, "%v", err)
198 }
199 dir := RepoDir(c.Cfg.Server.Root, owner, name)
200 if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
201 c.Store.DeleteRepo(id)
202 return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
203 }
204 if description != "" {
205 if err := gitutil.WriteDescription(dir, description); err != nil {
206 return c.fail(protocol.ExitFailure, "writing description: %v", err)
207 }
208 }
209 type out struct {
210 Path string `json:"path"`
211 Visibility string `json:"visibility"`
212 SSHURL string `json:"ssh_url"`
213 }
214 d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
215 return c.emit(d, func(w io.Writer) {
216 fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
217 })
218}
219
220func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
221
222func hostOf(siteURL string) string {
223 s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
224 return strings.TrimSuffix(s, "/")
225}
226
227func runRepoList(c *Ctx, args []string) int {
228 args, p, code := parsePageFlags(c, args, "repo", false)
229 if code >= 0 {
230 return code
231 }
232 if len(args) != 0 {
233 return c.fail(protocol.ExitUsage, "usage: repo list [--limit <n>] [--cursor <c>]")
234 }
235 repos, err := c.Store.ListReposForUser(c.User.ID, p.queryLimit(), p.key)
236 if err != nil {
237 return c.fail(protocol.ExitFailure, "%v", err)
238 }
239 repos, next := trimPage(p, repos, "repo", store.Repo.Path)
240 type out struct {
241 Path string `json:"path"`
242 Visibility string `json:"visibility"`
243 Description string `json:"description,omitempty"`
244 Archived bool `json:"archived,omitempty"`
245 }
246 var ds []out
247 for _, r := range repos {
248 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
249 ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
250 }
251 return c.emitPage(p, ds, next, func(w io.Writer) {
252 for _, d := range ds {
253 mark := ""
254 if d.Archived {
255 mark = "\t[archived]"
256 }
257 fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark)
258 }
259 })
260}
261
262func runRepoShow(c *Ctx, args []string) int {
263 if len(args) != 1 {
264 return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
265 }
266 repo, code := resolveRepo(c, args[0], policy.CanRead)
267 if code >= 0 {
268 return code
269 }
270 type mirrorOut struct {
271 Direction string `json:"direction"`
272 URL string `json:"url"`
273 Pending bool `json:"pending"`
274 LastSync string `json:"last_sync,omitempty"`
275 LastError string `json:"last_error,omitempty"`
276 }
277 type out struct {
278 Path string `json:"path"`
279 Description string `json:"description,omitempty"`
280 Website string `json:"website,omitempty"`
281 Visibility string `json:"visibility"`
282 DefaultBranch string `json:"default_branch"`
283 ProtectedBranches []string `json:"protected_branches,omitempty"`
284 Archived bool `json:"archived,omitempty"`
285 Topics []string `json:"topics,omitempty"`
286 Domains []string `json:"domains,omitempty"`
287 Mirrors []mirrorOut `json:"mirrors,omitempty"`
288 }
289 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
290 topics, err := c.Store.ListTopics(repo.ID)
291 if err != nil {
292 return c.fail(protocol.ExitFailure, "%v", err)
293 }
294 var domains []string
295 if ds, err := c.Store.ListPageDomains(repo.ID); err == nil {
296 for _, pd := range ds {
297 if pd.Verified() {
298 domains = append(domains, pd.Domain)
299 }
300 }
301 }
302 d := out{repo.Path(), desc, repo.Settings.Website, repo.Visibility, repo.DefaultBranch,
303 repo.Settings.ProtectedBranches, repo.Settings.Archived, topics, domains, nil}
304 // Mirror status is admin-only, like repo mirror list. The token never
305 // leaves the server.
306 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
307 ms, err := c.Store.ListMirrors(repo.ID)
308 if err != nil {
309 return c.fail(protocol.ExitFailure, "%v", err)
310 }
311 for _, m := range ms {
312 d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
313 }
314 }
315 return c.emit(d, func(w io.Writer) {
316 line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch)
317 if d.Archived {
318 line += "\t[archived]"
319 }
320 fmt.Fprintln(w, line)
321 if d.Description != "" {
322 fmt.Fprintf(w, "%s\n", d.Description)
323 }
324 if d.Website != "" {
325 fmt.Fprintf(w, "website: %s\n", d.Website)
326 }
327 if len(d.Topics) > 0 {
328 fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
329 }
330 if len(d.ProtectedBranches) > 0 {
331 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
332 }
333 if len(d.Domains) > 0 {
334 fmt.Fprintf(w, "pages domains: %s\n", strings.Join(d.Domains, ", "))
335 }
336 for _, m := range d.Mirrors {
337 status := "ok"
338 if m.Pending {
339 status = "pending"
340 }
341 if m.LastError != "" {
342 status = "error: " + m.LastError
343 }
344 fmt.Fprintf(w, "mirror: %s %s\tlast %s\t%s\n", m.Direction, m.URL, orDash(m.LastSync), status)
345 }
346 })
347}
348
349func runRepoTransfer(c *Ctx, args []string) int {
350 if len(args) != 2 {
351 return c.fail(protocol.ExitUsage, "usage: repo transfer <owner/name> <new-owner>")
352 }
353 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
354 if code >= 0 {
355 return code
356 }
357 newOwner := args[1]
358 if newOwner == repo.OwnerName {
359 return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
360 }
361
362 // Target: yourself, or an org you admin — same rule as repo create.
363 newKind, newID := "", int64(0)
364 if newOwner == c.User.Username {
365 newKind, newID = "user", c.User.ID
366 } else if org, err := c.Store.OrgByName(newOwner); err == nil {
367 role, err := c.Store.OrgRole(org.ID, c.User.ID)
368 if err != nil {
369 return c.fail(protocol.ExitFailure, "%v", err)
370 }
371 if role != "admin" {
372 return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
373 }
374 newKind, newID = "org", org.ID
375 } else {
376 return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
377 }
378
379 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
380 newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
381 if _, err := os.Stat(newDir); err == nil {
382 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
383 }
384 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
385 return c.fail(protocol.ExitUsage, "%v", err)
386 }
387 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
388 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
389 return c.fail(protocol.ExitFailure, "%v", err)
390 }
391 if err := os.Rename(oldDir, newDir); err != nil {
392 // Keep name and disk consistent: revert the database change.
393 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
394 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
395 }
396 // The wiki companion follows its repo.
397 oldWiki := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")
398 if _, err := os.Stat(oldWiki); err == nil {
399 os.Rename(oldWiki, RepoDir(c.Cfg.Server.Root, newOwner, repo.Name+".wiki"))
400 }
401 newPath := newOwner + "/" + repo.Name
402 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
403 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
404 })
405}
406
407func runRepoDelete(c *Ctx, args []string) int {
408 var path string
409 var yes bool
410 for _, a := range args {
411 if a == "--yes" {
412 yes = true
413 } else if path == "" {
414 path = a
415 } else {
416 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
417 }
418 }
419 if path == "" {
420 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
421 }
422 repo, code := resolveRepo(c, path, policy.CanAdmin)
423 if code >= 0 {
424 return code
425 }
426 if !yes {
427 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
428 }
429 return deleteRepo(c, repo)
430}
431
432// deleteRepo removes a repository the caller has already been cleared to
433// delete: the database row, then the directory and its wiki companion.
434func deleteRepo(c *Ctx, repo store.Repo) int {
435 // Open MRs sourced from this repo keep working (targets own the
436 // objects) but must show that the source is gone.
437 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
438 return c.fail(protocol.ExitFailure, "%v", err)
439 }
440 if err := c.Store.DeleteRepo(repo.ID); err != nil {
441 return c.fail(protocol.ExitFailure, "%v", err)
442 }
443 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
444 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
445 }
446 os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki"))
447 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
448 fmt.Fprintf(w, "deleted %s\n", repo.Path())
449 })
450}
451
452func runAccessGrant(c *Ctx, args []string) int {
453 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
454 return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
455 }
456 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
457 if code >= 0 {
458 return code
459 }
460 target, err := c.Store.UserByUsername(args[1])
461 if err != nil {
462 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
463 }
464 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
465 return c.fail(protocol.ExitFailure, "%v", err)
466 }
467 return c.emit(map[string]string{"granted": args[2], "user": target.Username},
468 func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
469}
470
471func runAccessRevoke(c *Ctx, args []string) int {
472 if len(args) != 2 {
473 return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
474 }
475 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
476 if code >= 0 {
477 return code
478 }
479 target, err := c.Store.UserByUsername(args[1])
480 if err != nil {
481 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
482 }
483 if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
484 if errors.Is(err, store.ErrNotFound) {
485 return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
486 }
487 return c.fail(protocol.ExitFailure, "%v", err)
488 }
489 return c.emit(map[string]string{"revoked": target.Username},
490 func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
491}
492
493func runAccessList(c *Ctx, args []string) int {
494 if len(args) != 1 {
495 return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
496 }
497 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
498 if code >= 0 {
499 return code
500 }
501 entries, err := c.Store.ListAccess(repo.ID)
502 if err != nil {
503 return c.fail(protocol.ExitFailure, "%v", err)
504 }
505 type out struct {
506 User string `json:"user"`
507 Role string `json:"role"`
508 }
509 var ds []out
510 for _, e := range entries {
511 ds = append(ds, out{e.Username, e.Role})
512 }
513 return c.emit(ds, func(w io.Writer) {
514 for _, d := range ds {
515 fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
516 }
517 })
518}
519
520func runSettingsShow(c *Ctx, args []string) int {
521 if len(args) != 1 {
522 return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
523 }
524 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
525 if code >= 0 {
526 return code
527 }
528 return c.emit(repo.Settings, func(w io.Writer) {
529 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
530 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
531 })
532}
533
534func runSetDescription(c *Ctx, args []string) int {
535 if len(args) != 2 {
536 return c.fail(protocol.ExitUsage, "usage: repo settings description <owner/name> <text>")
537 }
538 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
539 if code >= 0 {
540 return code
541 }
542 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
543 if err := gitutil.WriteDescription(dir, args[1]); err != nil {
544 return c.fail(protocol.ExitFailure, "%v", err)
545 }
546 return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
547 fmt.Fprintf(w, "description set on %s\n", repo.Path())
548 })
549}
550
551func runSetWebsite(c *Ctx, args []string) int {
552 if len(args) != 2 {
553 return c.fail(protocol.ExitUsage, "usage: repo settings website <owner/name> <url>")
554 }
555 site := strings.TrimSpace(args[1])
556 if err := validateWebsite(site); err != nil {
557 return c.fail(protocol.ExitUsage, "%v", err)
558 }
559 if len(site) > 256 {
560 return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
561 }
562 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
563 if code >= 0 {
564 return code
565 }
566 s := repo.Settings
567 s.Website = site
568 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
569 return c.fail(protocol.ExitFailure, "%v", err)
570 }
571 return c.emit(map[string]string{"website": site}, func(w io.Writer) {
572 if site == "" {
573 fmt.Fprintf(w, "website cleared on %s\n", repo.Path())
574 } else {
575 fmt.Fprintf(w, "website set on %s\n", repo.Path())
576 }
577 })
578}
579
580func runSetVisibility(c *Ctx, args []string) int {
581 if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
582 return c.fail(protocol.ExitUsage, "usage: repo settings visibility <owner/name> public|private")
583 }
584 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
585 if code >= 0 {
586 return code
587 }
588 return setRepoVisibility(c, repo, args[1])
589}
590
591// setRepoVisibility applies a visibility change the caller has already
592// been cleared to make.
593func setRepoVisibility(c *Ctx, repo store.Repo, visibility string) int {
594 if repo.Visibility == visibility {
595 return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
596 fmt.Fprintf(w, "%s is already %s\n", repo.Path(), visibility)
597 })
598 }
599 if err := c.Store.SetRepoVisibility(repo.ID, visibility); err != nil {
600 return c.fail(protocol.ExitFailure, "%v", err)
601 }
602 // Going private takes the repository off every anonymous surface, so
603 // git:// exposure cannot outlive the change.
604 if visibility == "private" && repo.Settings.GitDaemon {
605 s := repo.Settings
606 s.GitDaemon = false
607 c.Store.SetRepoSettings(repo.ID, s)
608 }
609 c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": visibility})
610 return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
611 fmt.Fprintf(w, "%s is now %s\n", repo.Path(), visibility)
612 })
613}
614
615func runGitDaemon(c *Ctx, args []string) int {
616 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
617 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
618 }
619 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
620 if code >= 0 {
621 return code
622 }
623 on := args[1] == "on"
624 if on && repo.Visibility != "public" {
625 return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
626 }
627 if on && !c.Cfg.GitDaemon.Enabled {
628 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
629 }
630 s := repo.Settings
631 s.GitDaemon = on
632 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
633 return c.fail(protocol.ExitFailure, "%v", err)
634 }
635 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
636}
637
638func runArchive(c *Ctx, args []string) int { return setArchived(c, args, true) }
639func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
640
641func setArchived(c *Ctx, args []string, archived bool) int {
642 verb := "archive"
643 if !archived {
644 verb = "unarchive"
645 }
646 if len(args) != 1 {
647 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
648 }
649 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
650 if code >= 0 {
651 return code
652 }
653 return archiveRepo(c, repo, archived)
654}
655
656// archiveRepo flips the archived flag on a repository the caller has
657// already been cleared to manage.
658func archiveRepo(c *Ctx, repo store.Repo, archived bool) int {
659 verb := "archive"
660 if !archived {
661 verb = "unarchive"
662 }
663 if repo.Settings.Archived == archived {
664 return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
665 }
666 s := repo.Settings
667 s.Archived = archived
668 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
669 return c.fail(protocol.ExitFailure, "%v", err)
670 }
671 c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
672 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
673}
674
675func runTopicsList(c *Ctx, args []string) int {
676 if len(args) != 1 {
677 return c.fail(protocol.ExitUsage, "usage: repo topics <owner/name>")
678 }
679 repo, code := resolveRepo(c, args[0], policy.CanRead)
680 if code >= 0 {
681 return code
682 }
683 topics, err := c.Store.ListTopics(repo.ID)
684 if err != nil {
685 return c.fail(protocol.ExitFailure, "%v", err)
686 }
687 return c.emit(topics, func(w io.Writer) {
688 for _, t := range topics {
689 fmt.Fprintln(w, t)
690 }
691 })
692}
693
694func runTopicsAdd(c *Ctx, args []string) int { return editTopics(c, args, true) }
695func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) }
696
697func editTopics(c *Ctx, args []string, add bool) int {
698 verb := "add"
699 if !add {
700 verb = "remove"
701 }
702 if len(args) < 2 {
703 return c.fail(protocol.ExitUsage, "usage: repo topics %s <owner/name> <topic>...", verb)
704 }
705 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
706 if code >= 0 {
707 return code
708 }
709 topics := args[1:]
710 if add {
711 for _, t := range topics {
712 if err := policy.ValidateTopic(t); err != nil {
713 return c.fail(protocol.ExitUsage, "%v", err)
714 }
715 }
716 have, err := c.Store.ListTopics(repo.ID)
717 if err != nil {
718 return c.fail(protocol.ExitFailure, "%v", err)
719 }
720 added := 0
721 for _, t := range topics {
722 if !slices.Contains(have, t) {
723 added++
724 }
725 }
726 if len(have)+added > policy.MaxTopics {
727 return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics)
728 }
729 for _, t := range topics {
730 if err := c.Store.AddTopic(repo.ID, t); err != nil {
731 return c.fail(protocol.ExitFailure, "%v", err)
732 }
733 }
734 } else {
735 for _, t := range topics {
736 if err := c.Store.RemoveTopic(repo.ID, t); err != nil {
737 if errors.Is(err, store.ErrNotFound) {
738 return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t)
739 }
740 return c.fail(protocol.ExitFailure, "%v", err)
741 }
742 }
743 }
744 now, err := c.Store.ListTopics(repo.ID)
745 if err != nil {
746 return c.fail(protocol.ExitFailure, "%v", err)
747 }
748 return c.emit(now, func(w io.Writer) {
749 fmt.Fprintf(w, "topics on %s: %s\n", repo.Path(), strings.Join(now, ", "))
750 })
751}
752
753// runRepoSearch matches the query against name, owner/name, description,
754// and topics of every repository the caller can see.
755func runRepoSearch(c *Ctx, args []string) int {
756 if len(args) != 1 {
757 return c.fail(protocol.ExitUsage, "usage: repo search <query>")
758 }
759 if err := validQuery(args[0]); err != nil {
760 return c.fail(protocol.ExitUsage, "%v", err)
761 }
762 q := strings.ToLower(args[0])
763
764 public, err := c.Store.ListPublicRepos()
765 if err != nil {
766 return c.fail(protocol.ExitFailure, "%v", err)
767 }
768 own, err := c.Store.ListReposForUser(c.User.ID, 0, "")
769 if err != nil {
770 return c.fail(protocol.ExitFailure, "%v", err)
771 }
772 seen := map[int64]bool{}
773 type out struct {
774 Path string `json:"path"`
775 Visibility string `json:"visibility"`
776 Description string `json:"description,omitempty"`
777 Topics []string `json:"topics,omitempty"`
778 }
779 var ds []out
780 for _, r := range append(public, own...) {
781 if seen[r.ID] {
782 continue
783 }
784 seen[r.ID] = true
785 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
786 topics, _ := c.Store.ListTopics(r.ID)
787 if !matchesRepo(q, r, desc, topics) {
788 continue
789 }
790 ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
791 }
792 return c.emit(ds, func(w io.Writer) {
793 for _, d := range ds {
794 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description)
795 }
796 })
797}
798
799func matchesRepo(q string, r store.Repo, desc string, topics []string) bool {
800 if strings.Contains(strings.ToLower(r.Path()), q) ||
801 strings.Contains(strings.ToLower(desc), q) {
802 return true
803 }
804 for _, t := range topics {
805 if strings.Contains(t, q) {
806 return true
807 }
808 }
809 return false
810}
811
812func runRepoGrep(c *Ctx, args []string) int {
813 var path, query, ref string
814 for i := 0; i < len(args); i++ {
815 switch args[i] {
816 case "--ref":
817 if i+1 >= len(args) {
818 return c.fail(protocol.ExitUsage, "--ref requires a value")
819 }
820 ref = args[i+1]
821 i++
822 default:
823 if path == "" {
824 path = args[i]
825 } else if query == "" {
826 query = args[i]
827 } else {
828 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
829 }
830 }
831 }
832 if path == "" || query == "" {
833 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
834 }
835 if err := validQuery(query); err != nil {
836 return c.fail(protocol.ExitUsage, "%v", err)
837 }
838 repo, code := resolveRepo(c, path, policy.CanRead)
839 if code >= 0 {
840 return code
841 }
842 if ref == "" {
843 ref = repo.DefaultBranch
844 }
845 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
846 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
847 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
848 }
849 matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches)
850 if err != nil {
851 return c.fail(protocol.ExitFailure, "%v", err)
852 }
853 type out struct {
854 Path string `json:"path"`
855 Line int `json:"line"`
856 Text string `json:"text"`
857 }
858 var ds []out
859 for _, m := range matches {
860 ds = append(ds, out{m.Path, m.Line, m.Text})
861 }
862 return c.emit(ds, func(w io.Writer) {
863 for _, d := range ds {
864 fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text)
865 }
866 })
867}
868
869func runRepoPin(c *Ctx, args []string) int { return setPinned(c, args, true) }
870func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) }
871
872func setPinned(c *Ctx, args []string, pin bool) int {
873 verb := "pin"
874 if !pin {
875 verb = "unpin"
876 }
877 if len(args) != 1 {
878 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
879 }
880 repo, code := resolveRepo(c, args[0], policy.CanRead)
881 if code >= 0 {
882 return code
883 }
884 if pin {
885 if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil {
886 return c.fail(protocol.ExitFailure, "%v", err)
887 }
888 } else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil {
889 if errors.Is(err, store.ErrNotFound) {
890 return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path())
891 }
892 return c.fail(protocol.ExitFailure, "%v", err)
893 }
894 return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) {
895 fmt.Fprintf(w, "%sned %s\n", verb, repo.Path())
896 })
897}
898
899func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
900func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
901
902func setProtect(c *Ctx, args []string, protect bool) int {
903 if len(args) != 2 {
904 return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
905 }
906 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
907 if code >= 0 {
908 return code
909 }
910 branch := args[1]
911 s := repo.Settings
912 has := slices.Contains(s.ProtectedBranches, branch)
913 if protect && !has {
914 s.ProtectedBranches = append(s.ProtectedBranches, branch)
915 slices.Sort(s.ProtectedBranches)
916 }
917 if !protect && has {
918 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
919 }
920 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
921 return c.fail(protocol.ExitFailure, "%v", err)
922 }
923 verb := "protected"
924 if !protect {
925 verb = "unprotected"
926 }
927 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
928}