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