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