Commit a7c9352033
Verified · cmc ci/build: success ci/sonar: success ci/test: success
.gitbay/wiki/Admin.org +22 −1
| @@ -277,6 +277,26 @@ gitbayd admin gc --aggressive # thorough repack; slow, rarely needed | ||
| 277 | 277 | gitbayd admin gc --lfs # also drop LFS objects no pointer names (older than a day) |
| 278 | 278 | #+end_src |
| 279 | 279 | |
| 280 | A history rewrite leaves the commits it removed reachable through | |
| 281 | =refs/merge-requests/N/head= of the merge requests that landed them, so | |
| 282 | they stay fetchable by anyone who can read the repository. Nothing drops | |
| 283 | a head ref on its own — an open or source-gone MR is merged through it, | |
| 284 | and a merged or closed one keeps its diff readable through it — so the | |
| 285 | cleanup is a command an instance admin runs, naming the MRs: | |
| 286 | ||
| 287 | #+begin_src sh | |
| 288 | ssh git@<host> admin mr prune owner/name 1 2 3 --yes | |
| 289 | #+end_src | |
| 290 | ||
| 291 | It 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 | |
| 293 | rather than after git's two-week grace, leaves a system comment on each | |
| 294 | MR, and audits as =admin mr.prune=. The MR keeps its title, comments, | |
| 295 | reviews and head sha; =mr diff= and the MR page say the head is gone. | |
| 296 | Run it when nothing is pushing to that repository: without the grace, a | |
| 297 | push caught between leaving quarantine and writing its ref loses its | |
| 298 | objects. Objects also survive in offsite backups until those are pruned. | |
| 299 | ||
| 280 | 300 | =deploy/cloud-init.yaml= ships a =gitbay-gc.timer= that runs =admin gc= |
| 281 | 301 | weekly (Sunday 07:00 UTC). Imported repositories keep whatever pack |
| 282 | 302 | layout 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 | 687 | - deleting a fork marks MRs sourced from it =source_gone=; their diffs |
| 668 | 688 | remain viewable and mergeable because the target repo owns the |
| 669 | 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 | 692 | - audit-relevant activity (issue/MR lifecycle, imports, pushes) lands in |
| 672 | 693 | the =events= table, which also feeds webhooks. |
| 673 | 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. | ||
| 369 | 369 | Build secrets, mirror configuration and tokens, custom domain claims, |
| 370 | 370 | API token minting, web session listing and revocation, deploy keys, |
| 371 | 371 | account and instance administration. Deleting, transferring or renaming |
| 372 | a repository is also CLI-only, as is deleting an organization: each | |
| 373 | removes or moves what clone URLs point at, and wants a typed command, | |
| 374 | not a button. | |
| 372 | a repository is also CLI-only, as is deleting an organization and | |
| 373 | pruning merge request heads (=admin mr prune=): each removes or moves | |
| 374 | what clone URLs point at, and wants a typed command, not a button. | |
| 375 | 375 | |
| 376 | 376 | These are the only rows where a =no= is intended. Everywhere else a |
| 377 | 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 | 420 | - the MR head lives in the *target* repository as |
| 421 | 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 | 425 | - force-pushing the source updates the MR and marks existing reviews |
| 424 | 426 | stale — unless the diff is the one they reviewed. A rebase onto a |
| 425 | 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 | ||
| 4 | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | 5 | anything 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 | 16 | * v1.23.0 — 2026-09-17 |
| 8 | 17 | |
| 9 | 18 | The web design foundation (#218). Tokens measured in both schemes, one |
cmd/gitbay/main.go +3
| @@ -138,6 +138,9 @@ func newRoot() *cobra.Command { | ||
| 138 | 138 | pass("visibility", "set visibility: <owner/name> public|private", passOpts{server: []string{"admin", "repo", "visibility"}}), |
| 139 | 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 | 145 | manCmd(root), |
| 143 | 146 | ) |
cmd/gitbay/ssh.go +1
| @@ -210,6 +210,7 @@ func withRepo(t target, args []string) ([]string, error) { | ||
| 210 | 210 | var listVerbs = map[string]bool{ |
| 211 | 211 | "list": true, "runners": true, "deliveries": true, "refs": true, |
| 212 | 212 | "revisions": true, "threads": true, "bookmarks": true, "jobs": true, |
| 213 | "prune": true, | |
| 213 | 214 | } |
| 214 | 215 | |
| 215 | 216 | // alignColumns reports whether a command's rows should be padded into |
internal/control/admin.go +94
| @@ -4,6 +4,8 @@ import ( | ||
| 4 | 4 | "errors" |
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | "slices" | |
| 8 | "strconv" | |
| 7 | 9 | "strings" |
| 8 | 10 | "time" |
| 9 | 11 | |
| @@ -63,6 +65,10 @@ func init() { | ||
| 63 | 65 | Summary: "delete any repository (instance admins; audited)", |
| 64 | 66 | Usage: "admin repo delete <owner/name> --yes", |
| 65 | 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 | 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 | ||
| 534 | type 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. | |
| 545 | func 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 | 653 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) |
| 654 | 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 | 658 | // After a merge (especially fast-forward) the live merge-base equals |
| 656 | 659 | // the head and the diff would vanish; use the recorded base instead. |
| 657 | 660 | base := mr.MergedBase |
internal/control/mrprune_test.go added +221
| @@ -0,0 +1,221 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 20 | func 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. | |
| 59 | func 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 | ||
| 68 | func 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 | ||
| 75 | func 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 | ||
| 81 | func 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 | ||
| 87 | func 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 | ||
| 118 | func 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. | |
| 130 | func 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. | |
| 169 | func 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. | |
| 193 | func 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. | |
| 207 | func 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 | 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. | |
| 51 | func 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. | |
| 61 | func 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 | 69 | // RevListRange returns commits in old..new, newest first. |
| 49 | 70 | func RevListRange(dir, old, new string) ([]string, error) { |
| 50 | 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 | 35 | Gates *control.GatesOut |
| 36 | 36 | SourceGone bool |
| 37 | 37 | HeadMerged bool |
| 38 | HeadPruned bool | |
| 38 | 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. | |
| 45 | func 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 | ||
| 41 | 61 | func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { |
| 42 | 62 | rows := make([]reviewRow, 0, len(reviews)) |
| 43 | 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 | 1863 | diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID) |
| 1864 | 1864 | |
| 1865 | 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 | 1870 | var files []diffFile |
| 1867 | 1871 | base := m.MergedBase |
| 1868 | 1872 | if base == "" { |
| @@ -1982,11 +1986,12 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1982 | 1986 | Gates *control.GatesOut |
| 1983 | 1987 | SourceGone bool |
| 1984 | 1988 | HeadMerged bool |
| 1989 | HeadPruned bool | |
| 1985 | 1990 | Base string |
| 1986 | 1991 | }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), |
| 1987 | 1992 | reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), |
| 1988 | 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 | 1997 | // sourceGone reports whether an MR's source branch no longer exists: the |
internal/web/templates/mr.html +1
| @@ -72,6 +72,7 @@ | ||
| 72 | 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 | 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 | 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 | 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 | 77 | {{end}} |
| 77 | 78 | |