internal/control/repo.go
1552 lines · 54158 bytes
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "os"
8 "path"
9 "path/filepath"
10 "slices"
11 "strconv"
12 "strings"
13
14 "gitbay.org/gitbay/internal/backuplock"
15 "gitbay.org/gitbay/internal/gitutil"
16 "gitbay.org/gitbay/internal/policy"
17 "gitbay.org/gitbay/internal/protocol"
18 "gitbay.org/gitbay/internal/store"
19)
20
21// RepoDir returns the on-disk path for a repository.
22func RepoDir(root, owner, name string) string {
23 return filepath.Join(root, "repos", owner, name+".git")
24}
25
26// HooksDir is the shared core.hooksPath directory.
27func HooksDir(root string) string { return filepath.Join(root, "hooks") }
28
29func init() {
30 register(Command{Path: []string{"repo", "create"},
31 Summary: "create a repository",
32 Usage: "repo create <owner/name> [--private]",
33 Flags: []Flag{
34 {"--private", "", "create it private", ""},
35 },
36 Examples: []string{"repo create krz/newthing --private"},
37 Run: runRepoCreate})
38 register(Command{Path: []string{"repo", "list"},
39 Summary: "list repositories you own or can access",
40 Usage: "repo list [--limit <n>] [--cursor <c>]",
41 Flags: []Flag{
42 {"--limit", "<n>", "rows per page", ""},
43 {"--cursor", "<c>", "continue from the previous page", ""},
44 },
45 Examples: []string{"repo list --limit 20"},
46 ReadOnly: true, Run: runRepoList})
47 register(Command{Path: []string{"repo", "show"},
48 Summary: "show repository details",
49 Usage: "repo show <owner/name>",
50 Examples: []string{"repo show krz/gitbay"},
51 ReadOnly: true, Run: runRepoShow})
52 register(Command{Path: []string{"repo", "transfer"},
53 NeedsRecentSignIn: true,
54 Summary: "move a repository to another owner",
55 Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)",
56 Examples: []string{"repo transfer krz/gitbay krazywarez"},
57 Run: runRepoTransfer})
58 register(Command{Path: []string{"repo", "rename"},
59 NeedsRecentSignIn: true,
60 Summary: "rename a repository",
61 Usage: "repo rename <owner/name> <new-name> (clone URLs change)",
62 Examples: []string{"repo rename krz/gitbay forge"},
63 Run: runRepoRename})
64 register(Command{Path: []string{"repo", "delete"},
65 NeedsRecentSignIn: true,
66 Summary: "delete a repository",
67 Usage: "repo delete <owner/name> --yes",
68 Flags: []Flag{
69 {"--yes", "", "confirm the permanent delete", ""},
70 },
71 Examples: []string{"repo delete cmc/scratch --yes"},
72 Run: runRepoDelete})
73 register(Command{Path: []string{"repo", "access", "grant"},
74 NeedsRecentSignIn: true,
75 Summary: "grant access",
76 Usage: "repo access grant <owner/name> <user> read|write|admin",
77 Examples: []string{"repo access grant krz/gitbay cmc write"},
78 Run: runAccessGrant})
79 register(Command{Path: []string{"repo", "access", "revoke"},
80 Summary: "revoke access",
81 Usage: "repo access revoke <owner/name> <user>",
82 Examples: []string{"repo access revoke krz/gitbay cmc"},
83 Run: runAccessRevoke})
84 register(Command{Path: []string{"repo", "access", "list"},
85 Summary: "list who can reach the repository, with the role and where it comes from",
86 Usage: "repo access list <owner/name>",
87 Examples: []string{"repo access list krz/gitbay"},
88 ReadOnly: true, Run: runAccessList})
89 register(Command{Path: []string{"repo", "settings", "show"},
90 Summary: "show settings",
91 Usage: "repo settings show <owner/name>",
92 Examples: []string{"repo settings show krz/gitbay"},
93 ReadOnly: true, Run: runSettingsShow})
94 register(Command{Path: []string{"repo", "settings", "protect"},
95 Summary: "protect a branch",
96 Usage: "repo settings protect <owner/name> <branch>",
97 Examples: []string{"repo settings protect krz/gitbay main"},
98 Run: runProtect})
99 register(Command{Path: []string{"repo", "settings", "unprotect"},
100 Summary: "unprotect a branch",
101 Usage: "repo settings unprotect <owner/name> <branch>",
102 Examples: []string{"repo settings unprotect krz/gitbay main"},
103 Run: runUnprotect})
104 register(Command{Path: []string{"repo", "settings", "protect-tag"},
105 Summary: "protect tags matching a glob (created once, never moved or deleted)",
106 Usage: "repo settings protect-tag <owner/name> <glob>",
107 Examples: []string{"repo settings protect-tag krz/gitbay 'v*'"},
108 Run: runProtectTag})
109 register(Command{Path: []string{"repo", "settings", "unprotect-tag"},
110 Summary: "drop a protected-tag glob",
111 Usage: "repo settings unprotect-tag <owner/name> <glob>",
112 Examples: []string{"repo settings unprotect-tag krz/gitbay 'v*'"},
113 Run: runUnprotectTag})
114 register(Command{Path: []string{"repo", "settings", "description"},
115 Summary: "set the repository description",
116 Usage: "repo settings description <owner/name> <text> ('' clears)",
117 Examples: []string{`repo settings description krz/gitbay "a CLI-first git forge"`},
118 Run: runSetDescription})
119 register(Command{Path: []string{"repo", "settings", "visibility"},
120 Summary: "set repository visibility",
121 Usage: "repo settings visibility <owner/name> public|private",
122 Examples: []string{"repo settings visibility krz/gitbay public"},
123 // Making a repository public shows it to everyone.
124 NeedsRecentSignIn: true,
125 Run: runSetVisibility})
126 register(Command{Path: []string{"repo", "settings", "website"},
127 Summary: "set the repository website",
128 Usage: "repo settings website <owner/name> <url> ('' clears)",
129 Examples: []string{"repo settings website krz/gitbay https://gitbay.org"},
130 Run: runSetWebsite})
131 register(Command{Path: []string{"repo", "settings", "default-branch"},
132 Summary: "set the default branch",
133 Usage: "repo settings default-branch <owner/name> <branch>",
134 Examples: []string{"repo settings default-branch krz/gitbay main"},
135 Run: runSetDefaultBranch})
136 register(Command{Path: []string{"repo", "settings", "git-daemon"},
137 Summary: "expose over git://",
138 Usage: "repo settings git-daemon <owner/name> on|off",
139 Examples: []string{"repo settings git-daemon krz/gitbay on"},
140 Run: runGitDaemon})
141 register(Command{Path: []string{"repo", "archive"},
142 Summary: "archive a repository (read-only: pushes and issue/MR writes refused)",
143 Usage: "repo archive <owner/name>",
144 Examples: []string{"repo archive krz/gitbay"},
145 Run: runArchive})
146 register(Command{Path: []string{"repo", "unarchive"},
147 Summary: "unarchive a repository",
148 Usage: "repo unarchive <owner/name>",
149 Examples: []string{"repo unarchive krz/gitbay"},
150 Run: runUnarchive})
151 register(Command{Path: []string{"repo", "topics"},
152 Summary: "list topics",
153 Usage: "repo topics <owner/name>",
154 Examples: []string{"repo topics krz/gitbay"},
155 ReadOnly: true, Run: runTopicsList})
156 register(Command{Path: []string{"repo", "topics", "add"},
157 Summary: "add topics",
158 Usage: "repo topics add <owner/name> <topic>...",
159 Examples: []string{"repo topics add krz/gitbay git forge cli"},
160 Run: runTopicsAdd})
161 register(Command{Path: []string{"repo", "topics", "remove"},
162 Summary: "remove topics",
163 Usage: "repo topics remove <owner/name> <topic>...",
164 Examples: []string{"repo topics remove krz/gitbay cli"},
165 Run: runTopicsRemove})
166 register(Command{Path: []string{"repo", "search"},
167 Summary: "find repositories by name, description, or topic",
168 Usage: "repo search <query>",
169 Examples: []string{"repo search forge"},
170 ReadOnly: true, Run: runRepoSearch})
171 register(Command{Path: []string{"repo", "grep"},
172 Summary: "search file contents",
173 Usage: "repo grep <owner/name> <query> [--ref <ref>]",
174 Flags: []Flag{
175 {"--ref", "<ref>", "branch, tag or commit to search", "the default branch"},
176 },
177 Examples: []string{"repo grep krz/gitbay TODO"},
178 ReadOnly: true, Run: runRepoGrep})
179 register(Command{Path: []string{"repo", "diff"},
180 Summary: "the patch between two refs, from their merge base",
181 Usage: "repo diff <owner/name> <base> <head>",
182 Examples: []string{"repo diff krz/gitbay main cli-output-help"},
183 ReadOnly: true, Run: runRepoDiff})
184 register(Command{Path: []string{"repo", "pin"},
185 Summary: "pin a repository to your dashboard",
186 Usage: "repo pin <owner/name>",
187 Examples: []string{"repo pin krz/gitbay"},
188 Run: runRepoPin})
189 register(Command{Path: []string{"repo", "unpin"},
190 Summary: "unpin a repository",
191 Usage: "repo unpin <owner/name>",
192 Examples: []string{"repo unpin krz/gitbay"},
193 Run: runRepoUnpin})
194 register(Command{Path: []string{"repo", "bookmark"},
195 Summary: "bookmark a repository to come back to",
196 Usage: "repo bookmark <owner/name>",
197 Examples: []string{"repo bookmark krz/gitbay"},
198 Run: runRepoBookmark})
199 register(Command{Path: []string{"repo", "unbookmark"},
200 Summary: "remove a bookmark",
201 Usage: "repo unbookmark <owner/name>",
202 Examples: []string{"repo unbookmark krz/gitbay"},
203 Run: runRepoUnbookmark})
204 register(Command{Path: []string{"repo", "bookmarks"},
205 Summary: "list the repositories you have bookmarked",
206 Usage: "repo bookmarks",
207 Examples: []string{"repo bookmarks"},
208 ReadOnly: true, Run: runRepoBookmarks})
209}
210
211const (
212 minQueryLen = 2
213 maxQueryLen = 200
214 maxGrepMatches = 200
215)
216
217func validQuery(q string) error {
218 if len(q) < minQueryLen || len(q) > maxQueryLen {
219 return fmt.Errorf("query must be %d to %d characters", minQueryLen, maxQueryLen)
220 }
221 return nil
222}
223
224// refuseArchived blocks content writes (pushes are refused in the transport
225// layer) on archived repositories. Settings, access, and lifecycle commands
226// stay available so an archived repo can be managed and unarchived.
227func refuseArchived(c *Ctx, repo store.Repo) int {
228 if repo.Settings.Archived {
229 return c.fail(protocol.ExitDenied, "%s is archived and read-only; unarchive it first", repo.Path())
230 }
231 return -1
232}
233
234// resolveRepo loads a repo and checks the given permission for c.User.
235func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
236 repo, err := c.Store.RepoByPath(path)
237 if err != nil {
238 if errors.Is(err, store.ErrNotFound) {
239 // Same message whether it doesn't exist or is invisible.
240 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
241 }
242 return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
243 }
244 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
245 if err != nil {
246 return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
247 }
248 if !check(c.User, repo, grant) {
249 if !policy.CanRead(c.User, repo, grant) {
250 // Invisible repos 404, per the enumeration rule.
251 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
252 }
253 return repo, c.fail(protocol.ExitDenied, "permission denied on %s; ask its owner for access", path)
254 }
255 return repo, -1
256}
257
258func runRepoCreate(c *Ctx, args []string) int {
259 f, err := c.parseArgs(args, flagSpec{Values: []string{"--description"}, Bools: []string{"--private"}, MaxPos: 1, Usage: "repo create <owner/name> [--private] [--description <text>]"})
260 if err != nil {
261 return c.fail(protocol.ExitUsage, "%v", err)
262 }
263 visibility, path, description := "public", f.pos(0), f.Value("--description")
264 if f.Has("--private") {
265 visibility = "private"
266 }
267 owner, name, ok := strings.Cut(path, "/")
268 if !ok {
269 return c.usage()
270 }
271 if err := policyValidateRepoName(name); err != nil {
272 return c.failInput(err)
273 }
274 ownerKind, ownerID, code := resolveNewRepoOwner(c, owner)
275 if code >= 0 {
276 return code
277 }
278 repoCreateMu.Lock()
279 if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 {
280 repoCreateMu.Unlock()
281 return code
282 }
283 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
284 repoCreateMu.Unlock()
285 if err != nil {
286 return c.fail(protocol.ExitFailure, "%v", err)
287 }
288 dir := RepoDir(c.Cfg.Server.Root, owner, name)
289 if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
290 c.Store.DeleteRepo(id)
291 return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
292 }
293 if description != "" {
294 if err := gitutil.WriteDescription(dir, description); err != nil {
295 return c.fail(protocol.ExitFailure, "writing description: %v", err)
296 }
297 }
298 type out struct {
299 Path string `json:"path"`
300 Visibility string `json:"visibility"`
301 SSHURL string `json:"ssh_url"`
302 }
303 d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
304 return c.emit(d, func(w io.Writer) {
305 fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
306 })
307}
308
309// resolveNewRepoOwner answers who a new repository belongs to: the
310// caller, or an organization they administer. The returned code is -1
311// when the owner is good, and the exit code to return otherwise.
312func resolveNewRepoOwner(c *Ctx, owner string) (kind string, id int64, code int) {
313 if owner == c.User.Username {
314 return "user", c.User.ID, -1
315 }
316 org, err := c.Store.OrgByName(owner)
317 if err != nil {
318 return "", 0, c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
319 }
320 role, err := c.Store.OrgRole(org.ID, c.User.ID)
321 if err != nil {
322 return "", 0, c.fail(protocol.ExitFailure, "%v", err)
323 }
324 if role != "admin" {
325 return "", 0, c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
326 }
327 return "org", org.ID, -1
328}
329
330func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
331
332func hostOf(siteURL string) string {
333 s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
334 return strings.TrimSuffix(s, "/")
335}
336
337func runRepoList(c *Ctx, args []string) int {
338 args, p, code := parsePageFlags(c, args, "repo", false)
339 if code >= 0 {
340 return code
341 }
342 if len(args) != 0 {
343 return c.usage()
344 }
345 repos, err := c.Store.ListReposForUser(c.User.ID, p.queryLimit(), p.key)
346 if err != nil {
347 return c.fail(protocol.ExitFailure, "%v", err)
348 }
349 repos, next := trimPage(p, repos, "repo", store.Repo.Path)
350 type out struct {
351 Path string `json:"path"`
352 Visibility string `json:"visibility"`
353 Description string `json:"description,omitempty"`
354 Archived bool `json:"archived,omitempty"`
355 }
356 var ds []out
357 for _, r := range repos {
358 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
359 ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
360 }
361 return c.emitPageView(p, ds, next, func(w io.Writer) {
362 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
363 for _, d := range ds {
364 cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)}
365 if d.Archived {
366 cells = c.note(cells, 1, "[archived]", "archived")
367 }
368 tb.row(cells...)
369 }
370 tb.flush()
371 }, func() screen {
372 rows := make([]row, len(ds))
373 for i, d := range ds {
374 state := d.Visibility
375 if d.Archived {
376 state += ", archived"
377 }
378 rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(state), cFlex(d.Description))
379 }
380 s := listScreen("Repositories", rows)
381 if len(ds) > 0 {
382 s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}}
383 }
384 return s
385 })
386}
387
388// repoMirrorOut is one mirror as repo show emits it, for admins.
389type repoMirrorOut struct {
390 Direction string `json:"direction"`
391 URL string `json:"url"`
392 Pending bool `json:"pending"`
393 LastSync string `json:"last_sync,omitempty"`
394 LastError string `json:"last_error,omitempty"`
395}
396
397// repoShowOut is what repo show emits.
398type repoShowOut struct {
399 Path string `json:"path"`
400 Description string `json:"description,omitempty"`
401 Website string `json:"website,omitempty"`
402 Visibility string `json:"visibility"`
403 DefaultBranch string `json:"default_branch"`
404 ProtectedBranches []string `json:"protected_branches,omitempty"`
405 Archived bool `json:"archived,omitempty"`
406 Topics []string `json:"topics,omitempty"`
407 Domains []string `json:"domains,omitempty"`
408 Mirrors []repoMirrorOut `json:"mirrors,omitempty"`
409 // ForkOf names the parent only when the caller can read it: a
410 // private parent is not confirmed to exist, here as anywhere.
411 ForkOf string `json:"fork_of,omitempty"`
412 // Watch and Bookmarked are the caller's own state, so a client
413 // can draw a toggle rather than two stateless buttons (#178).
414 Watch string `json:"watch,omitempty"` // watching, muted, or absent
415 Bookmarked bool `json:"bookmarked,omitempty"`
416}
417
418func runRepoShow(c *Ctx, args []string) int {
419 if len(args) != 1 {
420 return c.usage()
421 }
422 repo, code := resolveRepo(c, args[0], policy.CanRead)
423 if code >= 0 {
424 return code
425 }
426 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
427 topics, err := c.Store.ListTopics(repo.ID)
428 if err != nil {
429 return c.fail(protocol.ExitFailure, "%v", err)
430 }
431 var domains []string
432 if ds, err := c.Store.ListPageDomains(repo.ID); err == nil {
433 for _, pd := range ds {
434 if pd.Verified() {
435 domains = append(domains, pd.Domain)
436 }
437 }
438 }
439 d := repoShowOut{Path: repo.Path(), Description: desc, Website: repo.Settings.Website, Visibility: repo.Visibility,
440 DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches,
441 Archived: repo.Settings.Archived, Topics: topics, Domains: domains}
442 if repo.ForkOf != 0 {
443 if parent, err := c.Store.RepoByID(repo.ForkOf); err == nil {
444 if grant, err := c.Store.AccessRole(parent.ID, c.User.ID); err == nil && policy.CanRead(c.User, parent, grant) {
445 d.ForkOf = parent.Path()
446 }
447 }
448 }
449 if c.User.ID != 0 {
450 d.Watch = c.Store.RepoWatchState(repo.ID, c.User.ID)
451 d.Bookmarked = c.Store.IsBookmarked(c.User.ID, repo.ID)
452 }
453 // Mirror status is admin-only, like repo mirror list. The token never
454 // leaves the server.
455 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
456 ms, err := c.Store.ListMirrors(repo.ID)
457 if err != nil {
458 return c.fail(protocol.ExitFailure, "%v", err)
459 }
460 for _, m := range ms {
461 d.Mirrors = append(d.Mirrors, repoMirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
462 }
463 }
464 var glance repoGlance
465 var mrs []store.MR
466 var issues []store.Issue
467 var commits []CommitOut
468 if c.Term.Cols > 0 && !c.JSON {
469 glance = repoAtAGlance(c, repo)
470 mrs, _ = c.Store.ListMRs(repo.ID, "open", 5, 0)
471 issues, _ = c.Store.QueryIssues(repo.ID, store.IssueFilter{State: "open", Limit: 5})
472 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
473 if shas, err := gitutil.RevList(dir, "refs/heads/"+repo.DefaultBranch, 5); err == nil {
474 subjects := gitutil.Subjects(dir, shas)
475 for _, sha := range shas {
476 commits = append(commits, CommitOut{sha, subjects[sha]})
477 }
478 }
479 }
480 return c.emitView(d, func(w io.Writer) {
481 bookmarked, archived := "", ""
482 if d.Bookmarked {
483 bookmarked = "yes"
484 }
485 if d.Archived {
486 archived = "yes"
487 }
488 v := c.view(w)
489 v.title(d.Path, d.Description, d.Visibility)
490 v.fields(
491 "default branch", d.DefaultBranch,
492 "website", d.Website,
493 "topics", strings.Join(d.Topics, ", "),
494 "protected", strings.Join(d.ProtectedBranches, ", "),
495 "pages domains", strings.Join(d.Domains, ", "),
496 "fork of", d.ForkOf,
497 "watch", d.Watch,
498 "bookmarked", bookmarked,
499 "archived", archived,
500 "url", c.siteURL(d.Path),
501 )
502 if len(d.Mirrors) > 0 {
503 v.section("mirror")
504 tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS")
505 for _, m := range d.Mirrors {
506 status := "ok"
507 if m.Pending {
508 status = "pending"
509 }
510 if m.LastError != "" {
511 status = "error: " + m.LastError
512 }
513 tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(c.when(m.LastSync))), cState(status))
514 }
515 tb.flush()
516 }
517 }, func() screen { return repoShowScreen(c, d, glance, mrs, issues, commits) })
518}
519
520// repoGlance is what repo show adds at a terminal: how to clone it and
521// what is going on in it.
522type repoGlance struct {
523 clone, release, checks string
524 issuesN, mrsN int
525}
526
527// repoAtAGlance reads the glance fields. Each is left blank when it
528// cannot be read: they are a summary, not the command's result.
529func repoAtAGlance(c *Ctx, repo store.Repo) repoGlance {
530 host := c.Cfg.SiteHost()
531 if c.Cfg.SSH.Port != 22 {
532 host += ":" + strconv.Itoa(c.Cfg.SSH.Port)
533 }
534 g := repoGlance{clone: "ssh://git@" + host + "/" + repo.Path() + ".git"}
535 g.issuesN, g.mrsN = c.Store.OpenCounts(repo.ID)
536 if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 {
537 g.release = rs[0].Tag + ", " + relAge(rs[0].CreatedAt, termNow())
538 }
539 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
540 if tip, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
541 if sts, err := c.Store.ListCommitStatuses(repo.ID, tip); err == nil {
542 if m := checksMark(sts); m.s != "" {
543 g.checks = m.s + " on " + repo.DefaultBranch
544 }
545 }
546 }
547 return g
548}
549
550func runRepoTransfer(c *Ctx, args []string) int {
551 if len(args) != 2 {
552 return c.usage()
553 }
554 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
555 if code >= 0 {
556 return code
557 }
558 newOwner := args[1]
559 if newOwner == repo.OwnerName {
560 return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
561 }
562
563 // Target: yourself, or an org you admin — same rule as repo create.
564 newKind, newID := "", int64(0)
565 if newOwner == c.User.Username {
566 newKind, newID = "user", c.User.ID
567 } else if org, err := c.Store.OrgByName(newOwner); err == nil {
568 role, err := c.Store.OrgRole(org.ID, c.User.ID)
569 if err != nil {
570 return c.fail(protocol.ExitFailure, "%v", err)
571 }
572 if role != "admin" {
573 return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
574 }
575 newKind, newID = "org", org.ID
576 } else {
577 return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
578 }
579
580 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
581 newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
582 // The receiving owner's caps apply as if the repository were created
583 // there. The lock covers the move so two transfers cannot both pass.
584 repoCreateMu.Lock()
585 defer repoCreateMu.Unlock()
586 if code := checkRepoQuota(c, newKind, newID); code >= 0 {
587 return code
588 }
589 if code := checkBytesLeft(c, newKind, newID, newOwner, gitutil.DirSize(oldDir)); code >= 0 {
590 return code
591 }
592 if _, err := os.Stat(newDir); err == nil {
593 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
594 }
595 release, lockCode := holdOffBackup(c)
596 if lockCode >= 0 {
597 return lockCode
598 }
599 defer release()
600 // The directory moves before the record changes: a move that fails
601 // leaves nothing to undo, whereas the record's change into an org
602 // folds labels and milestones into the org's rows, which a revert
603 // cannot unfold (#212). A record that then fails moves the directory
604 // back, and says so if even that fails, since the operator then has
605 // a row pointing at a directory that is not there.
606 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
607 return c.fail(protocol.ExitFailure, "%v", err)
608 }
609 if err := os.Rename(oldDir, newDir); err != nil {
610 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
611 }
612 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
613 if rerr := os.Rename(newDir, oldDir); rerr != nil {
614 return c.fail(protocol.ExitFailure, "%v; and moving the directory back failed: %v (the record still names %s but the directory is now %s)", err, rerr, repo.Path(), newOwner+"/"+repo.Name)
615 }
616 return c.failErr(err)
617 }
618 newPath := newOwner + "/" + repo.Name
619 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
620 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
621 })
622}
623
624func runRepoRename(c *Ctx, args []string) int {
625 if len(args) != 2 {
626 return c.usage()
627 }
628 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
629 if code >= 0 {
630 return code
631 }
632 newName := args[1]
633 if newName == repo.Name {
634 return c.fail(protocol.ExitUsage, "%s is already named %s", repo.Path(), newName)
635 }
636 if err := policyValidateRepoName(newName); err != nil {
637 return c.failInput(err)
638 }
639 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
640 newDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, newName)
641 if _, err := os.Stat(newDir); err == nil {
642 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", repo.OwnerName, newName)
643 }
644 release, lockCode := holdOffBackup(c)
645 if lockCode >= 0 {
646 return lockCode
647 }
648 defer release()
649 if err := c.Store.RenameRepo(repo.ID, newName); err != nil {
650 return c.failErr(err)
651 }
652 if err := os.Rename(oldDir, newDir); err != nil {
653 // Same rule as transfer: keep name and disk consistent, and say so
654 // if even the revert fails.
655 if rerr := c.Store.RenameRepo(repo.ID, repo.Name); rerr != nil {
656 return c.fail(protocol.ExitFailure, "moving repository: %v; and reverting the record failed: %v (the record now names %s/%s but the directory is still %s)", err, rerr, repo.OwnerName, newName, repo.Path())
657 }
658 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
659 }
660 newPath := repo.OwnerName + "/" + newName
661 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
662 fmt.Fprintf(w, "renamed %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
663 })
664}
665
666func runRepoDelete(c *Ctx, args []string) int {
667 var path string
668 var yes bool
669 for _, a := range args {
670 if a == "--yes" {
671 yes = true
672 } else if path == "" {
673 path = a
674 } else {
675 return c.usage()
676 }
677 }
678 if path == "" {
679 return c.usage()
680 }
681 repo, code := resolveRepo(c, path, policy.CanAdmin)
682 if code >= 0 {
683 return code
684 }
685 if !yes {
686 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
687 }
688 return deleteRepo(c, repo)
689}
690
691// deleteRepo removes a repository the caller has already been cleared to
692// delete: the database row, then the directory.
693//
694// There is deliberately no repo.deleted event. events.repo_id and
695// webhooks.repo_id both cascade from repos, so recording one would delete
696// it, and every webhook that could have subscribed, in the same
697// statement. A repository's deletion is not observable through its own
698// webhooks; an instance that needs to hear about it wants the audit log
699// (#112).
700func deleteRepo(c *Ctx, repo store.Repo) int {
701 release, lockCode := holdOffBackup(c)
702 if lockCode >= 0 {
703 return lockCode
704 }
705 defer release()
706 if err := removeRepo(c.Store, c.Cfg.Server.Root, repo); err != nil {
707 return c.fail(protocol.ExitFailure, "%v", err)
708 }
709 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
710 fmt.Fprintf(w, "deleted %s\n", repo.Path())
711 })
712}
713
714// removeRepo is deleteRepo's work without a request: the caller holds
715// off the backup.
716func removeRepo(st *store.Store, root string, repo store.Repo) error {
717 // Open MRs sourced from this repo keep working (targets own the
718 // objects) but must show that the source is gone.
719 if err := st.MarkSourceGoneForRepo(repo.ID); err != nil {
720 return err
721 }
722 if err := st.DeleteRepo(repo.ID); err != nil {
723 return err
724 }
725 if err := os.RemoveAll(RepoDir(root, repo.OwnerName, repo.Name)); err != nil {
726 return fmt.Errorf("database row removed but disk cleanup failed: %w", err)
727 }
728 return nil
729}
730
731// holdOffBackup keeps a full backup from starting while a repository
732// directory moves or goes, and refuses while one runs: the backup's
733// database snapshot names every repository its walk then archives
734// (#259). The caller defers the returned release.
735func holdOffBackup(c *Ctx) (func(), int) {
736 release, err := backuplock.TryShared(c.Cfg.Server.Root)
737 if err != nil {
738 return nil, c.fail(protocol.ExitFailure, "%v", err)
739 }
740 return release, -1
741}
742
743func runAccessGrant(c *Ctx, args []string) int {
744 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
745 return c.usage()
746 }
747 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
748 if code >= 0 {
749 return code
750 }
751 target, err := c.Store.UserByUsername(args[1])
752 if err != nil {
753 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
754 }
755 if target.Ghost {
756 return c.fail(protocol.ExitDenied, "%v", errGhost)
757 }
758 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
759 return c.fail(protocol.ExitFailure, "%v", err)
760 }
761 return c.emit(map[string]string{"granted": args[2], "user": target.Username},
762 func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
763}
764
765func runAccessRevoke(c *Ctx, args []string) int {
766 if len(args) != 2 {
767 return c.usage()
768 }
769 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
770 if code >= 0 {
771 return code
772 }
773 target, err := c.Store.UserByUsername(args[1])
774 if err != nil {
775 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
776 }
777 if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
778 if errors.Is(err, store.ErrNotFound) {
779 return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
780 }
781 return c.fail(protocol.ExitFailure, "%v", err)
782 }
783 return c.emit(map[string]string{"revoked": target.Username},
784 func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
785}
786
787func runAccessList(c *Ctx, args []string) int {
788 if len(args) != 1 {
789 return c.usage()
790 }
791 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
792 if code >= 0 {
793 return code
794 }
795 entries, err := c.Store.EffectiveAccess(repo.ID)
796 if err != nil {
797 return c.fail(protocol.ExitFailure, "%v", err)
798 }
799 type out struct {
800 User string `json:"user"`
801 Role string `json:"role"`
802 Source string `json:"source"`
803 }
804 var ds []out
805 for _, e := range entries {
806 ds = append(ds, out{e.Username, e.Role, e.Source})
807 }
808 return c.emitView(ds, func(w io.Writer) {
809 tb := c.table(w, "USER", "ROLE", "SOURCE")
810 for _, d := range ds {
811 tb.row(cRef(d.User), cState(d.Role), cText("via "+d.Source))
812 }
813 tb.flush()
814 }, func() screen {
815 rows := make([]row, len(ds))
816 for i, d := range ds {
817 rows[i] = rowOf(cRef(d.User), cState(d.Role), cMeta("via "+d.Source))
818 }
819 return listScreen("Access", rows,
820 action{"Access", []string{"repo", "access", "grant", repo.Path(), "<user>", "write"}},
821 action{"Access", []string{"repo", "access", "revoke", repo.Path(), "<user>"}},
822 )
823 })
824}
825
826func runSettingsShow(c *Ctx, args []string) int {
827 if len(args) != 1 {
828 return c.usage()
829 }
830 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
831 if code >= 0 {
832 return code
833 }
834 return c.emitView(repo.Settings, func(w io.Writer) {
835 v := c.view(w)
836 v.title(repo.Path(), "settings", "")
837 v.fields(
838 "protected branches", strings.Join(repo.Settings.ProtectedBranches, ", "),
839 "protected tags", strings.Join(repo.Settings.ProtectedTags, ", "),
840 "require mr", strconv.FormatBool(repo.Settings.RequireMR),
841 "require checks", strconv.FormatBool(repo.Settings.RequireChecks),
842 "required contexts", strings.Join(repo.Settings.RequiredContexts, ", "),
843 "require signed commits", strconv.FormatBool(repo.Settings.RequireSignedCommits),
844 "git daemon", strconv.FormatBool(repo.Settings.GitDaemon),
845 "archived", strconv.FormatBool(repo.Settings.Archived),
846 )
847 }, func() screen {
848 set := repo.Settings
849 onOff := func(b bool) cell {
850 if b {
851 return cText("on")
852 }
853 return cMeta("off")
854 }
855 approvals := cMeta("off")
856 if set.RequireApprovals > 0 {
857 approvals = cText(strconv.Itoa(set.RequireApprovals))
858 }
859 list := func(xs []string) cell {
860 if len(xs) == 0 {
861 return cMeta("none")
862 }
863 return cText(strings.Join(xs, ", "))
864 }
865 mr := "on"
866 if set.RequireMR {
867 mr = "off"
868 }
869 s := screen{fields: []field{
870 {"Repo", []cell{cLink(repo.Path(), c.siteURL(repo.Path())), cMeta("settings")}},
871 {"Protected", []cell{list(set.ProtectedBranches)}},
872 {"Protected tags", []cell{list(set.ProtectedTags)}},
873 {"Require MR", []cell{onOff(set.RequireMR)}},
874 {"Require checks", []cell{onOff(set.RequireChecks)}},
875 {"Contexts", []cell{list(set.RequiredContexts)}},
876 {"Approvals", []cell{approvals}},
877 {"Resolved threads", []cell{onOff(set.RequireResolved)}},
878 {"Code owners", []cell{onOff(set.RequireCodeowners)}},
879 {"Signed commits", []cell{onOff(set.RequireSignedCommits)}},
880 {"Git daemon", []cell{onOff(set.GitDaemon)}},
881 {"Archived", []cell{onOff(set.Archived)}},
882 }, actions: []action{
883 {"Settings", []string{"repo", "settings", "protect", repo.Path(), "<branch>"}},
884 {"Settings", []string{"repo", "settings", "require-mr", repo.Path(), mr}},
885 }}
886 if set.Website != "" {
887 s.fields = append(s.fields, field{"Website", []cell{cText(set.Website)}})
888 }
889 return s
890 })
891}
892
893func runSetDescription(c *Ctx, args []string) int {
894 if len(args) != 2 {
895 return c.usage()
896 }
897 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
898 if code >= 0 {
899 return code
900 }
901 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
902 if err := gitutil.WriteDescription(dir, args[1]); err != nil {
903 return c.fail(protocol.ExitFailure, "%v", err)
904 }
905 return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
906 fmt.Fprintf(w, "description set on %s\n", repo.Path())
907 })
908}
909
910func runSetDefaultBranch(c *Ctx, args []string) int {
911 if len(args) != 2 {
912 return c.usage()
913 }
914 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
915 if code >= 0 {
916 return code
917 }
918 branch := args[1]
919 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
920 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+branch); err != nil {
921 return c.fail(protocol.ExitFailure, "no branch named %q on %s", branch, repo.Path())
922 }
923 if err := gitutil.SetHead(dir, branch); err != nil {
924 return c.fail(protocol.ExitFailure, "%v", err)
925 }
926 if err := c.Store.UpdateDefaultBranch(repo.ID, branch); err != nil {
927 return c.fail(protocol.ExitFailure, "%v", err)
928 }
929 c.Store.RequestSymbolIndex(repo.ID, false)
930 return c.emit(map[string]string{"default_branch": branch}, func(w io.Writer) {
931 fmt.Fprintf(w, "default branch of %s is now %s\n", repo.Path(), branch)
932 })
933}
934
935func runSetWebsite(c *Ctx, args []string) int {
936 if len(args) != 2 {
937 return c.usage()
938 }
939 site := strings.TrimSpace(args[1])
940 if err := validateWebsite(site); err != nil {
941 return c.failInput(err)
942 }
943 if len(site) > 256 {
944 return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
945 }
946 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
947 if code >= 0 {
948 return code
949 }
950 if _, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Website = site }); err != nil {
951 return c.fail(protocol.ExitFailure, "%v", err)
952 }
953 return c.emit(map[string]string{"website": site}, func(w io.Writer) {
954 if site == "" {
955 fmt.Fprintf(w, "website cleared on %s\n", repo.Path())
956 } else {
957 fmt.Fprintf(w, "website set on %s\n", repo.Path())
958 }
959 })
960}
961
962func runSetVisibility(c *Ctx, args []string) int {
963 if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
964 return c.usage()
965 }
966 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
967 if code >= 0 {
968 return code
969 }
970 return setRepoVisibility(c, repo, args[1])
971}
972
973// setRepoVisibility applies a visibility change the caller has already
974// been cleared to make.
975func setRepoVisibility(c *Ctx, repo store.Repo, visibility string) int {
976 if repo.Visibility == visibility {
977 return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
978 fmt.Fprintf(w, "%s is already %s\n", repo.Path(), visibility)
979 })
980 }
981 if err := c.Store.SetRepoVisibility(repo.ID, visibility); err != nil {
982 return c.fail(protocol.ExitFailure, "%v", err)
983 }
984 // Going private takes the repository off every anonymous surface, so
985 // git:// exposure cannot outlive the change.
986 if visibility == "private" && repo.Settings.GitDaemon {
987 c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = false })
988 }
989 c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": visibility})
990 return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
991 fmt.Fprintf(w, "%s is now %s\n", repo.Path(), visibility)
992 })
993}
994
995func runGitDaemon(c *Ctx, args []string) int {
996 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
997 return c.usage()
998 }
999 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1000 if code >= 0 {
1001 return code
1002 }
1003 on := args[1] == "on"
1004 if on && repo.Visibility != "public" {
1005 return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
1006 }
1007 if on && !c.Cfg.GitDaemon.Enabled {
1008 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
1009 }
1010 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = on })
1011 if err != nil {
1012 return c.fail(protocol.ExitFailure, "%v", err)
1013 }
1014 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
1015}
1016
1017func runArchive(c *Ctx, args []string) int { return setArchived(c, args, true) }
1018func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
1019
1020func setArchived(c *Ctx, args []string, archived bool) int {
1021 if len(args) != 1 {
1022 return c.usage()
1023 }
1024 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1025 if code >= 0 {
1026 return code
1027 }
1028 return archiveRepo(c, repo, archived)
1029}
1030
1031// archiveRepo flips the archived flag on a repository the caller has
1032// already been cleared to manage.
1033func archiveRepo(c *Ctx, repo store.Repo, archived bool) int {
1034 verb := "archive"
1035 if !archived {
1036 verb = "unarchive"
1037 }
1038 if repo.Settings.Archived == archived {
1039 return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
1040 }
1041 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Archived = archived })
1042 if err != nil {
1043 return c.fail(protocol.ExitFailure, "%v", err)
1044 }
1045 c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
1046 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
1047}
1048
1049// topicsScreen is a repository's topics at a terminal, after a read or
1050// an edit.
1051func topicsScreen(repo store.Repo, topics []string) screen {
1052 rows := make([]row, len(topics))
1053 for i, t := range topics {
1054 rows[i] = rowOf(cRef(t))
1055 }
1056 return listScreen("Topics", rows,
1057 action{"Edit", []string{"repo", "topics", "add", repo.Path(), "<topic>"}},
1058 action{"Edit", []string{"repo", "topics", "remove", repo.Path(), "<topic>"}},
1059 )
1060}
1061
1062func runTopicsList(c *Ctx, args []string) int {
1063 if len(args) != 1 {
1064 return c.usage()
1065 }
1066 repo, code := resolveRepo(c, args[0], policy.CanRead)
1067 if code >= 0 {
1068 return code
1069 }
1070 topics, err := c.Store.ListTopics(repo.ID)
1071 if err != nil {
1072 return c.fail(protocol.ExitFailure, "%v", err)
1073 }
1074 return c.emitView(topics, func(w io.Writer) {
1075 tb := c.table(w, "TOPIC")
1076 for _, t := range topics {
1077 tb.row(cRef(t))
1078 }
1079 tb.flush()
1080 }, func() screen {
1081 return topicsScreen(repo, topics)
1082 })
1083}
1084
1085func runTopicsAdd(c *Ctx, args []string) int { return editTopics(c, args, true) }
1086func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) }
1087
1088func editTopics(c *Ctx, args []string, add bool) int {
1089 if len(args) < 2 {
1090 return c.usage()
1091 }
1092 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1093 if code >= 0 {
1094 return code
1095 }
1096 topics := args[1:]
1097 if add {
1098 for _, t := range topics {
1099 if err := policy.ValidateTopic(t); err != nil {
1100 return c.failInput(err)
1101 }
1102 }
1103 have, err := c.Store.ListTopics(repo.ID)
1104 if err != nil {
1105 return c.fail(protocol.ExitFailure, "%v", err)
1106 }
1107 added := 0
1108 for _, t := range topics {
1109 if !slices.Contains(have, t) {
1110 added++
1111 }
1112 }
1113 if len(have)+added > policy.MaxTopics {
1114 return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics)
1115 }
1116 for _, t := range topics {
1117 if err := c.Store.AddTopic(repo.ID, t); err != nil {
1118 return c.fail(protocol.ExitFailure, "%v", err)
1119 }
1120 }
1121 } else {
1122 for _, t := range topics {
1123 if err := c.Store.RemoveTopic(repo.ID, t); err != nil {
1124 if errors.Is(err, store.ErrNotFound) {
1125 return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t)
1126 }
1127 return c.fail(protocol.ExitFailure, "%v", err)
1128 }
1129 }
1130 }
1131 now, err := c.Store.ListTopics(repo.ID)
1132 if err != nil {
1133 return c.fail(protocol.ExitFailure, "%v", err)
1134 }
1135 return c.emitView(now, func(w io.Writer) {
1136 tb := c.table(w, "TOPIC")
1137 for _, t := range now {
1138 tb.row(cRef(t))
1139 }
1140 tb.flush()
1141 }, func() screen {
1142 return topicsScreen(repo, now)
1143 })
1144}
1145
1146// runRepoSearch matches the query against name, owner/name, description,
1147// and topics of every repository the caller can see.
1148func runRepoSearch(c *Ctx, args []string) int {
1149 if len(args) != 1 {
1150 return c.usage()
1151 }
1152 if err := validQuery(args[0]); err != nil {
1153 return c.failInput(err)
1154 }
1155 q := strings.ToLower(args[0])
1156
1157 public, err := c.Store.ListPublicRepos()
1158 if err != nil {
1159 return c.fail(protocol.ExitFailure, "%v", err)
1160 }
1161 own, err := c.Store.ListReposForUser(c.User.ID, 0, "")
1162 if err != nil {
1163 return c.fail(protocol.ExitFailure, "%v", err)
1164 }
1165 seen := map[int64]bool{}
1166 type out struct {
1167 Path string `json:"path"`
1168 Visibility string `json:"visibility"`
1169 Description string `json:"description,omitempty"`
1170 Topics []string `json:"topics,omitempty"`
1171 }
1172 var ds []out
1173 for _, r := range append(public, own...) {
1174 if seen[r.ID] {
1175 continue
1176 }
1177 seen[r.ID] = true
1178 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
1179 topics, _ := c.Store.ListTopics(r.ID)
1180 if !MatchesRepo(q, r.Path(), desc, topics) {
1181 continue
1182 }
1183 ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
1184 }
1185 return c.emitView(ds, func(w io.Writer) {
1186 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
1187 for _, d := range ds {
1188 tb.row(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description))
1189 }
1190 tb.flush()
1191 }, func() screen {
1192 rows := make([]row, len(ds))
1193 for i, d := range ds {
1194 rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description), cMeta(strings.Join(d.Topics, ", ")))
1195 }
1196 s := listScreen(fmt.Sprintf("Repositories matching %q", args[0]), rows)
1197 if len(ds) > 0 {
1198 s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}}
1199 }
1200 return s
1201 })
1202}
1203
1204// MatchesRepo is the one rule for matching a repository against a text
1205// query: its path, its description, or any of its topics. The web's
1206// /explore filter and /search page call it too, so the three surfaces
1207// cannot answer the same query differently.
1208func MatchesRepo(q, path, desc string, topics []string) bool {
1209 q = strings.ToLower(q)
1210 if strings.Contains(strings.ToLower(path), q) ||
1211 strings.Contains(strings.ToLower(desc), q) {
1212 return true
1213 }
1214 for _, t := range topics {
1215 if strings.Contains(strings.ToLower(t), q) {
1216 return true
1217 }
1218 }
1219 return false
1220}
1221
1222func runRepoGrep(c *Ctx, args []string) int {
1223 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep <owner/name> <query> [--ref <ref>]"})
1224 if err != nil {
1225 return c.fail(protocol.ExitUsage, "%v", err)
1226 }
1227 path, query, ref := f.pos(0), f.pos(1), f.Value("--ref")
1228 if path == "" || query == "" {
1229 return c.usage()
1230 }
1231 if err := validQuery(query); err != nil {
1232 return c.failInput(err)
1233 }
1234 repo, code := resolveRepo(c, path, policy.CanRead)
1235 if code >= 0 {
1236 return code
1237 }
1238 if ref == "" {
1239 ref = repo.DefaultBranch
1240 }
1241 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1242 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
1243 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
1244 }
1245 matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches)
1246 if err != nil {
1247 return c.fail(protocol.ExitFailure, "%v", err)
1248 }
1249 type out struct {
1250 Path string `json:"path"`
1251 Line int `json:"line"`
1252 Text string `json:"text"`
1253 }
1254 var ds []out
1255 for _, m := range matches {
1256 ds = append(ds, out{m.Path, m.Line, m.Text})
1257 }
1258 return c.emit(ds, func(w io.Writer) {
1259 for _, d := range ds {
1260 fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text)
1261 }
1262 })
1263}
1264
1265func runRepoPin(c *Ctx, args []string) int { return setPinned(c, args, true) }
1266func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) }
1267
1268func setPinned(c *Ctx, args []string, pin bool) int {
1269 verb := "pin"
1270 if !pin {
1271 verb = "unpin"
1272 }
1273 if len(args) != 1 {
1274 return c.usage()
1275 }
1276 repo, code := resolveRepo(c, args[0], policy.CanRead)
1277 if code >= 0 {
1278 return code
1279 }
1280 if pin {
1281 if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil {
1282 return c.fail(protocol.ExitFailure, "%v", err)
1283 }
1284 } else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil {
1285 if errors.Is(err, store.ErrNotFound) {
1286 return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path())
1287 }
1288 return c.fail(protocol.ExitFailure, "%v", err)
1289 }
1290 return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) {
1291 fmt.Fprintf(w, "%sned %s\n", verb, repo.Path())
1292 })
1293}
1294
1295func runRepoBookmark(c *Ctx, args []string) int { return setBookmarked(c, args, true) }
1296func runRepoUnbookmark(c *Ctx, args []string) int { return setBookmarked(c, args, false) }
1297
1298// setBookmarked mirrors setPinned. A bookmark needs only read access —
1299// bookmarking is something you do to someone else's repository, which is
1300// the whole point of it — and a private repository you cannot read is
1301// not found, as everywhere.
1302func setBookmarked(c *Ctx, args []string, on bool) int {
1303 verb := "bookmark"
1304 if !on {
1305 verb = "unbookmark"
1306 }
1307 if len(args) != 1 {
1308 return c.usage()
1309 }
1310 repo, code := resolveRepo(c, args[0], policy.CanRead)
1311 if code >= 0 {
1312 return code
1313 }
1314 if on {
1315 if err := c.Store.BookmarkRepo(c.User.ID, repo.ID); err != nil {
1316 return c.fail(protocol.ExitFailure, "%v", err)
1317 }
1318 } else if err := c.Store.UnbookmarkRepo(c.User.ID, repo.ID); err != nil {
1319 if errors.Is(err, store.ErrNotFound) {
1320 return c.fail(protocol.ExitNotFound, "%s is not bookmarked", repo.Path())
1321 }
1322 return c.fail(protocol.ExitFailure, "%v", err)
1323 }
1324 return c.emit(map[string]string{verb + "ed": repo.Path()}, func(w io.Writer) {
1325 fmt.Fprintf(w, "%sed %s\n", verb, repo.Path())
1326 })
1327}
1328
1329// BookmarkOut is one row of `repo bookmarks`: the repository and how many
1330// people have bookmarked it.
1331type BookmarkOut struct {
1332 Path string `json:"path"`
1333 Description string `json:"description,omitempty"`
1334 Visibility string `json:"visibility"`
1335 Bookmarks int `json:"bookmarks"`
1336}
1337
1338func runRepoBookmarks(c *Ctx, args []string) int {
1339 if len(args) != 0 {
1340 return c.usage()
1341 }
1342 repos, err := c.Store.ListBookmarks(c.User.ID)
1343 if err != nil {
1344 return c.fail(protocol.ExitFailure, "%v", err)
1345 }
1346 out := []BookmarkOut{}
1347 for _, r := range repos {
1348 // A repository bookmarked while public and since made private
1349 // stays in the table and drops out of the listing, the same way
1350 // it disappears from every other surface.
1351 grant, err := c.Store.AccessRole(r.ID, c.User.ID)
1352 if err != nil {
1353 return c.fail(protocol.ExitFailure, "%v", err)
1354 }
1355 if !policy.CanRead(c.User, r, grant) {
1356 continue
1357 }
1358 out = append(out, BookmarkOut{
1359 Path: r.Path(),
1360 Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)),
1361 Visibility: r.Visibility,
1362 Bookmarks: c.Store.BookmarkCount(r.ID),
1363 })
1364 }
1365 return c.emitView(out, func(w io.Writer) {
1366 tb := c.table(w, "PATH", "COUNT", "DESCRIPTION")
1367 for _, b := range out {
1368 tb.row(cRef(b.Path), cNum(int64(b.Bookmarks)), cFlex(b.Description))
1369 }
1370 tb.flush()
1371 }, func() screen {
1372 rows := make([]row, len(out))
1373 for i, b := range out {
1374 n := fmt.Sprintf("%d bookmarks", b.Bookmarks)
1375 if b.Bookmarks == 1 {
1376 n = "1 bookmark"
1377 }
1378 rows[i] = rowOf(cLink(b.Path, c.siteURL(b.Path)), cState(b.Visibility), cFlex(b.Description), cMeta(n))
1379 }
1380 s := listScreen("Bookmarks", rows)
1381 if len(out) > 0 {
1382 s.actions = []action{{"Read", []string{"repo", "show", out[0].Path}}}
1383 }
1384 return s
1385 })
1386}
1387
1388func runProtectTag(c *Ctx, args []string) int { return setProtectTag(c, args, true) }
1389func runUnprotectTag(c *Ctx, args []string) int { return setProtectTag(c, args, false) }
1390
1391func setProtectTag(c *Ctx, args []string, protect bool) int {
1392 if len(args) != 2 {
1393 return c.usage()
1394 }
1395 glob := args[1]
1396 if _, err := path.Match(glob, "x"); err != nil || glob == "" {
1397 return c.fail(protocol.ExitUsage, "bad glob %q", glob)
1398 }
1399 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1400 if code >= 0 {
1401 return code
1402 }
1403 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1404 has := slices.Contains(s.ProtectedTags, glob)
1405 if protect && !has {
1406 s.ProtectedTags = append(s.ProtectedTags, glob)
1407 slices.Sort(s.ProtectedTags)
1408 }
1409 if !protect && has {
1410 s.ProtectedTags = slices.DeleteFunc(s.ProtectedTags, func(g string) bool { return g == glob })
1411 }
1412 })
1413 if err != nil {
1414 return c.fail(protocol.ExitFailure, "%v", err)
1415 }
1416 verb := "protected"
1417 if !protect {
1418 verb = "unprotected"
1419 }
1420 return c.emit(s, func(w io.Writer) {
1421 fmt.Fprintf(w, "tags %s %s on %s\n", glob, verb, repo.Path())
1422 })
1423}
1424
1425func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
1426func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
1427
1428func setProtect(c *Ctx, args []string, protect bool) int {
1429 if len(args) != 2 {
1430 return c.usage()
1431 }
1432 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1433 if code >= 0 {
1434 return code
1435 }
1436 branch := args[1]
1437 // The list is read and rewritten inside the update, so two admins
1438 // protecting different branches at once both land.
1439 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1440 has := slices.Contains(s.ProtectedBranches, branch)
1441 if protect && !has {
1442 s.ProtectedBranches = append(s.ProtectedBranches, branch)
1443 slices.Sort(s.ProtectedBranches)
1444 }
1445 if !protect && has {
1446 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
1447 }
1448 })
1449 if err != nil {
1450 return c.fail(protocol.ExitFailure, "%v", err)
1451 }
1452 verb := "protected"
1453 if !protect {
1454 verb = "unprotected"
1455 }
1456 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
1457}
1458
1459// runRepoDiff is the compare view's command: what head adds on top of
1460// base, measured from their merge base the way a merge request diff is,
1461// so a base that moved on does not show up as removals (#118).
1462func runRepoDiff(c *Ctx, args []string) int {
1463 f, err := c.parseArgs(args, flagSpec{MaxPos: 3, Usage: "repo diff <owner/name> <base> <head>"})
1464 if err != nil || len(f.Pos) != 3 {
1465 return c.usage()
1466 }
1467 repo, code := resolveRepo(c, f.pos(0), policy.CanRead)
1468 if code >= 0 {
1469 return code
1470 }
1471 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1472 base, err := gitutil.ResolveRef(dir, f.pos(1))
1473 if err != nil {
1474 return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(1), repo.Path())
1475 }
1476 head, err := gitutil.ResolveRef(dir, f.pos(2))
1477 if err != nil {
1478 return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(2), repo.Path())
1479 }
1480 mergeBase, err := gitutil.MergeBase(dir, base, head)
1481 if err != nil {
1482 return c.fail(protocol.ExitUsage, "%v", err)
1483 }
1484 patch, truncated, err := gitutil.Diff(dir, mergeBase, head, 4<<20)
1485 if err != nil {
1486 return c.fail(protocol.ExitFailure, "%v", err)
1487 }
1488 if c.JSON {
1489 return c.emit(map[string]any{"base": base, "head": head, "merge_base": mergeBase, "patch": patch, "truncated": truncated}, nil)
1490 }
1491 fmt.Fprint(c.Stdout, c.Term.diff(patch))
1492 if truncated {
1493 fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB")
1494 }
1495 return protocol.ExitOK
1496}
1497
1498// repoShowScreen is repo show at a terminal: how to clone it, where the
1499// default branch stands, what is open, and the latest commits.
1500func repoShowScreen(c *Ctx, d repoShowOut, g repoGlance, mrs []store.MR, issues []store.Issue, commits []CommitOut) screen {
1501 s := screen{body: d.Description, format: "text"}
1502 name := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility)}
1503 if d.Archived {
1504 name[1].s += ", archived"
1505 }
1506 s.fields = append(s.fields, field{"Repo", name})
1507 if g.clone != "" {
1508 s.fields = append(s.fields, field{"Clone", []cell{cText(g.clone)}})
1509 }
1510 head := []cell{cText(d.DefaultBranch)}
1511 if g.checks != "" {
1512 head = []cell{cText(d.DefaultBranch), cText(strings.TrimSuffix(g.checks, " on "+d.DefaultBranch))}
1513 }
1514 s.fields = append(s.fields, field{"Head", head})
1515 if g.release != "" {
1516 s.fields = append(s.fields, field{"Release", []cell{cText(g.release)}})
1517 }
1518 if len(d.Topics) > 0 {
1519 s.fields = append(s.fields, field{"Topics", []cell{cText(strings.Join(d.Topics, ", "))}})
1520 }
1521 if d.ForkOf != "" {
1522 s.fields = append(s.fields, field{"Fork of", []cell{cRef(d.ForkOf)}})
1523 }
1524 for _, m := range d.Mirrors {
1525 if m.LastError != "" {
1526 s.fields = append(s.fields, field{"Mirror", []cell{cGlyph("failed"), cText(m.Direction + " " + m.URL + ": " + m.LastError)}})
1527 }
1528 }
1529 if !c.Term.Links {
1530 s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(d.Path))}})
1531 }
1532
1533 ms := section{title: "Open merge requests", n: g.mrsN, more: []string{"mr", "list", d.Path}}
1534 for _, m := range mrs {
1535 ms.rows = append(ms.rows, rowOf(cRef(fmt.Sprintf("!%d", m.Number)), cFlex(m.Title), cAge(m.UpdatedAt)))
1536 }
1537 is := section{title: "Open issues", n: g.issuesN, more: []string{"issue", "list", d.Path}}
1538 for _, i := range issues {
1539 is.rows = append(is.rows, rowOf(cRef(fmt.Sprintf("#%d", i.Number)), cFlex(i.Title), cAge(i.UpdatedAt)))
1540 }
1541 cs := section{title: "Recent commits", n: len(commits), more: []string{"repo", "log", d.Path}}
1542 for _, cm := range commits {
1543 cs.rows = append(cs.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject)))
1544 }
1545 s.sections = []section{ms, is, cs}
1546 s.actions = []action{
1547 {"Contribute", []string{"mr", "create", d.Path}},
1548 {"Contribute", []string{"issue", "create", d.Path}},
1549 {"Read", []string{"repo", "log", d.Path}},
1550 }
1551 return s
1552}