Commit 3797906826
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/Parity.org +1
| @@ -154,6 +154,7 @@ always markdown. | ||
| 154 | 154 | | watch, unwatch | yes | yes | yes | |
| 155 | 155 | | mute | yes | no | yes | |
| 156 | 156 | | settings, protection | yes | yes | yes | |
| 157 | | default branch | yes | yes | no | | |
| 157 | 158 | | require codeowners | yes | yes | yes | |
| 158 | 159 | | access grants | yes | no | yes | |
| 159 | 160 | | webhooks | yes | no | yes | |
.gitbay/wiki/Users.org +1
| @@ -191,6 +191,7 @@ Access and settings (owner or =admin= grant): | ||
| 191 | 191 | gitbay repo access grant you/project alice write # read | write | admin |
| 192 | 192 | gitbay repo access revoke you/project alice |
| 193 | 193 | gitbay repo settings protect you/project main # no force-push, no delete |
| 194 | gitbay repo settings default-branch you/project trunk # HEAD, and what the web shows | |
| 194 | 195 | gitbay repo settings require-signed you/project on # every commit must verify |
| 195 | 196 | gitbay repo settings git-daemon you/project on # expose over git:// |
| 196 | 197 | gitbay 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 { | ||
| 471 | 471 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), |
| 472 | 472 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), |
| 473 | 473 | 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}), | |
| 474 | 475 | pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), |
| 475 | 476 | pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}), |
| 476 | 477 | 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 13 | func 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() { | ||
| 66 | 66 | register(Command{Path: []string{"repo", "settings", "website"}, |
| 67 | 67 | Summary: "set the repository website", |
| 68 | 68 | 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}) | |
| 69 | 72 | register(Command{Path: []string{"repo", "settings", "git-daemon"}, |
| 70 | 73 | Summary: "expose over git://", |
| 71 | 74 | Usage: "repo settings git-daemon <owner/name> on|off", Run: runGitDaemon}) |
| @@ -585,6 +588,30 @@ func runSetDescription(c *Ctx, args []string) int { | ||
| 585 | 588 | }) |
| 586 | 589 | } |
| 587 | 590 | |
| 591 | func 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 | ||
| 588 | 615 | func runSetWebsite(c *Ctx, args []string) int { |
| 589 | 616 | if len(args) != 2 { |
| 590 | 617 | 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) { | ||
| 187 | 187 | // place a hook writes outside its own repository. |
| 188 | 188 | func (s *Server) postReceive(req Request) { |
| 189 | 189 | pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID) |
| 190 | if pushedRepoErr == nil { | |
| 191 | s.adoptDefaultBranch(&pushedRepo, req.Updates) | |
| 192 | } | |
| 190 | 193 | for _, u := range req.Updates { |
| 191 | 194 | // Every ref update is an event webhooks can subscribe to. |
| 192 | 195 | s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf( |
| @@ -269,6 +272,35 @@ func (s *Server) postReceive(req Request) { | ||
| 269 | 272 | } |
| 270 | 273 | } |
| 271 | 274 | |
| 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. | |
| 281 | func (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 | ||
| 272 | 304 | // queueBuilds queues the push jobs for a branch update. The work is |
| 273 | 305 | // shared with the merge path, which moves a ref without reaching a hook. |
| 274 | 306 | func (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. | ||
| 68 | 68 | argv = []string{"repo", "settings", "website", repo, v("website")} |
| 69 | 69 | case "visibility": |
| 70 | 70 | argv = []string{"repo", "settings", "visibility", repo, v("visibility")} |
| 71 | case "default-branch": | |
| 72 | argv = []string{"repo", "settings", "default-branch", repo, v("default-branch")} | |
| 71 | 73 | case "git-daemon": |
| 72 | 74 | argv = []string{"repo", "settings", "git-daemon", repo, onOff(v("git-daemon"))} |
| 73 | 75 | case "require-checks": |
internal/web/templates/settings.html +9 −1
| @@ -17,7 +17,15 @@ | ||
| 17 | 17 | <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org"> |
| 18 | 18 | <button type="submit">Save</button> |
| 19 | 19 | </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"> | |
| 21 | 29 | <input type="hidden" name="field" value="topics"> |
| 22 | 30 | <label for="topics-add">Topics to add</label> |
| 23 | 31 | <input type="text" id="topics-add" name="add" placeholder="add, space-separated"> |