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