control, hookd, web, wiki: the default branch can be set, and follows the first push !337
8 files changed, +167 −1
Layout: unified · split
.gitbay/wiki/Parity.org +1
| @@ -154,6 +154,7 @@ always markdown. | |||
| 154 | | watch, unwatch | yes | yes | yes | | 154 | | watch, unwatch | yes | yes | yes | |
| 155 | | mute | yes | no | yes | | 155 | | mute | yes | no | yes | |
| 156 | | settings, protection | yes | yes | yes | | 156 | | settings, protection | yes | yes | yes | |
| 157 | | default branch | yes | yes | no | | ||
| 157 | | require codeowners | yes | yes | yes | | 158 | | require codeowners | yes | yes | yes | |
| 158 | | access grants | yes | no | yes | | 159 | | access grants | yes | no | yes | |
| 159 | | webhooks | yes | no | yes | | 160 | | webhooks | yes | no | yes | |
.gitbay/wiki/Users.org +1
| @@ -191,6 +191,7 @@ Access and settings (owner or =admin= grant): | |||
| 191 | gitbay repo access grant you/project alice write # read | write | admin | 191 | gitbay repo access grant you/project alice write # read | write | admin |
| 192 | gitbay repo access revoke you/project alice | 192 | gitbay repo access revoke you/project alice |
| 193 | gitbay repo settings protect you/project main # no force-push, no delete | 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 | gitbay repo settings require-signed you/project on # every commit must verify | 195 | gitbay repo settings require-signed you/project on # every commit must verify |
| 195 | gitbay repo settings git-daemon you/project on # expose over git:// | 196 | gitbay repo settings git-daemon you/project on # expose over git:// |
| 196 | gitbay repo topics add you/project cli forge # free-form tags, shown on the web | 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 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), | 471 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), |
| 472 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), | 472 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), |
| 473 | pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), | 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 | pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), | 475 | pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), |
| 475 | pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}), | 476 | pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}), |
| 476 | pass("require-codeowners", "require an owner's approval per covered file: on|off", passOpts{server: []string{"repo", "settings", "require-codeowners"}, needsRepo: true}), | 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 | register(Command{Path: []string{"repo", "settings", "website"}, | 66 | register(Command{Path: []string{"repo", "settings", "website"}, |
| 67 | Summary: "set the repository website", | 67 | Summary: "set the repository website", |
| 68 | Usage: "repo settings website <owner/name> <url> ('' clears)", Run: runSetWebsite}) | 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 | register(Command{Path: []string{"repo", "settings", "git-daemon"}, | 72 | register(Command{Path: []string{"repo", "settings", "git-daemon"}, |
| 70 | Summary: "expose over git://", | 73 | Summary: "expose over git://", |
| 71 | Usage: "repo settings git-daemon <owner/name> on|off", Run: runGitDaemon}) | 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 | func runSetWebsite(c *Ctx, args []string) int { | 615 | func runSetWebsite(c *Ctx, args []string) int { |
| 589 | if len(args) != 2 { | 616 | if len(args) != 2 { |
| 590 | return c.fail(protocol.ExitUsage, "usage: repo settings website <owner/name> <url>") | 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 | // place a hook writes outside its own repository. | 187 | // place a hook writes outside its own repository. |
| 188 | func (s *Server) postReceive(req Request) { | 188 | func (s *Server) postReceive(req Request) { |
| 189 | pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID) | 189 | pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID) |
| 190 | if pushedRepoErr == nil { | ||
| 191 | s.adoptDefaultBranch(&pushedRepo, req.Updates) | ||
| 192 | } | ||
| 190 | for _, u := range req.Updates { | 193 | for _, u := range req.Updates { |
| 191 | // Every ref update is an event webhooks can subscribe to. | 194 | // Every ref update is an event webhooks can subscribe to. |
| 192 | s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf( | 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 | // queueBuilds queues the push jobs for a branch update. The work is | 304 | // queueBuilds queues the push jobs for a branch update. The work is |
| 273 | // shared with the merge path, which moves a ref without reaching a hook. | 305 | // shared with the merge path, which moves a ref without reaching a hook. |
| 274 | func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, old, sha string) { | 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 | argv = []string{"repo", "settings", "website", repo, v("website")} | 68 | argv = []string{"repo", "settings", "website", repo, v("website")} |
| 69 | case "visibility": | 69 | case "visibility": |
| 70 | argv = []string{"repo", "settings", "visibility", repo, v("visibility")} | 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 | case "git-daemon": | 73 | case "git-daemon": |
| 72 | argv = []string{"repo", "settings", "git-daemon", repo, onOff(v("git-daemon"))} | 74 | argv = []string{"repo", "settings", "git-daemon", repo, onOff(v("git-daemon"))} |
| 73 | case "require-checks": | 75 | case "require-checks": |
internal/web/templates/settings.html +9 −1
| @@ -17,7 +17,15 @@ | |||
| 17 | <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org"> | 17 | <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org"> |
| 18 | <button type="submit">Save</button> | 18 | <button type="submit">Save</button> |
| 19 | </form> | 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 | <input type="hidden" name="field" value="topics"> | 29 | <input type="hidden" name="field" value="topics"> |
| 22 | <label for="topics-add">Topics to add</label> | 30 | <label for="topics-add">Topics to add</label> |
| 23 | <input type="text" id="topics-add" name="add" placeholder="add, space-separated"> | 31 | <input type="text" id="topics-add" name="add" placeholder="add, space-separated"> |