Commit 3797906826

3797906826d106b7e052e9d1a95958af851ec052

parent: ac3ae48217

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

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

control, hookd, web, wiki: the default branch can be set, and follows the first push

repo create initialised HEAD at main and nothing moved it except repo
import. A first push of master or trunk left HEAD naming a branch that
did not exist: git clone reported the remote HEAD as nonexistent and
checked out nothing, and every surface read the stored main and asked
git for a branch that was not there.

post-receive now moves an unborn HEAD to the first branch a push
creates and records it; a push that includes the default branch needs
nothing. repo settings default-branch <owner/name> <branch> moves it
later, refusing a branch that does not exist. The settings page carries
the same control as a select over the branches. Parity row.

Closes #189
.gitbay/wiki/Parity.org +1
@@ -154,6 +154,7 @@ always markdown.
154154| watch, unwatch | yes | yes | yes |
155155| mute | yes | no | yes |
156156| settings, protection | yes | yes | yes |
157| default branch | yes | yes | no |
157158| require codeowners | yes | yes | yes |
158159| access grants | yes | no | yes |
159160| webhooks | yes | no | yes |
.gitbay/wiki/Users.org +1
@@ -191,6 +191,7 @@ Access and settings (owner or =admin= grant):
191191gitbay repo access grant you/project alice write # read | write | admin
192192gitbay repo access revoke you/project alice
193193gitbay repo settings protect you/project main # no force-push, no delete
194gitbay repo settings default-branch you/project trunk # HEAD, and what the web shows
194195gitbay repo settings require-signed you/project on # every commit must verify
195196gitbay repo settings git-daemon you/project on # expose over git://
196197gitbay repo topics add you/project cli forge # free-form tags, shown on the web
cmd/gitbay/main.go +1
@@ -471,6 +471,7 @@ func repoCmd() *cobra.Command {
471471 pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}),
472472 pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}),
473473 pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}),
474 pass("default-branch", "set the default branch: <branch>", passOpts{server: []string{"repo", "settings", "default-branch"}, needsRepo: true}),
474475 pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}),
475476 pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}),
476477 pass("require-codeowners", "require an owner's approval per covered file: on|off", passOpts{server: []string{"repo", "settings", "require-codeowners"}, needsRepo: true}),
e2e/defaultbranch_test.go added +94
@@ -0,0 +1,94 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// A first push of a branch other than main moves the unborn HEAD to it,
12// and repo settings default-branch moves it later (#189).
13func TestDefaultBranch(t *testing.T) {
14 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
15 aliceKey := inst.newKey(t, "alice")
16 bobKey := inst.newKey(t, "bob")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
20 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj"); code != 0 {
21 t.Fatalf("repo create: %s", errOut)
22 }
23
24 env := inst.gitEnv(aliceKey)
25 dir := filepath.Join(t.TempDir(), "proj")
26 mustGit(t, t.TempDir(), env, "init", "-q", "-b", "master", dir)
27 if err := os.WriteFile(filepath.Join(dir, "README"), []byte("hello\n"), 0o644); err != nil {
28 t.Fatal(err)
29 }
30 mustGit(t, dir, env, "add", "README")
31 mustGit(t, dir, env, "commit", "-q", "-m", "one")
32 mustGit(t, dir, env, "remote", "add", "origin", inst.sshURL("alice/proj"))
33 mustGit(t, dir, env, "push", "-q", "origin", "master")
34
35 // HEAD followed the push: a clone checks master out, and the record
36 // agrees.
37 clone := filepath.Join(t.TempDir(), "c1")
38 mustGit(t, t.TempDir(), env, "clone", "-q", inst.sshURL("alice/proj"), clone)
39 if head := strings.TrimSpace(mustGit(t, clone, env, "symbolic-ref", "HEAD")); head != "refs/heads/master" {
40 t.Fatalf("clone HEAD %q", head)
41 }
42 if _, err := os.Stat(filepath.Join(clone, "README")); err != nil {
43 t.Fatalf("clone checked out nothing: %v", err)
44 }
45 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/proj", "--json")
46 if !strings.Contains(out, `"default_branch":"master"`) {
47 t.Fatalf("repo show after first push: %s", out)
48 }
49
50 // A second branch does not move HEAD; the setting does.
51 mustGit(t, dir, env, "checkout", "-q", "-b", "dev")
52 if err := os.WriteFile(filepath.Join(dir, "DEV"), []byte("dev\n"), 0o644); err != nil {
53 t.Fatal(err)
54 }
55 mustGit(t, dir, env, "add", "DEV")
56 mustGit(t, dir, env, "commit", "-q", "-m", "two")
57 mustGit(t, dir, env, "push", "-q", "origin", "dev")
58 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/proj", "--json")
59 if !strings.Contains(out, `"default_branch":"master"`) {
60 t.Fatalf("second branch moved the default: %s", out)
61 }
62 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "settings", "default-branch", "alice/proj", "dev"); code != 4 {
63 t.Fatalf("non-admin set the default branch: %d %s", code, errOut)
64 }
65 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "default-branch", "alice/proj", "nope"); code != 1 || !strings.Contains(errOut, "no branch") {
66 t.Fatalf("missing branch accepted: %d %s", code, errOut)
67 }
68 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "default-branch", "alice/proj", "dev"); code != 0 {
69 t.Fatalf("default-branch: %s", errOut)
70 }
71 clone = filepath.Join(t.TempDir(), "c2")
72 mustGit(t, t.TempDir(), env, "clone", "-q", inst.sshURL("alice/proj"), clone)
73 if head := strings.TrimSpace(mustGit(t, clone, env, "symbolic-ref", "HEAD")); head != "refs/heads/dev" {
74 t.Fatalf("clone HEAD after setting %q", head)
75 }
76 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/proj", "--json")
77 if !strings.Contains(out, `"default_branch":"dev"`) {
78 t.Fatalf("repo show after setting: %s", out)
79 }
80
81 // The settings page carries the same control.
82 alice := inst.login(t, aliceKey)
83 set := inst.base() + "/alice/proj/settings"
84 if _, body := browserGet(t, alice, set); !strings.Contains(body, `value="dev" selected`) {
85 t.Fatalf("settings page does not show the default branch:\n%s", body)
86 }
87 if status, _ := browserPost(t, alice, set, url.Values{"field": {"default-branch"}, "default-branch": {"master"}}); status != 200 {
88 t.Fatalf("settings post: %d", status)
89 }
90 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/proj", "--json")
91 if !strings.Contains(out, `"default_branch":"master"`) {
92 t.Fatalf("repo show after web: %s", out)
93 }
94}
internal/control/repo.go +27
@@ -66,6 +66,9 @@ func init() {
6666 register(Command{Path: []string{"repo", "settings", "website"},
6767 Summary: "set the repository website",
6868 Usage: "repo settings website <owner/name> <url> ('' clears)", Run: runSetWebsite})
69 register(Command{Path: []string{"repo", "settings", "default-branch"},
70 Summary: "set the default branch",
71 Usage: "repo settings default-branch <owner/name> <branch>", Run: runSetDefaultBranch})
6972 register(Command{Path: []string{"repo", "settings", "git-daemon"},
7073 Summary: "expose over git://",
7174 Usage: "repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
@@ -585,6 +588,30 @@ func runSetDescription(c *Ctx, args []string) int {
585588 })
586589}
587590
591func runSetDefaultBranch(c *Ctx, args []string) int {
592 if len(args) != 2 {
593 return c.fail(protocol.ExitUsage, "usage: repo settings default-branch <owner/name> <branch>")
594 }
595 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
596 if code >= 0 {
597 return code
598 }
599 branch := args[1]
600 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
601 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+branch); err != nil {
602 return c.fail(protocol.ExitFailure, "no branch named %q on %s", branch, repo.Path())
603 }
604 if err := gitutil.SetHead(dir, branch); err != nil {
605 return c.fail(protocol.ExitFailure, "%v", err)
606 }
607 if err := c.Store.UpdateDefaultBranch(repo.ID, branch); err != nil {
608 return c.fail(protocol.ExitFailure, "%v", err)
609 }
610 return c.emit(map[string]string{"default_branch": branch}, func(w io.Writer) {
611 fmt.Fprintf(w, "default branch of %s is now %s\n", repo.Path(), branch)
612 })
613}
614
588615func runSetWebsite(c *Ctx, args []string) int {
589616 if len(args) != 2 {
590617 return c.fail(protocol.ExitUsage, "usage: repo settings website <owner/name> <url>")
internal/hookd/hookd.go +32
@@ -187,6 +187,9 @@ func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
187187// place a hook writes outside its own repository.
188188func (s *Server) postReceive(req Request) {
189189 pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID)
190 if pushedRepoErr == nil {
191 s.adoptDefaultBranch(&pushedRepo, req.Updates)
192 }
190193 for _, u := range req.Updates {
191194 // Every ref update is an event webhooks can subscribe to.
192195 s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf(
@@ -269,6 +272,35 @@ func (s *Server) postReceive(req Request) {
269272 }
270273}
271274
275// adoptDefaultBranch moves an unborn HEAD to the first branch a push
276// creates. A repository is initialised with HEAD at the stored default,
277// and a first push of master or trunk left HEAD naming a branch that did
278// not exist: clones checked out nothing and every surface asked git for
279// a branch that was not there (#189). A push that includes the default
280// branch itself needs nothing.
281func (s *Server) adoptDefaultBranch(repo *store.Repo, updates []policy.RefUpdate) {
282 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
283 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
284 return
285 }
286 for _, u := range updates {
287 branch, ok := cutHeads(u.Ref)
288 if !ok || u.IsDelete || !gitutil.ZeroSHA(u.Old) {
289 continue
290 }
291 if err := gitutil.SetHead(dir, branch); err != nil {
292 slog.Error("post-receive: moving HEAD", "repo", repo.Path(), "err", err)
293 return
294 }
295 if err := s.st.UpdateDefaultBranch(repo.ID, branch); err != nil {
296 slog.Error("post-receive: recording default branch", "repo", repo.Path(), "err", err)
297 return
298 }
299 repo.DefaultBranch = branch
300 return
301 }
302}
303
272304// queueBuilds queues the push jobs for a branch update. The work is
273305// shared with the merge path, which moves a ref without reaching a hook.
274306func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, old, sha string) {
internal/httpd/settings.go +2
@@ -68,6 +68,8 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
6868 argv = []string{"repo", "settings", "website", repo, v("website")}
6969 case "visibility":
7070 argv = []string{"repo", "settings", "visibility", repo, v("visibility")}
71 case "default-branch":
72 argv = []string{"repo", "settings", "default-branch", repo, v("default-branch")}
7173 case "git-daemon":
7274 argv = []string{"repo", "settings", "git-daemon", repo, onOff(v("git-daemon"))}
7375 case "require-checks":
internal/web/templates/settings.html +9 −1
@@ -17,7 +17,15 @@
1717 <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org">
1818 <button type="submit">Save</button>
1919</form>
20<form method="post" action="{{$base}}" class="setform">
20{{if .Branches}}<form method="post" action="{{$base}}" class="setform">
21 <input type="hidden" name="field" value="default-branch">
22 <label for="default-branch">Default branch</label>
23 <select id="default-branch" name="default-branch">
24 {{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}}
25 </select>
26 <button type="submit">Save</button>
27</form>
28{{end}}<form method="post" action="{{$base}}" class="setform">
2129 <input type="hidden" name="field" value="topics">
2230 <label for="topics-add">Topics to add</label>
2331 <input type="text" id="topics-add" name="add" placeholder="add, space-separated">