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