Commit c1d0a3afb0

c1d0a3afb08372592122331d76de77525edd9d2b

parent: c000478e03

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

cmc <hello@cleberg.net> · 2026-09-07 22:27 UTC

policy, control, web, wiki: require-mr makes protected branches merge-only

repo settings protect meant no force-push and no delete; the merge
gates applied to mr merge and a writer could push straight past them.
repo settings require-mr <owner/name> on|off adds the mode every other
forge has: an existing protected branch refuses every direct push in
pre-receive, repo commit-file and the web editor are refused the same
way, and the server's merge is the only writer. Creating the branch is
still a push. Settings page checkbox, settings show field, Users page.

Closes #197
.gitbay/wiki/Users.org +8
@@ -192,6 +192,7 @@ Access and settings (owner or =admin= grant):
192192gitbay repo access grant you/project alice write # read | write | admin
193193gitbay repo access revoke you/project alice
194194gitbay repo settings protect you/project main # no force-push, no delete
195gitbay repo settings require-mr you/project on # protected branches: merge requests only
195196gitbay repo settings default-branch you/project trunk # HEAD, and what the web shows
196197gitbay repo settings require-signed you/project on # every commit must verify
197198gitbay repo settings git-daemon you/project on # expose over git://
@@ -397,6 +398,13 @@ to ask without it gating merges; with it on and no file on the target
397398branch, the merge is refused and says so. It does not wait on
398399=require-approvals=.
399400
401Those gates apply to =mr merge=. A direct push to a protected branch
402passes none of them until =require-mr on=: then an existing protected
403branch refuses every push, including =repo commit-file= and the web
404editor, and the server's merge is its only writer. Creating the branch
405is still a push, since there is nothing to open a merge request against
406yet.
407
400408A merge request whose target is another open merge request's source
401409branch is stacked on it: =mr create= says so, =mr show= carries
402410=stacked_on= and =stacked=, and merging the lower one retargets the
cmd/gitbay/main.go +1
@@ -479,6 +479,7 @@ func repoCmd() *cobra.Command {
479479 pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}),
480480 pass("visibility", "set repository visibility: public|private", passOpts{server: []string{"repo", "settings", "visibility"}, needsRepo: true}),
481481 pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}),
482 pass("require-mr", "protected branches take changes through merge requests only: on|off", passOpts{server: []string{"repo", "settings", "require-mr"}, needsRepo: true}),
482483 pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}),
483484 pass("website", "set the repository website: <url> ('' clears)", passOpts{server: []string{"repo", "settings", "website"}, needsRepo: true}),
484485 pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}),
e2e/requiremr_test.go added +83
@@ -0,0 +1,83 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// With require-mr on, a protected branch takes changes through mr merge
11// only: direct pushes and commit-file are refused, a branch that does
12// not exist yet can still be created, and unprotected branches are
13// unaffected (#197).
14func TestRequireMR(t *testing.T) {
15 inst := startInstance(t)
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
20 t.Fatalf("repo create: %s", errOut)
21 }
22 env := inst.gitEnv(aliceKey)
23 work := t.TempDir()
24 mustGit(t, work, env, "clone", "-q", inst.sshURL("alice/lib"), "lib")
25 dir := filepath.Join(work, "lib")
26 write := func(name, content string) {
27 t.Helper()
28 if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
29 t.Fatal(err)
30 }
31 mustGit(t, dir, env, "add", name)
32 mustGit(t, dir, env, "commit", "-q", "-m", name)
33 }
34 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
35 write("README", "one\n")
36 mustGit(t, dir, env, "push", "-q", "origin", "main")
37
38 for _, args := range [][]string{
39 {"repo", "settings", "protect", "alice/lib", "main"},
40 {"repo", "settings", "protect", "alice/lib", "release"},
41 {"repo", "settings", "require-mr", "alice/lib", "on"},
42 } {
43 if _, errOut, code := inst.ssh(t, aliceKey, "", args...); code != 0 {
44 t.Fatalf("%v: %s", args, errOut)
45 }
46 }
47 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/lib", "--json")
48 if !strings.Contains(out, `"require_mr":true`) {
49 t.Fatalf("settings show: %s", out)
50 }
51
52 // Direct push to main: refused, with the reason.
53 write("two", "two\n")
54 if out, code := gitRun(t, dir, env, "push", "origin", "main"); code == 0 || !strings.Contains(out, "merge requests only") {
55 t.Fatalf("direct push to protected branch: %d\n%s", code, out)
56 }
57 if _, errOut, code := inst.ssh(t, aliceKey, "x\n", "repo", "commit-file", "alice/lib", "notes.txt",
58 "--ref", "main", "--file", "-"); code != 4 || !strings.Contains(errOut, "merge requests only") {
59 t.Fatalf("commit-file to protected branch: %d %s", code, errOut)
60 }
61 // Creating a protected branch is still a push; so is an unprotected one.
62 mustGit(t, dir, env, "push", "-q", "origin", "main:release")
63 mustGit(t, dir, env, "push", "-q", "origin", "main:feature")
64
65 // The same change lands through a merge request.
66 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/lib",
67 "--source", "feature", "--target", "main", "--title", "'two'"); code != 0 {
68 t.Fatalf("mr create: %s", errOut)
69 }
70 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "1", "--strategy", "ff"); code != 0 {
71 t.Fatalf("mr merge: %s", errOut)
72 }
73 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/lib", "--limit", "1"); !strings.Contains(out, "two") {
74 t.Fatalf("merge did not move main: %s", out)
75 }
76
77 // Off again, and the push goes through.
78 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-mr", "alice/lib", "off"); code != 0 {
79 t.Fatalf("require-mr off: %s", errOut)
80 }
81 write("three", "three\n")
82 mustGit(t, dir, env, "push", "-q", "origin", "main")
83}
internal/control/commitfile.go +4
@@ -3,6 +3,7 @@ package control
33import (
44 "fmt"
55 "io"
6 "slices"
67 "strings"
78
89 "gitbay.org/gitbay/internal/gitutil"
@@ -55,6 +56,9 @@ func runCommitFile(c *Ctx, args []string) int {
5556 if !ok || filePath == "" {
5657 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
5758 }
59 if repo.Settings.RequireMR && slices.Contains(repo.Settings.ProtectedBranches, ref) {
60 return c.fail(protocol.ExitDenied, "branch %s accepts changes through merge requests only", ref)
61 }
5862 // The server authors this commit, so it cannot sign it.
5963 if repo.Settings.RequireSignedCommits {
6064 return c.fail(protocol.ExitDenied,
internal/control/mr.go +20
@@ -32,6 +32,9 @@ func init() {
3232 register(Command{Path: []string{"repo", "settings", "require-checks"},
3333 Summary: "gate merges on green statuses",
3434 Usage: "repo settings require-checks <owner/name> on|off", Run: runRequireChecks})
35 register(Command{Path: []string{"repo", "settings", "require-mr"},
36 Summary: "protected branches take changes through merge requests only",
37 Usage: "repo settings require-mr <owner/name> on|off", Run: runRequireMR})
3538 register(Command{Path: []string{"repo", "settings", "require-signed"},
3639 Summary: "require verified commit signatures",
3740 Usage: "repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
@@ -217,6 +220,23 @@ func runRequireChecks(c *Ctx, args []string) int {
217220 })
218221}
219222
223func runRequireMR(c *Ctx, args []string) int {
224 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
225 return c.fail(protocol.ExitUsage, "usage: repo settings require-mr <owner/name> on|off")
226 }
227 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
228 if code >= 0 {
229 return code
230 }
231 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireMR = args[1] == "on" })
232 if err != nil {
233 return c.fail(protocol.ExitFailure, "%v", err)
234 }
235 return c.emit(s, func(w io.Writer) {
236 fmt.Fprintf(w, "require_mr %s on %s\n", args[1], repo.Path())
237 })
238}
239
220240func runRequireSigned(c *Ctx, args []string) int {
221241 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
222242 return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off")
internal/control/repo.go +2 −2
@@ -606,8 +606,8 @@ func runSettingsShow(c *Ctx, args []string) int {
606606 return code
607607 }
608608 return c.emit(repo.Settings, func(w io.Writer) {
609 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
610 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
609 fmt.Fprintf(w, "protected_branches: %s\nrequire_mr: %v\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
610 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireMR, repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
611611 })
612612}
613613
internal/httpd/settings.go +2
@@ -78,6 +78,8 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
7878 argv = []string{"repo", "settings", "require-resolved", repo, onOff(v("require-resolved"))}
7979 case "require-codeowners":
8080 argv = []string{"repo", "settings", "require-codeowners", repo, onOff(v("require-codeowners"))}
81 case "require-mr":
82 argv = []string{"repo", "settings", "require-mr", repo, onOff(v("require-mr"))}
8183 case "require-signed":
8284 argv = []string{"repo", "settings", "require-signed", repo, onOff(v("require-signed"))}
8385 case "require-approvals":
internal/policy/access.go +8
@@ -106,7 +106,15 @@ func CheckPush(repo store.Repo, updates []RefUpdate) string {
106106 if u.IsForce {
107107 return "branch " + branch + " is protected: force-push refused"
108108 }
109 // Under require_mr the server's merge is the only writer of an
110 // existing protected branch. Creating one is still a push:
111 // there is nothing to route a merge request into yet.
112 if repo.Settings.RequireMR && !isZeroSHA(u.Old) {
113 return "branch " + branch + " accepts changes through merge requests only"
114 }
109115 }
110116 }
111117 return ""
112118}
119
120func isZeroSHA(sha string) bool { return sha != "" && strings.Trim(sha, "0") == "" }
internal/policy/access_test.go +29
@@ -1,6 +1,7 @@
11package policy
22
33import (
4 "strings"
45 "testing"
56
67 "gitbay.org/gitbay/internal/store"
@@ -110,3 +111,31 @@ func TestCheckPush(t *testing.T) {
110111 })
111112 }
112113}
114
115// With require_mr, a protected branch takes no direct push once it
116// exists; creating it and pushing elsewhere are unaffected (#197).
117func TestCheckPushRequireMR(t *testing.T) {
118 repo := store.Repo{Settings: store.RepoSettings{ProtectedBranches: []string{"main"}, RequireMR: true}}
119 const zero = "0000000000000000000000000000000000000000"
120 cases := []struct {
121 name string
122 updates []RefUpdate
123 denied bool
124 }{
125 {"update protected", []RefUpdate{{Ref: "refs/heads/main", Old: "abc", New: "def"}}, true},
126 {"create protected", []RefUpdate{{Ref: "refs/heads/main", Old: zero, New: "def"}}, false},
127 {"delete protected", []RefUpdate{{Ref: "refs/heads/main", Old: "abc", New: zero, IsDelete: true}}, true},
128 {"update unprotected", []RefUpdate{{Ref: "refs/heads/dev", Old: "abc", New: "def"}}, false},
129 }
130 for _, tc := range cases {
131 t.Run(tc.name, func(t *testing.T) {
132 msg := CheckPush(repo, tc.updates)
133 if (msg != "") != tc.denied {
134 t.Errorf("CheckPush = %q, denied should be %v", msg, tc.denied)
135 }
136 })
137 }
138 if msg := CheckPush(repo, cases[0].updates); !strings.Contains(msg, "merge requests only") {
139 t.Errorf("message %q", msg)
140 }
141}
internal/store/repos.go +1
@@ -28,6 +28,7 @@ type RepoSettings struct {
2828 RequireApprovals int `json:"require_approvals,omitempty"`
2929 RequireResolved bool `json:"require_resolved,omitempty"`
3030 RequireCodeowners bool `json:"require_codeowners,omitempty"`
31 RequireMR bool `json:"require_mr,omitempty"`
3132 GitDaemon bool `json:"git_daemon,omitempty"`
3233 Archived bool `json:"archived,omitempty"`
3334 Website string `json:"website,omitempty"`
internal/web/templates/settings.html +7
@@ -105,6 +105,13 @@
105105 </select>
106106 <button type="submit">Protect</button>
107107</form>
108<form method="post" action="{{$base}}" class="setform">
109 <input type="hidden" name="field" value="require-mr">
110 <label for="require-mr">Merge requests only</label>
111 <input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}>
112 <button type="submit">Save</button>
113</form>
114<p class="meta">With merge requests only, a protected branch refuses every direct push once it exists; the merge gates above are then what a change has to pass.</p>
108115
109116<h2>Dependencies</h2>
110117<form method="post" action="{{$base}}" class="setform">