internal/control/repo.go
826 lines · 28612 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 Mirrors []mirrorOut `json:"mirrors,omitempty"`
248 }
249 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
250 topics, err := c.Store.ListTopics(repo.ID)
251 if err != nil {
252 return c.fail(protocol.ExitFailure, "%v", err)
253 }
254 d := out{repo.Path(), desc, repo.Settings.Website, repo.Visibility, repo.DefaultBranch,
255 repo.Settings.ProtectedBranches, repo.Settings.Archived, topics, nil}
256 // Mirror status is admin-only, like repo mirror list. The token never
257 // leaves the server.
258 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
259 ms, err := c.Store.ListMirrors(repo.ID)
260 if err != nil {
261 return c.fail(protocol.ExitFailure, "%v", err)
262 }
263 for _, m := range ms {
264 d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
265 }
266 }
267 return c.emit(d, func(w io.Writer) {
268 line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch)
269 if d.Archived {
270 line += "\t[archived]"
271 }
272 fmt.Fprintln(w, line)
273 if d.Description != "" {
274 fmt.Fprintf(w, "%s\n", d.Description)
275 }
276 if d.Website != "" {
277 fmt.Fprintf(w, "website: %s\n", d.Website)
278 }
279 if len(d.Topics) > 0 {
280 fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
281 }
282 if len(d.ProtectedBranches) > 0 {
283 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
284 }
285 for _, m := range d.Mirrors {
286 status := "ok"
287 if m.Pending {
288 status = "pending"
289 }
290 if m.LastError != "" {
291 status = "error: " + m.LastError
292 }
293 fmt.Fprintf(w, "mirror: %s %s\tlast %s\t%s\n", m.Direction, m.URL, orDash(m.LastSync), status)
294 }
295 })
296}
297
298func runRepoTransfer(c *Ctx, args []string) int {
299 if len(args) != 2 {
300 return c.fail(protocol.ExitUsage, "usage: repo transfer <owner/name> <new-owner>")
301 }
302 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
303 if code >= 0 {
304 return code
305 }
306 newOwner := args[1]
307 if newOwner == repo.OwnerName {
308 return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
309 }
310
311 // Target: yourself, or an org you admin — same rule as repo create.
312 newKind, newID := "", int64(0)
313 if newOwner == c.User.Username {
314 newKind, newID = "user", c.User.ID
315 } else if org, err := c.Store.OrgByName(newOwner); err == nil {
316 role, err := c.Store.OrgRole(org.ID, c.User.ID)
317 if err != nil {
318 return c.fail(protocol.ExitFailure, "%v", err)
319 }
320 if role != "admin" {
321 return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
322 }
323 newKind, newID = "org", org.ID
324 } else {
325 return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
326 }
327
328 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
329 newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
330 if _, err := os.Stat(newDir); err == nil {
331 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
332 }
333 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
334 return c.fail(protocol.ExitUsage, "%v", err)
335 }
336 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
337 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
338 return c.fail(protocol.ExitFailure, "%v", err)
339 }
340 if err := os.Rename(oldDir, newDir); err != nil {
341 // Keep name and disk consistent: revert the database change.
342 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
343 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
344 }
345 // The wiki companion follows its repo.
346 oldWiki := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")
347 if _, err := os.Stat(oldWiki); err == nil {
348 os.Rename(oldWiki, RepoDir(c.Cfg.Server.Root, newOwner, repo.Name+".wiki"))
349 }
350 newPath := newOwner + "/" + repo.Name
351 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
352 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
353 })
354}
355
356func runRepoDelete(c *Ctx, args []string) int {
357 var path string
358 var yes bool
359 for _, a := range args {
360 if a == "--yes" {
361 yes = true
362 } else if path == "" {
363 path = a
364 } else {
365 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
366 }
367 }
368 if path == "" {
369 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
370 }
371 repo, code := resolveRepo(c, path, policy.CanAdmin)
372 if code >= 0 {
373 return code
374 }
375 if !yes {
376 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
377 }
378 // Open MRs sourced from this repo keep working (targets own the
379 // objects) but must show that the source is gone.
380 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
381 return c.fail(protocol.ExitFailure, "%v", err)
382 }
383 if err := c.Store.DeleteRepo(repo.ID); err != nil {
384 return c.fail(protocol.ExitFailure, "%v", err)
385 }
386 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
387 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
388 }
389 os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki"))
390 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
391 fmt.Fprintf(w, "deleted %s\n", repo.Path())
392 })
393}
394
395func runAccessGrant(c *Ctx, args []string) int {
396 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
397 return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
398 }
399 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
400 if code >= 0 {
401 return code
402 }
403 target, err := c.Store.UserByUsername(args[1])
404 if err != nil {
405 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
406 }
407 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
408 return c.fail(protocol.ExitFailure, "%v", err)
409 }
410 return c.emit(map[string]string{"granted": args[2], "user": target.Username},
411 func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
412}
413
414func runAccessRevoke(c *Ctx, args []string) int {
415 if len(args) != 2 {
416 return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
417 }
418 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
419 if code >= 0 {
420 return code
421 }
422 target, err := c.Store.UserByUsername(args[1])
423 if err != nil {
424 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
425 }
426 if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
427 if errors.Is(err, store.ErrNotFound) {
428 return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
429 }
430 return c.fail(protocol.ExitFailure, "%v", err)
431 }
432 return c.emit(map[string]string{"revoked": target.Username},
433 func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
434}
435
436func runAccessList(c *Ctx, args []string) int {
437 if len(args) != 1 {
438 return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
439 }
440 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
441 if code >= 0 {
442 return code
443 }
444 entries, err := c.Store.ListAccess(repo.ID)
445 if err != nil {
446 return c.fail(protocol.ExitFailure, "%v", err)
447 }
448 type out struct {
449 User string `json:"user"`
450 Role string `json:"role"`
451 }
452 var ds []out
453 for _, e := range entries {
454 ds = append(ds, out{e.Username, e.Role})
455 }
456 return c.emit(ds, func(w io.Writer) {
457 for _, d := range ds {
458 fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
459 }
460 })
461}
462
463func runSettingsShow(c *Ctx, args []string) int {
464 if len(args) != 1 {
465 return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
466 }
467 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
468 if code >= 0 {
469 return code
470 }
471 return c.emit(repo.Settings, func(w io.Writer) {
472 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
473 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
474 })
475}
476
477func runSetDescription(c *Ctx, args []string) int {
478 if len(args) != 2 {
479 return c.fail(protocol.ExitUsage, "usage: repo settings description <owner/name> <text>")
480 }
481 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
482 if code >= 0 {
483 return code
484 }
485 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
486 if err := gitutil.WriteDescription(dir, args[1]); err != nil {
487 return c.fail(protocol.ExitFailure, "%v", err)
488 }
489 return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
490 fmt.Fprintf(w, "description set on %s\n", repo.Path())
491 })
492}
493
494func runSetWebsite(c *Ctx, args []string) int {
495 if len(args) != 2 {
496 return c.fail(protocol.ExitUsage, "usage: repo settings website <owner/name> <url>")
497 }
498 site := strings.TrimSpace(args[1])
499 if err := validateWebsite(site); err != nil {
500 return c.fail(protocol.ExitUsage, "%v", err)
501 }
502 if len(site) > 256 {
503 return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
504 }
505 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
506 if code >= 0 {
507 return code
508 }
509 s := repo.Settings
510 s.Website = site
511 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
512 return c.fail(protocol.ExitFailure, "%v", err)
513 }
514 return c.emit(map[string]string{"website": site}, func(w io.Writer) {
515 if site == "" {
516 fmt.Fprintf(w, "website cleared on %s\n", repo.Path())
517 } else {
518 fmt.Fprintf(w, "website set on %s\n", repo.Path())
519 }
520 })
521}
522
523func runGitDaemon(c *Ctx, args []string) int {
524 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
525 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
526 }
527 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
528 if code >= 0 {
529 return code
530 }
531 on := args[1] == "on"
532 if on && repo.Visibility != "public" {
533 return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
534 }
535 if on && !c.Cfg.GitDaemon.Enabled {
536 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
537 }
538 s := repo.Settings
539 s.GitDaemon = on
540 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
541 return c.fail(protocol.ExitFailure, "%v", err)
542 }
543 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
544}
545
546func runArchive(c *Ctx, args []string) int { return setArchived(c, args, true) }
547func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
548
549func setArchived(c *Ctx, args []string, archived bool) int {
550 verb := "archive"
551 if !archived {
552 verb = "unarchive"
553 }
554 if len(args) != 1 {
555 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
556 }
557 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
558 if code >= 0 {
559 return code
560 }
561 if repo.Settings.Archived == archived {
562 return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
563 }
564 s := repo.Settings
565 s.Archived = archived
566 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
567 return c.fail(protocol.ExitFailure, "%v", err)
568 }
569 c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
570 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
571}
572
573func runTopicsList(c *Ctx, args []string) int {
574 if len(args) != 1 {
575 return c.fail(protocol.ExitUsage, "usage: repo topics <owner/name>")
576 }
577 repo, code := resolveRepo(c, args[0], policy.CanRead)
578 if code >= 0 {
579 return code
580 }
581 topics, err := c.Store.ListTopics(repo.ID)
582 if err != nil {
583 return c.fail(protocol.ExitFailure, "%v", err)
584 }
585 return c.emit(topics, func(w io.Writer) {
586 for _, t := range topics {
587 fmt.Fprintln(w, t)
588 }
589 })
590}
591
592func runTopicsAdd(c *Ctx, args []string) int { return editTopics(c, args, true) }
593func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) }
594
595func editTopics(c *Ctx, args []string, add bool) int {
596 verb := "add"
597 if !add {
598 verb = "remove"
599 }
600 if len(args) < 2 {
601 return c.fail(protocol.ExitUsage, "usage: repo topics %s <owner/name> <topic>...", verb)
602 }
603 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
604 if code >= 0 {
605 return code
606 }
607 topics := args[1:]
608 if add {
609 for _, t := range topics {
610 if err := policy.ValidateTopic(t); err != nil {
611 return c.fail(protocol.ExitUsage, "%v", err)
612 }
613 }
614 have, err := c.Store.ListTopics(repo.ID)
615 if err != nil {
616 return c.fail(protocol.ExitFailure, "%v", err)
617 }
618 added := 0
619 for _, t := range topics {
620 if !slices.Contains(have, t) {
621 added++
622 }
623 }
624 if len(have)+added > policy.MaxTopics {
625 return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics)
626 }
627 for _, t := range topics {
628 if err := c.Store.AddTopic(repo.ID, t); err != nil {
629 return c.fail(protocol.ExitFailure, "%v", err)
630 }
631 }
632 } else {
633 for _, t := range topics {
634 if err := c.Store.RemoveTopic(repo.ID, t); err != nil {
635 if errors.Is(err, store.ErrNotFound) {
636 return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t)
637 }
638 return c.fail(protocol.ExitFailure, "%v", err)
639 }
640 }
641 }
642 now, err := c.Store.ListTopics(repo.ID)
643 if err != nil {
644 return c.fail(protocol.ExitFailure, "%v", err)
645 }
646 return c.emit(now, func(w io.Writer) {
647 fmt.Fprintf(w, "topics on %s: %s\n", repo.Path(), strings.Join(now, ", "))
648 })
649}
650
651// runRepoSearch matches the query against name, owner/name, description,
652// and topics of every repository the caller can see.
653func runRepoSearch(c *Ctx, args []string) int {
654 if len(args) != 1 {
655 return c.fail(protocol.ExitUsage, "usage: repo search <query>")
656 }
657 if err := validQuery(args[0]); err != nil {
658 return c.fail(protocol.ExitUsage, "%v", err)
659 }
660 q := strings.ToLower(args[0])
661
662 public, err := c.Store.ListPublicRepos()
663 if err != nil {
664 return c.fail(protocol.ExitFailure, "%v", err)
665 }
666 own, err := c.Store.ListReposForUser(c.User.ID)
667 if err != nil {
668 return c.fail(protocol.ExitFailure, "%v", err)
669 }
670 seen := map[int64]bool{}
671 type out struct {
672 Path string `json:"path"`
673 Visibility string `json:"visibility"`
674 Description string `json:"description,omitempty"`
675 Topics []string `json:"topics,omitempty"`
676 }
677 var ds []out
678 for _, r := range append(public, own...) {
679 if seen[r.ID] {
680 continue
681 }
682 seen[r.ID] = true
683 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
684 topics, _ := c.Store.ListTopics(r.ID)
685 if !matchesRepo(q, r, desc, topics) {
686 continue
687 }
688 ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
689 }
690 return c.emit(ds, func(w io.Writer) {
691 for _, d := range ds {
692 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description)
693 }
694 })
695}
696
697func matchesRepo(q string, r store.Repo, desc string, topics []string) bool {
698 if strings.Contains(strings.ToLower(r.Path()), q) ||
699 strings.Contains(strings.ToLower(desc), q) {
700 return true
701 }
702 for _, t := range topics {
703 if strings.Contains(t, q) {
704 return true
705 }
706 }
707 return false
708}
709
710func runRepoGrep(c *Ctx, args []string) int {
711 var path, query, ref string
712 for i := 0; i < len(args); i++ {
713 switch args[i] {
714 case "--ref":
715 if i+1 >= len(args) {
716 return c.fail(protocol.ExitUsage, "--ref requires a value")
717 }
718 ref = args[i+1]
719 i++
720 default:
721 if path == "" {
722 path = args[i]
723 } else if query == "" {
724 query = args[i]
725 } else {
726 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
727 }
728 }
729 }
730 if path == "" || query == "" {
731 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
732 }
733 if err := validQuery(query); err != nil {
734 return c.fail(protocol.ExitUsage, "%v", err)
735 }
736 repo, code := resolveRepo(c, path, policy.CanRead)
737 if code >= 0 {
738 return code
739 }
740 if ref == "" {
741 ref = repo.DefaultBranch
742 }
743 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
744 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
745 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
746 }
747 matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches)
748 if err != nil {
749 return c.fail(protocol.ExitFailure, "%v", err)
750 }
751 type out struct {
752 Path string `json:"path"`
753 Line int `json:"line"`
754 Text string `json:"text"`
755 }
756 var ds []out
757 for _, m := range matches {
758 ds = append(ds, out{m.Path, m.Line, m.Text})
759 }
760 return c.emit(ds, func(w io.Writer) {
761 for _, d := range ds {
762 fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text)
763 }
764 })
765}
766
767func runRepoPin(c *Ctx, args []string) int { return setPinned(c, args, true) }
768func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) }
769
770func setPinned(c *Ctx, args []string, pin bool) int {
771 verb := "pin"
772 if !pin {
773 verb = "unpin"
774 }
775 if len(args) != 1 {
776 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
777 }
778 repo, code := resolveRepo(c, args[0], policy.CanRead)
779 if code >= 0 {
780 return code
781 }
782 if pin {
783 if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil {
784 return c.fail(protocol.ExitFailure, "%v", err)
785 }
786 } else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil {
787 if errors.Is(err, store.ErrNotFound) {
788 return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path())
789 }
790 return c.fail(protocol.ExitFailure, "%v", err)
791 }
792 return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) {
793 fmt.Fprintf(w, "%sned %s\n", verb, repo.Path())
794 })
795}
796
797func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
798func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
799
800func setProtect(c *Ctx, args []string, protect bool) int {
801 if len(args) != 2 {
802 return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
803 }
804 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
805 if code >= 0 {
806 return code
807 }
808 branch := args[1]
809 s := repo.Settings
810 has := slices.Contains(s.ProtectedBranches, branch)
811 if protect && !has {
812 s.ProtectedBranches = append(s.ProtectedBranches, branch)
813 slices.Sort(s.ProtectedBranches)
814 }
815 if !protect && has {
816 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
817 }
818 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
819 return c.fail(protocol.ExitFailure, "%v", err)
820 }
821 verb := "protected"
822 if !protect {
823 verb = "unprotected"
824 }
825 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
826}