internal/control/admin.go
620 lines · 21029 bytes
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "slices"
8 "strconv"
9 "strings"
10 "time"
11
12 "gitbay.org/gitbay/internal/gitutil"
13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/store"
15)
16
17func init() {
18 register(Command{Path: []string{"admin", "user", "list"},
19 Summary: "list accounts (instance admins)",
20 Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]",
21 ReadOnly: true, Run: runAdminUserList})
22 register(Command{Path: []string{"admin", "user", "show"},
23 Summary: "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
24 Usage: "admin user show <username>",
25 ReadOnly: true, Run: runAdminUserShow})
26 register(Command{Path: []string{"admin", "user", "promote"},
27 Summary: "make an account an instance admin",
28 Usage: "admin user promote <username>",
29 Run: runAdminUserPromote})
30 register(Command{Path: []string{"admin", "user", "demote"},
31 Summary: "remove instance admin from an account (never the last one)",
32 Usage: "admin user demote <username>",
33 Run: runAdminUserDemote})
34 register(Command{Path: []string{"admin", "runners"},
35 Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)",
36 Usage: "admin runners",
37 ReadOnly: true, Run: runAdminRunners})
38 register(Command{Path: []string{"admin", "runners", "remove"},
39 Summary: "drop a key's runner heartbeat row, e.g. one that polled once by mistake (instance admins)",
40 Usage: "admin runners remove <fingerprint>",
41 Run: runAdminRunnersForget})
42 // forget is the name this shipped under in v1.18; remove is the verb
43 // every other noun uses. Both stay for one release.
44 register(Command{Path: []string{"admin", "runners", "forget"},
45 Summary: "alias of admin runners remove",
46 Usage: "admin runners forget <fingerprint>",
47 Run: runAdminRunnersForget})
48 register(Command{Path: []string{"admin", "repo", "list"},
49 Summary: "list every repository with size and last push (instance admins)",
50 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]",
51 ReadOnly: true, Run: runAdminRepoList})
52 register(Command{Path: []string{"admin", "repo", "archive"},
53 Summary: "archive any repository (instance admins; audited)",
54 Usage: "admin repo archive <owner/name>",
55 Run: runAdminRepoArchive})
56 register(Command{Path: []string{"admin", "repo", "unarchive"},
57 Summary: "unarchive any repository (instance admins; audited)",
58 Usage: "admin repo unarchive <owner/name>",
59 Run: runAdminRepoUnarchive})
60 register(Command{Path: []string{"admin", "repo", "visibility"},
61 Summary: "set any repository's visibility (instance admins; audited)",
62 Usage: "admin repo visibility <owner/name> public|private",
63 Run: runAdminRepoVisibility})
64 register(Command{Path: []string{"admin", "repo", "delete"},
65 Summary: "delete any repository (instance admins; audited)",
66 Usage: "admin repo delete <owner/name> --yes",
67 Run: runAdminRepoDelete})
68 register(Command{Path: []string{"admin", "mr", "prune"},
69 Summary: "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)",
70 Usage: "admin mr prune <owner/name> <n> [<n>...] --yes",
71 Run: runAdminMRPrune})
72}
73
74// requireInstanceAdmin gates the admin noun. -1 means proceed.
75func requireInstanceAdmin(c *Ctx) int {
76 if !c.User.IsAdmin {
77 return c.fail(protocol.ExitDenied, "admin commands are for instance admins; ask one")
78 }
79 return -1
80}
81
82// adminUserOut is one account row, shared by list and show.
83type adminUserOut struct {
84 Username string `json:"username"`
85 State string `json:"state"` // active | pending | disabled
86 Admin bool `json:"admin"`
87 CreatedAt string `json:"created_at"`
88 LastSeen string `json:"last_seen,omitempty"`
89}
90
91func adminUserRow(u store.AdminUser) adminUserOut {
92 state := "active"
93 switch {
94 case u.Disabled:
95 state = "disabled"
96 case u.Pending:
97 state = "pending"
98 }
99 return adminUserOut{u.Username, state, u.IsAdmin, u.CreatedAt, u.LastSeen}
100}
101
102func runAdminUserList(c *Ctx, args []string) int {
103 if code := requireInstanceAdmin(c); code >= 0 {
104 return code
105 }
106 args, p, code := parsePageFlags(c, args, "admin-user", false)
107 if code >= 0 {
108 return code
109 }
110 f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 0,
111 Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]"})
112 if err != nil {
113 return c.fail(protocol.ExitUsage, "%v", err)
114 }
115 state := f.Value("--state")
116 switch state {
117 case "", "active", "pending", "disabled", "admin":
118 default:
119 return c.fail(protocol.ExitUsage, "--state requires active|pending|disabled|admin")
120 }
121 users, err := c.Store.ListUsers(state, p.queryLimit(), p.key)
122 if err != nil {
123 return c.fail(protocol.ExitFailure, "%v", err)
124 }
125 users, next := trimPage(p, users, "admin-user", func(u store.AdminUser) string { return u.Username })
126 var ds []adminUserOut
127 for _, u := range users {
128 ds = append(ds, adminUserRow(u))
129 }
130 return c.emitPage(p, ds, next, func(w io.Writer) {
131 for _, d := range ds {
132 mark := ""
133 if d.Admin {
134 mark = "admin"
135 }
136 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.Username, d.State, mark, d.CreatedAt, d.LastSeen)
137 }
138 })
139}
140
141func runAdminUserShow(c *Ctx, args []string) int {
142 if code := requireInstanceAdmin(c); code >= 0 {
143 return code
144 }
145 if len(args) != 1 {
146 return c.usage()
147 }
148 name := args[0]
149 u, err := c.Store.UserByUsername(name)
150 if errors.Is(err, store.ErrNotFound) {
151 return c.fail(protocol.ExitNotFound, "no user %q", name)
152 } else if err != nil {
153 return c.fail(protocol.ExitFailure, "%v", err)
154 }
155 row, err := c.Store.AdminUserByName(name)
156 if err != nil {
157 return c.fail(protocol.ExitFailure, "%v", err)
158 }
159
160 type keyOut struct {
161 Fingerprint string `json:"fingerprint"`
162 Algo string `json:"algo"`
163 Scope string `json:"scope"`
164 Label string `json:"label"`
165 CreatedAt string `json:"created_at"`
166 LastUsedAt string `json:"last_used_at,omitempty"`
167 }
168 type emailOut struct {
169 Address string `json:"address"`
170 Verified bool `json:"verified"`
171 VerifiedBy string `json:"verified_by,omitempty"` // smtp | admin
172 Primary bool `json:"primary"`
173 }
174 type pgpOut struct {
175 Fingerprint string `json:"fingerprint"`
176 ExpiresAt *time.Time `json:"expires_at,omitempty"`
177 RevokedAt *time.Time `json:"revoked_at,omitempty"`
178 }
179 type orgOut struct {
180 Org string `json:"org"`
181 Role string `json:"role"`
182 }
183 type tokenOut struct {
184 Name string `json:"name"`
185 Scope string `json:"scope"`
186 CreatedAt string `json:"created_at"`
187 ExpiresAt *time.Time `json:"expires_at,omitempty"`
188 LastUsedAt *time.Time `json:"last_used_at,omitempty"`
189 }
190 type out struct {
191 adminUserOut
192 Keys []keyOut `json:"keys"`
193 Emails []emailOut `json:"emails"`
194 PGPKeys []pgpOut `json:"pgp_keys"`
195 Orgs []orgOut `json:"orgs"`
196 Repos int64 `json:"repos"`
197 RepoLimit int64 `json:"repo_limit"` // 0 unlimited
198 ByteLimit int64 `json:"byte_limit"` // 0 unlimited
199 APITokens []tokenOut `json:"api_tokens"`
200 WebSessions int64 `json:"web_sessions"`
201 }
202 d := out{adminUserOut: adminUserRow(row),
203 Keys: []keyOut{}, Emails: []emailOut{}, PGPKeys: []pgpOut{}, Orgs: []orgOut{}, APITokens: []tokenOut{}}
204
205 keys, err := c.Store.ListSSHKeys(u.ID)
206 if err != nil {
207 return c.fail(protocol.ExitFailure, "%v", err)
208 }
209 for _, k := range keys {
210 d.Keys = append(d.Keys, keyOut{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedAt, k.LastUsedAt})
211 }
212 emails, err := c.Store.ListEmails(u.ID)
213 if err != nil {
214 return c.fail(protocol.ExitFailure, "%v", err)
215 }
216 for _, e := range emails {
217 d.Emails = append(d.Emails, emailOut{e.Address, e.Verified, e.VerifiedBy, e.Primary})
218 }
219 pgp, err := c.Store.ListPGPKeys(u.ID)
220 if err != nil {
221 return c.fail(protocol.ExitFailure, "%v", err)
222 }
223 for _, k := range pgp {
224 d.PGPKeys = append(d.PGPKeys, pgpOut{k.Fingerprint, k.ExpiresAt, k.RevokedAt})
225 }
226 orgs, err := c.Store.ListOrgsForUser(u.ID)
227 if err != nil {
228 return c.fail(protocol.ExitFailure, "%v", err)
229 }
230 for _, m := range orgs {
231 d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role})
232 }
233 if d.Repos, err = c.Store.OwnedRepoCount(u.ID); err != nil {
234 return c.fail(protocol.ExitFailure, "%v", err)
235 }
236 d.RepoLimit = RepoLimit(c.Store, limitsOf(c), u.ID)
237 d.ByteLimit = ByteLimit(c.Store, limitsOf(c), u.ID)
238 tokens, err := c.Store.ListAPITokens(u.ID)
239 if err != nil {
240 return c.fail(protocol.ExitFailure, "%v", err)
241 }
242 for _, t := range tokens {
243 d.APITokens = append(d.APITokens, tokenOut{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
244 }
245 if d.WebSessions, err = c.Store.WebSessionCount(u.ID); err != nil {
246 return c.fail(protocol.ExitFailure, "%v", err)
247 }
248
249 return c.emit(d, func(w io.Writer) {
250 fmt.Fprintf(w, "%s\t%s", d.Username, d.State)
251 if d.Admin {
252 fmt.Fprint(w, "\tadmin")
253 }
254 fmt.Fprintf(w, "\ncreated\t%s\n", d.CreatedAt)
255 if d.LastSeen != "" {
256 fmt.Fprintf(w, "last seen\t%s\n", d.LastSeen)
257 }
258 fmt.Fprintf(w, "repos\t%d\nweb sessions\t%d\n", d.Repos, d.WebSessions)
259 fmt.Fprintln(w, "keys:")
260 for _, k := range d.Keys {
261 fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", k.Fingerprint, k.Algo, k.Scope, k.LastUsedAt)
262 }
263 fmt.Fprintln(w, "emails:")
264 for _, e := range d.Emails {
265 state := "unverified"
266 if e.Verified {
267 state = "verified by " + e.VerifiedBy
268 }
269 mark := ""
270 if e.Primary {
271 mark = "\tprimary"
272 }
273 fmt.Fprintf(w, " %s\t%s%s\n", e.Address, state, mark)
274 }
275 fmt.Fprintln(w, "pgp keys:")
276 for _, k := range d.PGPKeys {
277 fmt.Fprintf(w, " %s\n", k.Fingerprint)
278 }
279 fmt.Fprintln(w, "orgs:")
280 for _, o := range d.Orgs {
281 fmt.Fprintf(w, " %s\t%s\n", o.Org, o.Role)
282 }
283 fmt.Fprintln(w, "api tokens:")
284 for _, t := range d.APITokens {
285 used := ""
286 if t.LastUsedAt != nil {
287 used = t.LastUsedAt.UTC().Format(time.RFC3339)
288 }
289 fmt.Fprintf(w, " %s\t%s\t%s\n", t.Name, t.Scope, strings.TrimSpace(used))
290 }
291 })
292}
293
294func runAdminUserPromote(c *Ctx, args []string) int { return setAdmin(c, args, true) }
295func runAdminUserDemote(c *Ctx, args []string) int { return setAdmin(c, args, false) }
296
297func setAdmin(c *Ctx, args []string, admin bool) int {
298 if code := requireInstanceAdmin(c); code >= 0 {
299 return code
300 }
301 verb := "demote"
302 if admin {
303 verb = "promote"
304 }
305 if len(args) != 1 {
306 return c.usage()
307 }
308 u, err := c.Store.UserByUsername(args[0])
309 if errors.Is(err, store.ErrNotFound) {
310 return c.fail(protocol.ExitNotFound, "no user %q", args[0])
311 } else if err != nil {
312 return c.fail(protocol.ExitFailure, "%v", err)
313 }
314 if u.IsAdmin == admin {
315 return c.fail(protocol.ExitUsage, "%s is already %s", u.Username, map[bool]string{true: "an admin", false: "not an admin"}[admin])
316 }
317 if admin && (u.Pending || u.Disabled) {
318 return c.fail(protocol.ExitUsage, "%s is %s; only an active account can be an admin", u.Username,
319 map[bool]string{true: "disabled", false: "pending"}[u.Disabled])
320 }
321 if err := c.Store.SetUserAdmin(u.ID, admin); err != nil {
322 if errors.Is(err, store.ErrLastAdmin) {
323 return c.failErr(err)
324 }
325 return c.fail(protocol.ExitFailure, "%v", err)
326 }
327 c.Store.Audit(c.User.ID, "admin user."+verb+"d", map[string]any{"user": u.Username})
328 return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) {
329 fmt.Fprintf(w, "%sd %s\n", verb, u.Username)
330 })
331}
332
333// adminRepo loads a repository for an admin override. Instance admin
334// carries no implicit read right, so policy is not consulted; the only
335// refusal is a path that does not exist. Every caller audits what it does.
336func adminRepo(c *Ctx, path string) (store.Repo, int) {
337 if code := requireInstanceAdmin(c); code >= 0 {
338 return store.Repo{}, code
339 }
340 repo, err := c.Store.RepoByPath(path)
341 if errors.Is(err, store.ErrNotFound) {
342 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
343 } else if err != nil {
344 return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
345 }
346 return repo, -1
347}
348
349func runAdminRepoList(c *Ctx, args []string) int {
350 if code := requireInstanceAdmin(c); code >= 0 {
351 return code
352 }
353 args, p, code := parsePageFlags(c, args, "admin-repo", false)
354 if code >= 0 {
355 return code
356 }
357 f, err := parseFlags(args, flagSpec{Values: []string{"--owner", "--visibility"}, MaxPos: 0,
358 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]"})
359 if err != nil {
360 return c.fail(protocol.ExitUsage, "%v", err)
361 }
362 owner, visibility := f.Value("--owner"), f.Value("--visibility")
363 if visibility != "" && visibility != "public" && visibility != "private" {
364 return c.fail(protocol.ExitUsage, "--visibility requires public|private")
365 }
366 repos, err := c.Store.ListReposAdmin(owner, visibility, p.queryLimit(), p.key)
367 if err != nil {
368 return c.fail(protocol.ExitFailure, "%v", err)
369 }
370 repos, next := trimPage(p, repos, "admin-repo", func(r store.AdminRepo) string { return r.Path })
371 type out struct {
372 Path string `json:"path"`
373 Visibility string `json:"visibility"`
374 Archived bool `json:"archived,omitempty"`
375 CreatedAt string `json:"created_at"`
376 LastPush string `json:"last_push,omitempty"`
377 Bytes int64 `json:"bytes"`
378 }
379 var ds []out
380 for _, r := range repos {
381 size := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
382 ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size})
383 }
384 return c.emitPage(p, ds, next, func(w io.Writer) {
385 for _, d := range ds {
386 mark := ""
387 if d.Archived {
388 mark = "\t[archived]"
389 }
390 fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s%s\n", d.Path, d.Visibility, d.Bytes, d.CreatedAt, d.LastPush, mark)
391 }
392 })
393}
394
395func runAdminRepoArchive(c *Ctx, args []string) int { return adminArchive(c, args, true) }
396func runAdminRepoUnarchive(c *Ctx, args []string) int { return adminArchive(c, args, false) }
397
398func adminArchive(c *Ctx, args []string, archived bool) int {
399 verb := "archive"
400 if !archived {
401 verb = "unarchive"
402 }
403 if len(args) != 1 {
404 return c.usage()
405 }
406 repo, code := adminRepo(c, args[0])
407 if code >= 0 {
408 return code
409 }
410 if code := archiveRepo(c, repo, archived); code != protocol.ExitOK {
411 return code
412 }
413 c.Store.Audit(c.User.ID, "admin repo."+verb, map[string]any{"repo": repo.Path()})
414 return protocol.ExitOK
415}
416
417func runAdminRepoVisibility(c *Ctx, args []string) int {
418 if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
419 return c.usage()
420 }
421 repo, code := adminRepo(c, args[0])
422 if code >= 0 {
423 return code
424 }
425 if code := setRepoVisibility(c, repo, args[1]); code != protocol.ExitOK {
426 return code
427 }
428 c.Store.Audit(c.User.ID, "admin repo.visibility", map[string]any{"repo": repo.Path(), "visibility": args[1]})
429 return protocol.ExitOK
430}
431
432func runAdminRepoDelete(c *Ctx, args []string) int {
433 var path string
434 var yes bool
435 for _, a := range args {
436 if a == "--yes" {
437 yes = true
438 } else if path == "" {
439 path = a
440 } else {
441 return c.usage()
442 }
443 }
444 if path == "" {
445 return c.usage()
446 }
447 repo, code := adminRepo(c, path)
448 if code >= 0 {
449 return code
450 }
451 if !yes {
452 return c.fail(protocol.ExitUsage, "admin repo delete is permanent; re-run with --yes")
453 }
454 if code := deleteRepo(c, repo); code != protocol.ExitOK {
455 return code
456 }
457 c.Store.Audit(c.User.ID, "admin repo.delete", map[string]any{"repo": repo.Path()})
458 return protocol.ExitOK
459}
460
461func runAdminRunnersForget(c *Ctx, args []string) int {
462 if code := requireInstanceAdmin(c); code >= 0 {
463 return code
464 }
465 if len(args) != 1 {
466 return c.usage()
467 }
468 if err := c.Store.ForgetRunner(args[0]); err != nil {
469 if errors.Is(err, store.ErrNotFound) {
470 return c.fail(protocol.ExitNotFound, "no runner has polled with %s", args[0])
471 }
472 return c.fail(protocol.ExitFailure, "%v", err)
473 }
474 c.Store.Audit(c.User.ID, "admin runners.forget", map[string]any{"fingerprint": args[0]})
475 return c.emit(map[string]string{"forgot": args[0]}, func(w io.Writer) {
476 fmt.Fprintf(w, "forgot runner %s\n", args[0])
477 })
478}
479
480func runAdminRunners(c *Ctx, args []string) int {
481 if code := requireInstanceAdmin(c); code >= 0 {
482 return code
483 }
484 if len(args) != 0 {
485 return c.usage()
486 }
487 runners, err := c.Store.ListRunners()
488 if err != nil {
489 return c.fail(protocol.ExitFailure, "%v", err)
490 }
491 queue, err := c.Store.QueueStats()
492 if err != nil {
493 return c.fail(protocol.ExitFailure, "%v", err)
494 }
495 if runners == nil {
496 runners = []store.Runner{}
497 }
498 // The scope column is what the key may claim, not what it asked for. A
499 // runner key is confined to its attachments, so they replace whatever
500 // -repos it polled with, and none of them means none. Any other key
501 // keeps the repositories it asked for, or the whole instance.
502 for i := range runners {
503 key, err := c.Store.SSHKeyByID(runners[i].KeyID)
504 if err != nil || key.Scope != "runner" {
505 continue
506 }
507 paths, err := c.Store.RunnerRepoPaths(runners[i].KeyID)
508 if err != nil {
509 return c.fail(protocol.ExitFailure, "%v", err)
510 }
511 runners[i].Scope = "none"
512 if len(paths) > 0 {
513 runners[i].Scope = strings.Join(paths, ",")
514 }
515 }
516 d := map[string]any{"queue": queue, "runners": runners}
517 return c.emit(d, func(w io.Writer) {
518 fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n",
519 queue.Pending, queue.Claimed24h, queue.ClaimWaitAvgS, queue.ClaimWaitMaxS, queue.Reaped24h)
520 for _, r := range runners {
521 scope := r.Scope
522 if scope == "" {
523 scope = "any"
524 }
525 held := "idle"
526 if r.BuildNumber != 0 {
527 held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
528 }
529 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Username, r.Fingerprint, r.LastSeen, scope, held)
530 }
531 })
532}
533
534type mrPruneOut struct {
535 Number int64 `json:"number"`
536 Head string `json:"head_sha"` // what the ref pointed at; empty if it was already gone
537}
538
539// runAdminMRPrune deletes refs/merge-requests/<n>/head for the named MRs
540// and prunes the repository at once, so commits a history rewrite left
541// reachable only through them stop being fetchable. Nothing drops a head
542// ref on its own: an open or source-gone MR is merged through it, and a
543// merged or closed one keeps its diff readable through it. Every check
544// runs before the first write.
545func runAdminMRPrune(c *Ctx, args []string) int {
546 var path string
547 var yes bool
548 var numbers []int64
549 for _, a := range args {
550 switch {
551 case a == "--yes":
552 yes = true
553 case path == "":
554 path = a
555 default:
556 n, err := strconv.ParseInt(a, 10, 64)
557 if err != nil || n <= 0 {
558 return c.usage()
559 }
560 if !slices.Contains(numbers, n) {
561 numbers = append(numbers, n)
562 }
563 }
564 }
565 if path == "" || len(numbers) == 0 {
566 return c.usage()
567 }
568 repo, code := adminRepo(c, path)
569 if code >= 0 {
570 return code
571 }
572 if !yes {
573 return c.fail(protocol.ExitUsage, "admin mr prune drops the commits for good; re-run with --yes")
574 }
575 mrs := make([]store.MR, 0, len(numbers))
576 for _, n := range numbers {
577 mr, err := c.Store.MRByNumber(repo.ID, n)
578 if errors.Is(err, store.ErrNotFound) {
579 return c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path())
580 } else if err != nil {
581 return c.fail(protocol.ExitFailure, "%v", err)
582 }
583 if mr.State != "merged" && mr.State != "closed" {
584 return c.fail(protocol.ExitFailure, "!%d is still mergeable and its head is what makes it so; merge or close it first", n)
585 }
586 mrs = append(mrs, mr)
587 }
588
589 // The record is written as each ref goes, not after the gc: a failure
590 // past this point leaves refs deleted, and the audit log and the MR
591 // thread must say so. Re-running the same command finishes the job.
592 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
593 rows := make([]mrPruneOut, 0, len(mrs))
594 for _, mr := range mrs {
595 ref := mrHeadRef(mr.Number)
596 row := mrPruneOut{Number: mr.Number}
597 if gitutil.RefExists(dir, ref) {
598 row.Head, _ = gitutil.ResolveRef(dir, ref)
599 if err := gitutil.DeleteRef(dir, ref); err != nil {
600 c.Store.Audit(c.User.ID, "admin mr.prune", map[string]any{"repo": repo.Path(), "numbers": numbers, "failed": err.Error()})
601 return c.fail(protocol.ExitFailure, "%v; the refs before !%d are deleted and not yet pruned; re-run the same command", err, mr.Number)
602 }
603 }
604 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("head ref pruned by %s; the diff is no longer available", c.User.Username))
605 rows = append(rows, row)
606 }
607 c.Store.Audit(c.User.ID, "admin mr.prune", map[string]any{"repo": repo.Path(), "numbers": numbers})
608 if err := gitutil.PruneNow(dir); err != nil {
609 return c.fail(protocol.ExitFailure, "%v; the head refs are deleted but the objects are not yet pruned; re-run the same command", err)
610 }
611 return c.emit(rows, func(w io.Writer) {
612 for _, r := range rows {
613 if r.Head == "" {
614 fmt.Fprintf(w, "!%d\talready gone\n", r.Number)
615 continue
616 }
617 fmt.Fprintf(w, "!%d\t%s\n", r.Number, r.Head)
618 }
619 })
620}