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