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