Commit a7c9352033

a7c9352033c319587613cb6825035e8d99f5871a

parent: e4ed0d11a3

Verified · cmc ci/build: success ci/sonar: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-18 02:40 UTC

control: admin mr prune drops MR head refs and prunes the repository

`admin mr prune <owner/name> <n>... --yes` deletes refs/merge-requests/N/head
for the named merged or closed MRs and runs `git gc --prune=now` on the
repository, for commits a history rewrite left reachable only through
them. Anything but a merged or closed MR is refused before any write,
since its head is what makes it mergeable. Each deletion leaves a system
comment and the call audits as `admin mr.prune` before the gc runs, so a
gc failure leaves a record; re-running finishes the job. Nothing drops a
head ref on its own.

`mr diff` and the MR page say the head is gone instead of an empty diff.

Closes #227
.gitbay/wiki/Admin.org +22 −1
@@ -277,6 +277,26 @@ gitbayd admin gc --aggressive # thorough repack; slow, rarely needed
277277gitbayd admin gc --lfs # also drop LFS objects no pointer names (older than a day)
278278#+end_src
279279
280A history rewrite leaves the commits it removed reachable through
281=refs/merge-requests/N/head= of the merge requests that landed them, so
282they stay fetchable by anyone who can read the repository. Nothing drops
283a head ref on its own — an open or source-gone MR is merged through it,
284and a merged or closed one keeps its diff readable through it — so the
285cleanup is a command an instance admin runs, naming the MRs:
286
287#+begin_src sh
288ssh git@<host> admin mr prune owner/name 1 2 3 --yes
289#+end_src
290
291It refuses an open or source-gone MR, deletes the named refs, runs
292=git gc --prune=now= on that one repository so the objects go at once
293rather than after git's two-week grace, leaves a system comment on each
294MR, and audits as =admin mr.prune=. The MR keeps its title, comments,
295reviews and head sha; =mr diff= and the MR page say the head is gone.
296Run it when nothing is pushing to that repository: without the grace, a
297push caught between leaving quarantine and writing its ref loses its
298objects. Objects also survive in offsite backups until those are pruned.
299
280300=deploy/cloud-init.yaml= ships a =gitbay-gc.timer= that runs =admin gc=
281301weekly (Sunday 07:00 UTC). Imported repositories keep whatever pack
282302layout the source sent, so a first manual =admin gc= after a bulk
@@ -667,7 +687,8 @@ answers and which commit serves, 503 when it does not.
667687- deleting a fork marks MRs sourced from it =source_gone=; their diffs
668688 remain viewable and mergeable because the target repo owns the
669689 objects.
670- =refs/merge-requests/*= is server-owned and unpushable by clients.
690- =refs/merge-requests/*= is server-owned and unpushable by clients;
691 only =admin mr prune= removes one (see Maintenance).
671692- audit-relevant activity (issue/MR lifecycle, imports, pushes) lands in
672693 the =events= table, which also feeds webhooks.
673694- the daemon idles under 10MB RSS; the smallest VPS tier is adequate.
.gitbay/wiki/Parity.org +3 −3
@@ -369,9 +369,9 @@ with the same cursors; iOS pages with them too.
369369Build secrets, mirror configuration and tokens, custom domain claims,
370370API token minting, web session listing and revocation, deploy keys,
371371account and instance administration. Deleting, transferring or renaming
372a repository is also CLI-only, as is deleting an organization: each
373removes or moves what clone URLs point at, and wants a typed command,
374not a button.
372a repository is also CLI-only, as is deleting an organization and
373pruning merge request heads (=admin mr prune=): each removes or moves
374what clone URLs point at, and wants a typed command, not a button.
375375
376376These are the only rows where a =no= is intended. Everywhere else a
377377=no= is work outstanding, and =n/a= means a surface cannot usefully
.gitbay/wiki/Users.org +3 −1
@@ -419,7 +419,9 @@ Semantics worth knowing:
419419
420420- the MR head lives in the *target* repository as
421421 =refs/merge-requests/N/head= (fetchable by any reader), so an MR
422 survives deletion of its source branch or fork.
422 survives deletion of its source branch or fork. It is never removed
423 on merge or close; after a history rewrite an instance admin can
424 drop it with =admin mr prune=.
423425- force-pushing the source updates the MR and marks existing reviews
424426 stale — unless the diff is the one they reviewed. A rebase onto a
425427 target that moved on changes every sha and nothing about the change,
CHANGELOG.org +9
@@ -4,6 +4,15 @@ Versioning follows semver from v0.1.0. Database migrations run
44automatically on daemon start; upgrade notes appear per release when
55anything beyond "replace the binary and restart" is needed.
66
7* v1.24.0 — unreleased
8
9- =admin mr prune <owner/name> <n>... --yes= drops the named merged or
10 closed MRs' =refs/merge-requests/N/head= and runs =git gc --prune=now=
11 on the repository, for commits a history rewrite left reachable only
12 through them. Open and source-gone MRs are refused. Nothing drops a
13 head ref on its own. =mr diff= and the MR page say when a head is gone
14 (#227).
15
716* v1.23.0 — 2026-09-17
817
918The web design foundation (#218). Tokens measured in both schemes, one
cmd/gitbay/main.go +3
@@ -138,6 +138,9 @@ func newRoot() *cobra.Command {
138138 pass("visibility", "set visibility: <owner/name> public|private", passOpts{server: []string{"admin", "repo", "visibility"}}),
139139 pass("delete", "delete a repository: <owner/name> --yes", passOpts{server: []string{"admin", "repo", "delete"}}),
140140 ),
141 group("mr", "merge requests in any repository (audited)",
142 pass("prune", "drop merged or closed MRs' head refs and the objects only they kept: <owner/name> <n>... --yes", passOpts{server: []string{"admin", "mr", "prune"}}),
143 ),
141144 ),
142145 manCmd(root),
143146 )
cmd/gitbay/ssh.go +1
@@ -210,6 +210,7 @@ func withRepo(t target, args []string) ([]string, error) {
210210var listVerbs = map[string]bool{
211211 "list": true, "runners": true, "deliveries": true, "refs": true,
212212 "revisions": true, "threads": true, "bookmarks": true, "jobs": true,
213 "prune": true,
213214}
214215
215216// alignColumns reports whether a command's rows should be padded into
internal/control/admin.go +94
@@ -4,6 +4,8 @@ import (
44 "errors"
55 "fmt"
66 "io"
7 "slices"
8 "strconv"
79 "strings"
810 "time"
911
@@ -63,6 +65,10 @@ func init() {
6365 Summary: "delete any repository (instance admins; audited)",
6466 Usage: "admin repo delete <owner/name> --yes",
6567 SSHOnly: true, 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 SSHOnly: true, Run: runAdminMRPrune})
6672}
6773
6874// requireInstanceAdmin gates the admin noun. -1 means proceed.
@@ -524,3 +530,91 @@ func runAdminRunners(c *Ctx, args []string) int {
524530 }
525531 })
526532}
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}
internal/control/mr.go +3
@@ -652,6 +652,9 @@ func runMRDiff(c *Ctx, args []string) int {
652652 }
653653 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
654654 head := mrHeadRef(mr.Number)
655 if _, err := gitutil.ResolveRef(dir, head); err != nil {
656 return c.fail(protocol.ExitFailure, "the head of !%d is no longer in the repository; its diff is not available", mr.Number)
657 }
655658 // After a merge (especially fast-forward) the live merge-base equals
656659 // the head and the diff would vanish; use the recorded base instead.
657660 base := mr.MergedBase
internal/control/mrprune_test.go added +221
@@ -0,0 +1,221 @@
1package control
2
3import (
4 "bytes"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "gitbay.org/gitbay/internal/config"
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// prunedRepo builds a repository whose one merged MR's head is reachable
17// from nothing but refs/merge-requests/1/head: main never contained it
18// and the feature branch is deleted. That is the reachability a history
19// rewrite leaves behind, and the only case the command is for.
20func prunedRepo(t *testing.T) (*store.Store, store.Repo, string, string) {
21 t.Helper()
22 st, repo, uid := newQueueTestRepo(t)
23 git := gitRunner(t)
24 root := t.TempDir()
25 src := filepath.Join(root, "src")
26 os.MkdirAll(src, 0o755)
27 git(root, "init", "-q", "-b", "main", "src")
28 os.WriteFile(filepath.Join(src, "README"), []byte("x\n"), 0o644)
29 git(src, "add", ".")
30 git(src, "commit", "-q", "-m", "base")
31 git(src, "checkout", "-q", "-b", "feature")
32 os.WriteFile(filepath.Join(src, "README"), []byte("y\n"), 0o644)
33 git(src, "add", ".")
34 git(src, "commit", "-q", "-m", "change")
35 headSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
36
37 dir := RepoDir(root, repo.OwnerName, repo.Name)
38 os.MkdirAll(filepath.Dir(dir), 0o755)
39 git(root, "clone", "-q", "--bare", src, dir)
40 git(dir, "symbolic-ref", "HEAD", "refs/heads/main")
41 git(dir, "update-ref", mrHeadRef(1), headSHA)
42 git(dir, "update-ref", "-d", "refs/heads/feature")
43
44 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", headSHA, "md", false); err != nil {
45 t.Fatal(err)
46 }
47 mr, err := st.MRByNumber(repo.ID, 1)
48 if err != nil {
49 t.Fatal(err)
50 }
51 if err := st.MarkMerged(mr.ID, headSHA, uid, ""); err != nil {
52 t.Fatal(err)
53 }
54 return st, repo, root, headSHA
55}
56
57// rootUser creates an instance admin in the store, so the system comment
58// and audit row it writes have a real author.
59func rootUser(t *testing.T, st *store.Store) store.User {
60 t.Helper()
61 id, err := st.CreateUser("root", true)
62 if err != nil {
63 t.Fatal(err)
64 }
65 return store.User{ID: id, Username: "root", IsAdmin: true}
66}
67
68func pruneCtx(st *store.Store, root string, user store.User) (*Ctx, *bytes.Buffer) {
69 var errOut bytes.Buffer
70 c := &Ctx{User: user, Scope: "full", Store: st, Stdout: &bytes.Buffer{}, Stderr: &errOut}
71 c.Cfg.Server = config.Server{Root: root}
72 return c, &errOut
73}
74
75func objectExists(dir, sha string) bool {
76 cmd := exec.Command("git", "-C", dir, "cat-file", "-e", sha)
77 cmd.Env = gitTestEnv()
78 return cmd.Run() == nil
79}
80
81func refExists(dir, ref string) bool {
82 cmd := exec.Command("git", "-C", dir, "show-ref", "--verify", "--quiet", ref)
83 cmd.Env = gitTestEnv()
84 return cmd.Run() == nil
85}
86
87func TestAdminMRPruneDropsHeadAndObjects(t *testing.T) {
88 st, repo, root, headSHA := prunedRepo(t)
89 dir := RepoDir(root, repo.OwnerName, repo.Name)
90 c, errOut := pruneCtx(st, root, rootUser(t, st))
91
92 if code := Dispatch(c, []string{"admin", "mr", "prune", repo.Path(), "1", "--yes"}); code != protocol.ExitOK {
93 t.Fatalf("exit %d: %s", code, errOut.String())
94 }
95 if refExists(dir, mrHeadRef(1)) {
96 t.Error("refs/merge-requests/1/head still exists")
97 }
98 if objectExists(dir, headSHA) {
99 t.Error("the head commit is still in the object store; gc --prune=now did not run")
100 }
101 mr, _ := st.MRByNumber(repo.ID, 1)
102 comments, err := st.ListMRComments(mr.ID)
103 if err != nil {
104 t.Fatal(err)
105 }
106 if len(comments) != 1 || !strings.Contains(comments[0].Body, "pruned") {
107 t.Errorf("want one system comment saying the head was pruned, got %+v", comments)
108 }
109 entries, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "admin mr.prune", Limit: 10})
110 if err != nil {
111 t.Fatal(err)
112 }
113 if len(entries) != 1 {
114 t.Errorf("want one audit row, got %d", len(entries))
115 }
116}
117
118func TestAdminMRPruneTreatsMissingRefAsDone(t *testing.T) {
119 st, repo, root, _ := prunedRepo(t)
120 dir := RepoDir(root, repo.OwnerName, repo.Name)
121 gitRunner(t)(dir, "update-ref", "-d", mrHeadRef(1))
122 c, errOut := pruneCtx(st, root, rootUser(t, st))
123 if code := Dispatch(c, []string{"admin", "mr", "prune", repo.Path(), "1", "--yes"}); code != protocol.ExitOK {
124 t.Fatalf("exit %d: %s", code, errOut.String())
125 }
126}
127
128// Every refusal happens before any write: the ref and the objects are
129// untouched afterwards, even for the number that was valid.
130func TestAdminMRPruneRefusals(t *testing.T) {
131 cases := []struct {
132 name string
133 admin bool
134 args []string
135 want int
136 msg string
137 }{
138 {"non-admin", false, []string{"1", "--yes"}, protocol.ExitDenied, "instance admins"},
139 {"no --yes", true, []string{"1"}, protocol.ExitUsage, "--yes"},
140 {"no numbers", true, []string{"--yes"}, protocol.ExitUsage, "usage"},
141 {"not a number", true, []string{"x", "--yes"}, protocol.ExitUsage, "usage"},
142 {"unknown MR", true, []string{"1", "7", "--yes"}, protocol.ExitNotFound, "!7 not found"},
143 }
144 for _, tc := range cases {
145 t.Run(tc.name, func(t *testing.T) {
146 st, repo, root, headSHA := prunedRepo(t)
147 dir := RepoDir(root, repo.OwnerName, repo.Name)
148 user := store.User{ID: 1, Username: "alice"}
149 if tc.admin {
150 user = rootUser(t, st)
151 }
152 c, errOut := pruneCtx(st, root, user)
153 argv := append([]string{"admin", "mr", "prune", repo.Path()}, tc.args...)
154 if code := Dispatch(c, argv); code != tc.want {
155 t.Fatalf("exit %d, want %d: %s", code, tc.want, errOut.String())
156 }
157 if !strings.Contains(errOut.String(), tc.msg) {
158 t.Errorf("stderr %q does not mention %q", errOut.String(), tc.msg)
159 }
160 if !refExists(dir, mrHeadRef(1)) || !objectExists(dir, headSHA) {
161 t.Error("a refused call touched the repository")
162 }
163 })
164 }
165}
166
167// An open or source-gone MR is still mergeable, and its head ref is what
168// makes it so.
169func TestAdminMRPruneRefusesMergeableMR(t *testing.T) {
170 for _, state := range []string{"open", "source_gone"} {
171 t.Run(state, func(t *testing.T) {
172 st, repo, root, headSHA := prunedRepo(t)
173 dir := RepoDir(root, repo.OwnerName, repo.Name)
174 mr, _ := st.MRByNumber(repo.ID, 1)
175 if err := st.SetMRState(mr.ID, state); err != nil {
176 t.Fatal(err)
177 }
178 c, errOut := pruneCtx(st, root, rootUser(t, st))
179 if code := Dispatch(c, []string{"admin", "mr", "prune", repo.Path(), "1", "--yes"}); code != protocol.ExitFailure {
180 t.Fatalf("exit %d, want %d: %s", code, protocol.ExitFailure, errOut.String())
181 }
182 if !strings.Contains(errOut.String(), "still mergeable") {
183 t.Errorf("stderr %q does not say why", errOut.String())
184 }
185 if !refExists(dir, mrHeadRef(1)) || !objectExists(dir, headSHA) {
186 t.Error("a refused call touched the repository")
187 }
188 })
189 }
190}
191
192// mr diff on a pruned head says so instead of leaking git's own error.
193func TestMRDiffNamesAPrunedHead(t *testing.T) {
194 st, repo, root, _ := prunedRepo(t)
195 dir := RepoDir(root, repo.OwnerName, repo.Name)
196 gitRunner(t)(dir, "update-ref", "-d", mrHeadRef(1))
197 c, errOut := pruneCtx(st, root, store.User{ID: 1, Username: "alice"})
198 if code := Dispatch(c, []string{"mr", "diff", repo.Path(), "1"}); code != protocol.ExitFailure {
199 t.Fatalf("exit %d, want %d: %s", code, protocol.ExitFailure, errOut.String())
200 }
201 if !strings.Contains(errOut.String(), "no longer in the repository") || strings.Contains(errOut.String(), "exit status") {
202 t.Errorf("stderr %q should say the head is gone and not echo git", errOut.String())
203 }
204}
205
206// The same number twice is one MR: one deletion, one system comment.
207func TestAdminMRPruneDedupesNumbers(t *testing.T) {
208 st, repo, root, _ := prunedRepo(t)
209 c, errOut := pruneCtx(st, root, rootUser(t, st))
210 if code := Dispatch(c, []string{"admin", "mr", "prune", repo.Path(), "1", "1", "--yes"}); code != protocol.ExitOK {
211 t.Fatalf("exit %d: %s", code, errOut.String())
212 }
213 mr, _ := st.MRByNumber(repo.ID, 1)
214 comments, err := st.ListMRComments(mr.ID)
215 if err != nil {
216 t.Fatal(err)
217 }
218 if len(comments) != 1 {
219 t.Errorf("want one system comment, got %d", len(comments))
220 }
221}
internal/gitutil/merge.go +21
@@ -45,6 +45,27 @@ func DeleteRef(dir, ref string) error {
4545 return nil
4646}
4747
48// RefExists reports whether ref is present, whatever it points at. Unlike
49// ResolveRef it does not need the object to exist, so a ref left dangling
50// by an interrupted prune still reads as present and gets deleted.
51func RefExists(dir, ref string) bool {
52 return exec.Command(toolpath.Look("git"), "-C", dir, "show-ref", "--verify", "--quiet", ref).Run() == nil
53}
54
55// PruneNow repacks the repository and drops every unreachable object at
56// once, instead of after git's two-week grace. For when a ref was deleted
57// so that what it pointed at stops being fetchable by sha. Without the
58// grace, a push whose objects have left quarantine but whose ref is not
59// yet written can lose them; the window is milliseconds, and the one
60// caller is an explicit admin command, not a timer.
61func PruneNow(dir string) error {
62 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "gc", "--quiet", "--prune=now")
63 if out, err := cmd.CombinedOutput(); err != nil {
64 return fmt.Errorf("gc --prune=now: %v\n%s", err, out)
65 }
66 return nil
67}
68
4869// RevListRange returns commits in old..new, newest first.
4970func RevListRange(dir, old, new string) ([]string, error) {
5071 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--end-of-options", new, "^"+old)
internal/httpd/mrpage_test.go +20
@@ -35,9 +35,29 @@ type mrPageData struct {
3535 Gates *control.GatesOut
3636 SourceGone bool
3737 HeadMerged bool
38 HeadPruned bool
3839 Base string
3940}
4041
42// A pruned head has no diff to show; the page must say the head is gone
43// rather than that nothing changed, which is what an empty file list
44// otherwise renders as.
45func TestMRDiffViewNamesAPrunedHead(t *testing.T) {
46 var sb strings.Builder
47 if err := web.Render(&sb, "mr.html", mrPageData{
48 repoPage: testRepoPage(), MR: testMR("merged"), View: "diff", HeadPruned: true,
49 }); err != nil {
50 t.Fatalf("render: %v", err)
51 }
52 out := sb.String()
53 if !strings.Contains(out, "no longer in the repository") {
54 t.Error("diff view of a pruned head does not say the head is gone")
55 }
56 if strings.Contains(out, "No changes between") {
57 t.Error("diff view of a pruned head claims there were no changes")
58 }
59}
60
4161func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
4262 rows := make([]reviewRow, 0, len(reviews))
4363 for _, r := range reviews {
internal/httpd/web.go +6 −1
@@ -1863,6 +1863,10 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
18631863 diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
18641864
18651865 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1866 // An admin can prune the head ref; the diff is then unavailable, not
1867 // empty, and the page must not read as the latter.
1868 _, headErr := gitutil.ResolveRef(p.Dir, headRef)
1869 headPruned := headErr != nil
18661870 var files []diffFile
18671871 base := m.MergedBase
18681872 if base == "" {
@@ -1982,11 +1986,12 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
19821986 Gates *control.GatesOut
19831987 SourceGone bool
19841988 HeadMerged bool
1989 HeadPruned bool
19851990 Base string
19861991 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
19871992 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
19881993 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates,
1989 sourceGone(p, m), headMerged, base})
1994 sourceGone(p, m), headMerged, headPruned, base})
19901995}
19911996
19921997// sourceGone reports whether an MR's source branch no longer exists: the
internal/web/templates/mr.html +1
@@ -72,6 +72,7 @@
7272{{if .DiffFiles}}<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p>
7373{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below. Fetch the branch to see all of it.</p>{{end}}
7474{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}}
75{{else if .HeadPruned}}<p class="empty-note">The head of this merge request is no longer in the repository; its diff is not available.</p>
7576{{else}}<p class="empty-note">No changes between the source and target.{{if .HeadMerged}} The source branch was already merged or fast-forwarded into <code>{{.MR.TargetRef}}</code>.{{end}}</p>{{end}}
7677{{end}}
7778