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