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

merged merged by cmc on 2026-09-18 03:27 UTC · krz/gitbay:admin-mr-prune into main

13 files changed, +407 −6

Layout: unified · split

.gitbay/wiki/Admin.org +22 −1
@@ -277,6 +277,26 @@ gitbayd admin gc --aggressive # thorough repack; slow, rarely needed
277gitbayd admin gc --lfs # also drop LFS objects no pointer names (older than a day) 277gitbayd admin gc --lfs # also drop LFS objects no pointer names (older than a day)
278#+end_src 278#+end_src
279 279
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
280=deploy/cloud-init.yaml= ships a =gitbay-gc.timer= that runs =admin gc= 300=deploy/cloud-init.yaml= ships a =gitbay-gc.timer= that runs =admin gc=
281weekly (Sunday 07:00 UTC). Imported repositories keep whatever pack 301weekly (Sunday 07:00 UTC). Imported repositories keep whatever pack
282layout the source sent, so a first manual =admin gc= after a bulk 302layout 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.
667- deleting a fork marks MRs sourced from it =source_gone=; their diffs 687- deleting a fork marks MRs sourced from it =source_gone=; their diffs
668 remain viewable and mergeable because the target repo owns the 688 remain viewable and mergeable because the target repo owns the
669 objects. 689 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).
671- audit-relevant activity (issue/MR lifecycle, imports, pushes) lands in 692- audit-relevant activity (issue/MR lifecycle, imports, pushes) lands in
672 the =events= table, which also feeds webhooks. 693 the =events= table, which also feeds webhooks.
673- the daemon idles under 10MB RSS; the smallest VPS tier is adequate. 694- 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.
369Build secrets, mirror configuration and tokens, custom domain claims, 369Build secrets, mirror configuration and tokens, custom domain claims,
370API token minting, web session listing and revocation, deploy keys, 370API token minting, web session listing and revocation, deploy keys,
371account and instance administration. Deleting, transferring or renaming 371account and instance administration. Deleting, transferring or renaming
372a repository is also CLI-only, as is deleting an organization: each 372a repository is also CLI-only, as is deleting an organization and
373removes or moves what clone URLs point at, and wants a typed command, 373pruning merge request heads (=admin mr prune=): each removes or moves
374not a button. 374what clone URLs point at, and wants a typed command, not a button.
375 375
376These are the only rows where a =no= is intended. Everywhere else a 376These are the only rows where a =no= is intended. Everywhere else a
377=no= is work outstanding, and =n/a= means a surface cannot usefully 377=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:
419 419
420- the MR head lives in the *target* repository as 420- the MR head lives in the *target* repository as
421 =refs/merge-requests/N/head= (fetchable by any reader), so an MR 421 =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=.
423- force-pushing the source updates the MR and marks existing reviews 425- force-pushing the source updates the MR and marks existing reviews
424 stale — unless the diff is the one they reviewed. A rebase onto a 426 stale — unless the diff is the one they reviewed. A rebase onto a
425 target that moved on changes every sha and nothing about the change, 427 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
4automatically on daemon start; upgrade notes appear per release when 4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed. 5anything beyond "replace the binary and restart" is needed.
6 6
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
7* v1.23.0 — 2026-09-17 16* v1.23.0 — 2026-09-17
8 17
9The web design foundation (#218). Tokens measured in both schemes, one 18The web design foundation (#218). Tokens measured in both schemes, one
cmd/gitbay/main.go +3
@@ -138,6 +138,9 @@ func newRoot() *cobra.Command {
138 pass("visibility", "set visibility: <owner/name> public|private", passOpts{server: []string{"admin", "repo", "visibility"}}), 138 pass("visibility", "set visibility: <owner/name> public|private", passOpts{server: []string{"admin", "repo", "visibility"}}),
139 pass("delete", "delete a repository: <owner/name> --yes", passOpts{server: []string{"admin", "repo", "delete"}}), 139 pass("delete", "delete a repository: <owner/name> --yes", passOpts{server: []string{"admin", "repo", "delete"}}),
140 ), 140 ),
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 ),
141 ), 144 ),
142 manCmd(root), 145 manCmd(root),
143 ) 146 )
cmd/gitbay/ssh.go +1
@@ -210,6 +210,7 @@ func withRepo(t target, args []string) ([]string, error) {
210var listVerbs = map[string]bool{ 210var listVerbs = map[string]bool{
211 "list": true, "runners": true, "deliveries": true, "refs": true, 211 "list": true, "runners": true, "deliveries": true, "refs": true,
212 "revisions": true, "threads": true, "bookmarks": true, "jobs": true, 212 "revisions": true, "threads": true, "bookmarks": true, "jobs": true,
213 "prune": true,
213} 214}
214 215
215// alignColumns reports whether a command's rows should be padded into 216// alignColumns reports whether a command's rows should be padded into
internal/control/admin.go +94
@@ -4,6 +4,8 @@ import (
4 "errors" 4 "errors"
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "slices"
8 "strconv"
7 "strings" 9 "strings"
8 "time" 10 "time"
9 11
@@ -63,6 +65,10 @@ func init() {
63 Summary: "delete any repository (instance admins; audited)", 65 Summary: "delete any repository (instance admins; audited)",
64 Usage: "admin repo delete <owner/name> --yes", 66 Usage: "admin repo delete <owner/name> --yes",
65 SSHOnly: true, Run: runAdminRepoDelete}) 67 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})
66} 72}
67 73
68// requireInstanceAdmin gates the admin noun. -1 means proceed. 74// requireInstanceAdmin gates the admin noun. -1 means proceed.
@@ -524,3 +530,91 @@ func runAdminRunners(c *Ctx, args []string) int {
524 } 530 }
525 }) 531 })
526} 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}
internal/control/mr.go +3
@@ -652,6 +652,9 @@ func runMRDiff(c *Ctx, args []string) int {
652 } 652 }
653 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) 653 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
654 head := mrHeadRef(mr.Number) 654 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 }
655 // After a merge (especially fast-forward) the live merge-base equals 658 // After a merge (especially fast-forward) the live merge-base equals
656 // the head and the diff would vanish; use the recorded base instead. 659 // the head and the diff would vanish; use the recorded base instead.
657 base := mr.MergedBase 660 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 {
45 return nil 45 return nil
46} 46}
47 47
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
48// RevListRange returns commits in old..new, newest first. 69// RevListRange returns commits in old..new, newest first.
49func RevListRange(dir, old, new string) ([]string, error) { 70func RevListRange(dir, old, new string) ([]string, error) {
50 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--end-of-options", new, "^"+old) 71 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 {
35 Gates *control.GatesOut 35 Gates *control.GatesOut
36 SourceGone bool 36 SourceGone bool
37 HeadMerged bool 37 HeadMerged bool
38 HeadPruned bool
38 Base string 39 Base string
39} 40}
40 41
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
41func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { 61func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
42 rows := make([]reviewRow, 0, len(reviews)) 62 rows := make([]reviewRow, 0, len(reviews))
43 for _, r := range reviews { 63 for _, r := range reviews {
internal/httpd/web.go +6 −1
@@ -1863,6 +1863,10 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1863 diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID) 1863 diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
1864 1864
1865 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number) 1865 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
1866 var files []diffFile 1870 var files []diffFile
1867 base := m.MergedBase 1871 base := m.MergedBase
1868 if base == "" { 1872 if base == "" {
@@ -1982,11 +1986,12 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1982 Gates *control.GatesOut 1986 Gates *control.GatesOut
1983 SourceGone bool 1987 SourceGone bool
1984 HeadMerged bool 1988 HeadMerged bool
1989 HeadPruned bool
1985 Base string 1990 Base string
1986 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), 1991 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1987 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), 1992 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1988 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates, 1993 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates,
1989 sourceGone(p, m), headMerged, base}) 1994 sourceGone(p, m), headMerged, headPruned, base})
1990} 1995}
1991 1996
1992// sourceGone reports whether an MR's source branch no longer exists: the 1997// sourceGone reports whether an MR's source branch no longer exists: the
internal/web/templates/mr.html +1
@@ -72,6 +72,7 @@
72{{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> 72{{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>
73{{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}} 73{{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}}
74{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}} 74{{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>
75{{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}} 76{{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}}
76{{end}} 77{{end}}
77 78