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