Skip to content

Commit 7e09d21

Browse files
committed
Initial version of protected branches (#776)
- Able to restrict force push and deletion - Able to restrict direct push
1 parent dab7682 commit 7e09d21

26 files changed

+524
-103
lines changed

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Gogs [![Build Status](https://travis-ci.org/gogits/gogs.svg?branch=master)](https://travis-ci.org/gogits/gogs) [![Build status](https://ci.appveyor.com/api/projects/status/b9uu5ejl933e2wlt/branch/master?svg=true)](https://ci.appveyor.com/project/Unknwon/gogs/branch/master) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/gogs/localized.svg)](https://crowdin.com/project/gogs) [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/gogits/gogs?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
1+
Gogs [![Build Status](https://travis-ci.org/gogits/gogs.svg?branch=master)](https://travis-ci.org/gogits/gogs) [![Build status](https://ci.appveyor.com/api/projects/status/b9uu5ejl933e2wlt/branch/master?svg=true)](https://ci.appveyor.com/project/Unknwon/gogs/branch/master) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/gogs/localized.svg)](https://crowdin.com/project/gogs) [![Sourcegraph](https://sourcegraph.com/github.com/gogits/gogs/-/badge.svg)](https://sourcegraph.com/github.com/gogits/gogs?badge) [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/gogits/gogs?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
22
=====================
33

44
![](https://github.com/gogits/gogs/blob/master/public/img/gogs-large-resize.png?raw=true)
@@ -43,7 +43,7 @@ The goal of this project is to make the easiest, fastest, and most painless way
4343
- Add/Remove repository collaborators
4444
- Repository/Organization webhooks (including Slack)
4545
- Repository Git hooks/deploy keys
46-
- Repository issues, pull requests and wiki
46+
- Repository issues, pull requests, wiki and protected branches
4747
- Migrate and mirror repository and its wiki
4848
- Web editor for repository files and wiki
4949
- Jupyter Notebook

README_ZH.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Gogs 的目标是打造一个最简单、最快速和最轻松的方式搭建自
2424
- 支持添加和删除仓库协作者
2525
- 支持仓库和组织级别 Web 钩子(包括 Slack 集成)
2626
- 支持仓库 Git 钩子和部署密钥
27-
- 支持仓库工单(Issue)、合并请求(Pull Request)以及 Wiki
27+
- 支持仓库工单(Issue)、合并请求(Pull Request)Wiki 和保护分支
2828
- 支持迁移和镜像仓库以及它的 Wiki
2929
- 支持在线编辑仓库文件和 Wiki
3030
- 支持自定义源的 Gravatar 和 Federated Avatar

cmd/hook.go

+47-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"bufio"
99
"bytes"
1010
"crypto/tls"
11+
"fmt"
1112
"os"
1213
"os/exec"
1314
"path/filepath"
@@ -64,13 +65,58 @@ func runHookPreReceive(c *cli.Context) error {
6465
if len(os.Getenv("SSH_ORIGINAL_COMMAND")) == 0 {
6566
return nil
6667
}
67-
setup(c, "hooks/pre-receive.log", false)
68+
setup(c, "hooks/pre-receive.log", true)
69+
70+
isWiki := strings.Contains(os.Getenv(http.ENV_REPO_CUSTOM_HOOKS_PATH), ".wiki.git/")
6871

6972
buf := bytes.NewBuffer(nil)
7073
scanner := bufio.NewScanner(os.Stdin)
7174
for scanner.Scan() {
7275
buf.Write(scanner.Bytes())
7376
buf.WriteByte('\n')
77+
78+
if isWiki {
79+
continue
80+
}
81+
82+
fields := bytes.Fields(scanner.Bytes())
83+
if len(fields) != 3 {
84+
continue
85+
}
86+
oldCommitID := string(fields[0])
87+
newCommitID := string(fields[1])
88+
branchName := strings.TrimPrefix(string(fields[2]), git.BRANCH_PREFIX)
89+
90+
// Branch protection
91+
repoID := com.StrTo(os.Getenv(http.ENV_REPO_ID)).MustInt64()
92+
protectBranch, err := models.GetProtectBranchOfRepoByName(repoID, branchName)
93+
if err != nil {
94+
if models.IsErrBranchNotExist(err) {
95+
continue
96+
}
97+
fail("Internal error", "GetProtectBranchOfRepoByName [repo_id: %d, branch: %s]: %v", repoID, branchName, err)
98+
}
99+
if !protectBranch.Protected {
100+
continue
101+
}
102+
103+
// Check if branch allows direct push
104+
if protectBranch.RequirePullRequest {
105+
fail(fmt.Sprintf("Branch '%s' is protected and commits must be merged through pull request", branchName), "")
106+
}
107+
108+
// check and deletion
109+
if newCommitID == git.EMPTY_SHA {
110+
fail(fmt.Sprintf("Branch '%s' is protected from deletion", branchName), "")
111+
}
112+
113+
// Check force push
114+
output, err := git.NewCommand("rev-list", oldCommitID, "^"+newCommitID).Run()
115+
if err != nil {
116+
fail("Internal error", "Fail to detect force push: %v", err)
117+
} else if len(output) > 0 {
118+
fail(fmt.Sprintf("Branch '%s' is protected from force push", branchName), "")
119+
}
74120
}
75121

76122
customHooksPath := filepath.Join(os.Getenv(http.ENV_REPO_CUSTOM_HOOKS_PATH), "pre-receive")

cmd/serv.go

+9-2
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ func runServ(c *cli.Context) error {
175175

176176
// Prohibit push to mirror repositories.
177177
if requestMode > models.ACCESS_MODE_READ && repo.IsMirror {
178-
fail("mirror repository is read-only", "")
178+
fail("Mirror repository is read-only", "")
179179
}
180180

181181
// Allow anonymous (user is nil) clone for public repositories.
@@ -251,7 +251,14 @@ func runServ(c *cli.Context) error {
251251
gitCmd = exec.Command(verb, repoFullName)
252252
}
253253
if requestMode == models.ACCESS_MODE_WRITE {
254-
gitCmd.Env = append(os.Environ(), http.ComposeHookEnvs(repo.RepoPath(), owner.Name, owner.Salt, repo.Name, user)...)
254+
gitCmd.Env = append(os.Environ(), http.ComposeHookEnvs(http.ComposeHookEnvsOptions{
255+
AuthUser: user,
256+
OwnerName: owner.Name,
257+
OwnerSalt: owner.Salt,
258+
RepoID: repo.ID,
259+
RepoName: repo.Name,
260+
RepoPath: repo.RepoPath(),
261+
})...)
255262
}
256263
gitCmd.Dir = setting.RepoRootPath
257264
gitCmd.Stdout = os.Stdout

cmd/web.go

+20-9
Original file line numberDiff line numberDiff line change
@@ -435,10 +435,21 @@ func runWeb(ctx *cli.Context) error {
435435
m.Combo("").Get(repo.Settings).
436436
Post(bindIgnErr(auth.RepoSettingForm{}), repo.SettingsPost)
437437
m.Group("/collaboration", func() {
438-
m.Combo("").Get(repo.Collaboration).Post(repo.CollaborationPost)
438+
m.Combo("").Get(repo.SettingsCollaboration).Post(repo.SettingsCollaborationPost)
439439
m.Post("/access_mode", repo.ChangeCollaborationAccessMode)
440440
m.Post("/delete", repo.DeleteCollaboration)
441441
})
442+
m.Group("/branches", func() {
443+
m.Get("", repo.SettingsBranches)
444+
m.Post("/default_branch", repo.UpdateDefaultBranch)
445+
m.Combo("/*").Get(repo.SettingsProtectedBranch).
446+
Post(bindIgnErr(auth.ProtectBranchForm{}), repo.SettingsProtectedBranchPost)
447+
}, func(ctx *context.Context) {
448+
if ctx.Repo.Repository.IsMirror {
449+
ctx.NotFound()
450+
return
451+
}
452+
})
442453

443454
m.Group("/hooks", func() {
444455
m.Get("", repo.Webhooks)
@@ -452,15 +463,15 @@ func runWeb(ctx *cli.Context) error {
452463
m.Post("/slack/:id", bindIgnErr(auth.NewSlackHookForm{}), repo.SlackHooksEditPost)
453464

454465
m.Group("/git", func() {
455-
m.Get("", repo.GitHooks)
456-
m.Combo("/:name").Get(repo.GitHooksEdit).
457-
Post(repo.GitHooksEditPost)
466+
m.Get("", repo.SettingsGitHooks)
467+
m.Combo("/:name").Get(repo.SettingsGitHooksEdit).
468+
Post(repo.SettingsGitHooksEditPost)
458469
}, context.GitHookService())
459470
})
460471

461472
m.Group("/keys", func() {
462-
m.Combo("").Get(repo.DeployKeys).
463-
Post(bindIgnErr(auth.AddSSHKeyForm{}), repo.DeployKeysPost)
473+
m.Combo("").Get(repo.SettingsDeployKeys).
474+
Post(bindIgnErr(auth.AddSSHKeyForm{}), repo.SettingsDeployKeysPost)
464475
m.Post("/delete", repo.DeleteDeployKey)
465476
})
466477

@@ -555,13 +566,13 @@ func runWeb(ctx *cli.Context) error {
555566
m.Post("/upload-remove", bindIgnErr(auth.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer)
556567
}, func(ctx *context.Context) {
557568
if !setting.Repository.Upload.Enabled {
558-
ctx.Handle(404, "", nil)
569+
ctx.NotFound()
559570
return
560571
}
561572
})
562573
}, reqRepoWriter, context.RepoRef(), func(ctx *context.Context) {
563-
if !ctx.Repo.Repository.CanEnableEditor() || ctx.Repo.IsViewCommit {
564-
ctx.Handle(404, "", nil)
574+
if !ctx.Repo.CanEnableEditor() {
575+
ctx.NotFound()
565576
return
566577
}
567578
})

conf/locale/locale_en-US.ini

+16
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,22 @@ settings.collaboration.admin = Admin
639639
settings.collaboration.write = Write
640640
settings.collaboration.read = Read
641641
settings.collaboration.undefined = Undefined
642+
settings.branches = Branches
643+
settings.default_branch = Default Branch
644+
settings.default_branch_desc = The default branch is considered the "base" branch for code commits, pull requests and online editing.
645+
settings.update = Update
646+
settings.update_default_branch_success = Default branch of this repository has been updated successfully!
647+
settings.protected_branches = Protected Branches
648+
settings.protected_branches_desc = Protect branches from force pushing, accidental deletion and whitelist code committers.
649+
settings.choose_a_branch = Choose a branch...
650+
settings.branch_protection = Branch Protection
651+
settings.branch_protection_desc = Please choose protect options for branch <b>%s</b>.
652+
settings.protect_this_branch = Protect this branch
653+
settings.protect_this_branch_desc = Disable force pushes and prevent from deletion.
654+
settings.protect_require_pull_request = Require pull request instead direct pushing
655+
settings.protect_require_pull_request_desc = Enable this option to disable direct pushing to this branch. Commits have to be pushed to another non-protected branch and merged to this branch through pull request.
656+
settings.protect_whitelist_committers = Whitelist who can push to this branch
657+
settings.protect_whitelist_committers_desc = Add people or teams to whitelist of direct push to this branch.
642658
settings.hooks = Webhooks
643659
settings.githooks = Git Hooks
644660
settings.basic_settings = Basic Settings

gogs.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
"github.com/gogits/gogs/modules/setting"
1717
)
1818

19-
const APP_VER = "0.9.153.0217"
19+
const APP_VER = "0.9.154.0217"
2020

2121
func init() {
2222
setting.AppVer = APP_VER

models/action.go

+1
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@ func CommitRepoAction(opts CommitRepoActionOptions) error {
460460
opType = ACTION_PUSH_TAG
461461
opts.Commits = &PushCommits{}
462462
} else {
463+
// TODO: detect branch deletion
463464
// if not the first commit, set the compare URL.
464465
if opts.OldCommitID == git.EMPTY_SHA {
465466
isNewBranch = true

models/models.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ func init() {
6565
new(Watch), new(Star), new(Follow), new(Action),
6666
new(Issue), new(PullRequest), new(Comment), new(Attachment), new(IssueUser),
6767
new(Label), new(IssueLabel), new(Milestone),
68-
new(Mirror), new(Release), new(LoginSource), new(Webhook),
69-
new(HookTask),
68+
new(Mirror), new(Release), new(LoginSource), new(Webhook), new(HookTask),
69+
new(ProtectBranch),
7070
new(Team), new(OrgUser), new(TeamUser), new(TeamRepo),
7171
new(Notice), new(EmailAddress))
7272

models/repo.go

+4
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,10 @@ func (repo *Repository) AllowsPulls() bool {
441441
return repo.CanEnablePulls() && repo.EnablePulls
442442
}
443443

444+
func (repo *Repository) IsBranchRequirePullRequest(name string) bool {
445+
return IsBranchOfRepoRequirePullRequest(repo.ID, name)
446+
}
447+
444448
// CanEnableEditor returns true if repository meets the requirements of web editor.
445449
func (repo *Repository) CanEnableEditor() bool {
446450
return !repo.IsMirror

models/repo_branch.go

+56-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
package models
66

77
import (
8+
"fmt"
9+
810
"github.com/gogits/git-module"
911
)
1012

@@ -36,7 +38,7 @@ func GetBranchesByPath(path string) ([]*Branch, error) {
3638

3739
func (repo *Repository) GetBranch(br string) (*Branch, error) {
3840
if !git.IsBranchExist(repo.RepoPath(), br) {
39-
return nil, &ErrBranchNotExist{br}
41+
return nil, ErrBranchNotExist{br}
4042
}
4143
return &Branch{
4244
Path: repo.RepoPath(),
@@ -55,3 +57,56 @@ func (br *Branch) GetCommit() (*git.Commit, error) {
5557
}
5658
return gitRepo.GetBranchCommit(br.Name)
5759
}
60+
61+
// ProtectBranch contains options of a protected branch.
62+
type ProtectBranch struct {
63+
ID int64
64+
RepoID int64 `xorm:"UNIQUE(protect_branch)"`
65+
Name string `xorm:"UNIQUE(protect_branch)"`
66+
Protected bool
67+
RequirePullRequest bool
68+
}
69+
70+
// GetProtectBranchOfRepoByName returns *ProtectBranch by branch name in given repostiory.
71+
func GetProtectBranchOfRepoByName(repoID int64, name string) (*ProtectBranch, error) {
72+
protectBranch := &ProtectBranch{
73+
RepoID: repoID,
74+
Name: name,
75+
}
76+
has, err := x.Get(protectBranch)
77+
if err != nil {
78+
return nil, err
79+
} else if !has {
80+
return nil, ErrBranchNotExist{name}
81+
}
82+
return protectBranch, nil
83+
}
84+
85+
// IsBranchOfRepoRequirePullRequest returns true if branch requires pull request in given repository.
86+
func IsBranchOfRepoRequirePullRequest(repoID int64, name string) bool {
87+
protectBranch, err := GetProtectBranchOfRepoByName(repoID, name)
88+
if err != nil {
89+
return false
90+
}
91+
return protectBranch.Protected && protectBranch.RequirePullRequest
92+
}
93+
94+
// UpdateProtectBranch saves branch protection options.
95+
// If ID is 0, it creates a new record. Otherwise, updates existing record.
96+
func UpdateProtectBranch(protectBranch *ProtectBranch) (err error) {
97+
if protectBranch.ID == 0 {
98+
if _, err = x.Insert(protectBranch); err != nil {
99+
return fmt.Errorf("Insert: %v", err)
100+
}
101+
return
102+
}
103+
104+
_, err = x.Id(protectBranch.ID).AllCols().Update(protectBranch)
105+
return err
106+
}
107+
108+
// GetProtectBranchesByRepoID returns a list of *ProtectBranch in given repostiory.
109+
func GetProtectBranchesByRepoID(repoID int64) ([]*ProtectBranch, error) {
110+
protectBranches := make([]*ProtectBranch, 0, 2)
111+
return protectBranches, x.Where("repo_id = ?", repoID).Asc("name").Find(&protectBranches)
112+
}

modules/auth/repo_form.go

+16
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,22 @@ func (f *RepoSettingForm) Validate(ctx *macaron.Context, errs binding.Errors) bi
106106
return validate(errs, ctx.Data, f, ctx.Locale)
107107
}
108108

109+
// __________ .__
110+
// \______ \____________ ____ ____ | |__
111+
// | | _/\_ __ \__ \ / \_/ ___\| | \
112+
// | | \ | | \// __ \| | \ \___| Y \
113+
// |______ / |__| (____ /___| /\___ >___| /
114+
// \/ \/ \/ \/ \/
115+
116+
type ProtectBranchForm struct {
117+
Protected bool
118+
RequirePullRequest bool
119+
}
120+
121+
func (f *ProtectBranchForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
122+
return validate(errs, ctx.Data, f, ctx.Locale)
123+
}
124+
109125
// __ __ ___. .__ .__ __
110126
// / \ / \ ____\_ |__ | |__ | |__ ____ | | __
111127
// \ \/\/ // __ \| __ \| | \| | \ / _ \| |/ /

modules/bindata/bindata.go

+3-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

modules/context/repo.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ func (r *Repository) HasAccess() bool {
7373

7474
// CanEnableEditor returns true if repository is editable and user has proper access level.
7575
func (r *Repository) CanEnableEditor() bool {
76-
return r.Repository.CanEnableEditor() && r.IsViewBranch && r.IsWriter()
76+
return r.Repository.CanEnableEditor() && r.IsViewBranch && r.IsWriter() && !r.Repository.IsBranchRequirePullRequest(r.BranchName)
7777
}
7878

7979
// GetEditorconfig returns the .editorconfig definition if found in the

public/css/gogs.css

+18-1
Original file line numberDiff line numberDiff line change
@@ -1225,7 +1225,6 @@ footer .ui.language .menu {
12251225
}
12261226
.repository.file.list #file-buttons {
12271227
font-weight: normal;
1228-
margin-top: -3px;
12291228
}
12301229
.repository.file.list #file-buttons .ui.button {
12311230
padding: 8px 10px;
@@ -2274,6 +2273,24 @@ footer .ui.language .menu {
22742273
margin-left: 5px;
22752274
margin-top: -3px;
22762275
}
2276+
.repository.settings.branches .protected-branches .selection.dropdown {
2277+
width: 300px;
2278+
}
2279+
.repository.settings.branches .protected-branches .item {
2280+
border: 1px solid #eaeaea;
2281+
padding: 10px 15px;
2282+
}
2283+
.repository.settings.branches .protected-branches .item:not(:last-child) {
2284+
border-bottom: 0;
2285+
}
2286+
.repository.settings.branches .branch-protection .help {
2287+
margin-left: 26px;
2288+
padding-top: 0;
2289+
}
2290+
.repository.settings.branches .branch-protection .fields {
2291+
margin-left: 20px;
2292+
display: block;
2293+
}
22772294
.repository.settings.webhook .events .column {
22782295
padding-bottom: 0;
22792296
}

public/js/gogs.js

+12
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,18 @@ function initRepository() {
341341
});
342342
}
343343

344+
// Branches
345+
if ($('.repository.settings.branches').length > 0) {
346+
initFilterSearchDropdown('.protected-branches .dropdown');
347+
$('.enable-protection').change(function () {
348+
if (this.checked) {
349+
$($(this).data('target')).removeClass('disabled');
350+
} else {
351+
$($(this).data('target')).addClass('disabled');
352+
}
353+
});
354+
}
355+
344356
// Labels
345357
if ($('.repository.labels').length > 0) {
346358
// Create label

0 commit comments

Comments
 (0)