Procházet zdrojové kódy

Initial version of protected branches (#776)

- Able to restrict force push and deletion
- Able to restrict direct push
Unknwon před 8 roky
rodič
revize
7e09d210ba

+ 2 - 2
README.md

@@ -1,4 +1,4 @@
-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)
+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)
 =====================
 
 ![](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
 - Add/Remove repository collaborators
 - Repository/Organization webhooks (including Slack)
 - Repository Git hooks/deploy keys
-- Repository issues, pull requests and wiki
+- Repository issues, pull requests, wiki and protected branches
 - Migrate and mirror repository and its wiki
 - Web editor for repository files and wiki
 - Jupyter Notebook

+ 1 - 1
README_ZH.md

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

+ 47 - 1
cmd/hook.go

@@ -8,6 +8,7 @@ import (
 	"bufio"
 	"bytes"
 	"crypto/tls"
+	"fmt"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -64,13 +65,58 @@ func runHookPreReceive(c *cli.Context) error {
 	if len(os.Getenv("SSH_ORIGINAL_COMMAND")) == 0 {
 		return nil
 	}
-	setup(c, "hooks/pre-receive.log", false)
+	setup(c, "hooks/pre-receive.log", true)
+
+	isWiki := strings.Contains(os.Getenv(http.ENV_REPO_CUSTOM_HOOKS_PATH), ".wiki.git/")
 
 	buf := bytes.NewBuffer(nil)
 	scanner := bufio.NewScanner(os.Stdin)
 	for scanner.Scan() {
 		buf.Write(scanner.Bytes())
 		buf.WriteByte('\n')
+
+		if isWiki {
+			continue
+		}
+
+		fields := bytes.Fields(scanner.Bytes())
+		if len(fields) != 3 {
+			continue
+		}
+		oldCommitID := string(fields[0])
+		newCommitID := string(fields[1])
+		branchName := strings.TrimPrefix(string(fields[2]), git.BRANCH_PREFIX)
+
+		// Branch protection
+		repoID := com.StrTo(os.Getenv(http.ENV_REPO_ID)).MustInt64()
+		protectBranch, err := models.GetProtectBranchOfRepoByName(repoID, branchName)
+		if err != nil {
+			if models.IsErrBranchNotExist(err) {
+				continue
+			}
+			fail("Internal error", "GetProtectBranchOfRepoByName [repo_id: %d, branch: %s]: %v", repoID, branchName, err)
+		}
+		if !protectBranch.Protected {
+			continue
+		}
+
+		// Check if branch allows direct push
+		if protectBranch.RequirePullRequest {
+			fail(fmt.Sprintf("Branch '%s' is protected and commits must be merged through pull request", branchName), "")
+		}
+
+		// check and deletion
+		if newCommitID == git.EMPTY_SHA {
+			fail(fmt.Sprintf("Branch '%s' is protected from deletion", branchName), "")
+		}
+
+		// Check force push
+		output, err := git.NewCommand("rev-list", oldCommitID, "^"+newCommitID).Run()
+		if err != nil {
+			fail("Internal error", "Fail to detect force push: %v", err)
+		} else if len(output) > 0 {
+			fail(fmt.Sprintf("Branch '%s' is protected from force push", branchName), "")
+		}
 	}
 
 	customHooksPath := filepath.Join(os.Getenv(http.ENV_REPO_CUSTOM_HOOKS_PATH), "pre-receive")

+ 9 - 2
cmd/serv.go

@@ -175,7 +175,7 @@ func runServ(c *cli.Context) error {
 
 	// Prohibit push to mirror repositories.
 	if requestMode > models.ACCESS_MODE_READ && repo.IsMirror {
-		fail("mirror repository is read-only", "")
+		fail("Mirror repository is read-only", "")
 	}
 
 	// Allow anonymous (user is nil) clone for public repositories.
@@ -251,7 +251,14 @@ func runServ(c *cli.Context) error {
 		gitCmd = exec.Command(verb, repoFullName)
 	}
 	if requestMode == models.ACCESS_MODE_WRITE {
-		gitCmd.Env = append(os.Environ(), http.ComposeHookEnvs(repo.RepoPath(), owner.Name, owner.Salt, repo.Name, user)...)
+		gitCmd.Env = append(os.Environ(), http.ComposeHookEnvs(http.ComposeHookEnvsOptions{
+			AuthUser:  user,
+			OwnerName: owner.Name,
+			OwnerSalt: owner.Salt,
+			RepoID:    repo.ID,
+			RepoName:  repo.Name,
+			RepoPath:  repo.RepoPath(),
+		})...)
 	}
 	gitCmd.Dir = setting.RepoRootPath
 	gitCmd.Stdout = os.Stdout

+ 20 - 9
cmd/web.go

@@ -435,10 +435,21 @@ func runWeb(ctx *cli.Context) error {
 			m.Combo("").Get(repo.Settings).
 				Post(bindIgnErr(auth.RepoSettingForm{}), repo.SettingsPost)
 			m.Group("/collaboration", func() {
-				m.Combo("").Get(repo.Collaboration).Post(repo.CollaborationPost)
+				m.Combo("").Get(repo.SettingsCollaboration).Post(repo.SettingsCollaborationPost)
 				m.Post("/access_mode", repo.ChangeCollaborationAccessMode)
 				m.Post("/delete", repo.DeleteCollaboration)
 			})
+			m.Group("/branches", func() {
+				m.Get("", repo.SettingsBranches)
+				m.Post("/default_branch", repo.UpdateDefaultBranch)
+				m.Combo("/*").Get(repo.SettingsProtectedBranch).
+					Post(bindIgnErr(auth.ProtectBranchForm{}), repo.SettingsProtectedBranchPost)
+			}, func(ctx *context.Context) {
+				if ctx.Repo.Repository.IsMirror {
+					ctx.NotFound()
+					return
+				}
+			})
 
 			m.Group("/hooks", func() {
 				m.Get("", repo.Webhooks)
@@ -452,15 +463,15 @@ func runWeb(ctx *cli.Context) error {
 				m.Post("/slack/:id", bindIgnErr(auth.NewSlackHookForm{}), repo.SlackHooksEditPost)
 
 				m.Group("/git", func() {
-					m.Get("", repo.GitHooks)
-					m.Combo("/:name").Get(repo.GitHooksEdit).
-						Post(repo.GitHooksEditPost)
+					m.Get("", repo.SettingsGitHooks)
+					m.Combo("/:name").Get(repo.SettingsGitHooksEdit).
+						Post(repo.SettingsGitHooksEditPost)
 				}, context.GitHookService())
 			})
 
 			m.Group("/keys", func() {
-				m.Combo("").Get(repo.DeployKeys).
-					Post(bindIgnErr(auth.AddSSHKeyForm{}), repo.DeployKeysPost)
+				m.Combo("").Get(repo.SettingsDeployKeys).
+					Post(bindIgnErr(auth.AddSSHKeyForm{}), repo.SettingsDeployKeysPost)
 				m.Post("/delete", repo.DeleteDeployKey)
 			})
 
@@ -555,13 +566,13 @@ func runWeb(ctx *cli.Context) error {
 				m.Post("/upload-remove", bindIgnErr(auth.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer)
 			}, func(ctx *context.Context) {
 				if !setting.Repository.Upload.Enabled {
-					ctx.Handle(404, "", nil)
+					ctx.NotFound()
 					return
 				}
 			})
 		}, reqRepoWriter, context.RepoRef(), func(ctx *context.Context) {
-			if !ctx.Repo.Repository.CanEnableEditor() || ctx.Repo.IsViewCommit {
-				ctx.Handle(404, "", nil)
+			if !ctx.Repo.CanEnableEditor() {
+				ctx.NotFound()
 				return
 			}
 		})

+ 16 - 0
conf/locale/locale_en-US.ini

@@ -639,6 +639,22 @@ settings.collaboration.admin = Admin
 settings.collaboration.write = Write
 settings.collaboration.read = Read
 settings.collaboration.undefined = Undefined
+settings.branches = Branches
+settings.default_branch = Default Branch
+settings.default_branch_desc = The default branch is considered the "base" branch for code commits, pull requests and online editing.
+settings.update = Update
+settings.update_default_branch_success = Default branch of this repository has been updated successfully!
+settings.protected_branches = Protected Branches
+settings.protected_branches_desc = Protect branches from force pushing, accidental deletion and whitelist code committers.
+settings.choose_a_branch = Choose a branch...
+settings.branch_protection = Branch Protection
+settings.branch_protection_desc = Please choose protect options for branch <b>%s</b>.
+settings.protect_this_branch = Protect this branch
+settings.protect_this_branch_desc = Disable force pushes and prevent from deletion.
+settings.protect_require_pull_request = Require pull request instead direct pushing
+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.
+settings.protect_whitelist_committers = Whitelist who can push to this branch
+settings.protect_whitelist_committers_desc = Add people or teams to whitelist of direct push to this branch.
 settings.hooks = Webhooks
 settings.githooks = Git Hooks
 settings.basic_settings = Basic Settings

+ 1 - 1
gogs.go

@@ -16,7 +16,7 @@ import (
 	"github.com/gogits/gogs/modules/setting"
 )
 
-const APP_VER = "0.9.153.0217"
+const APP_VER = "0.9.154.0217"
 
 func init() {
 	setting.AppVer = APP_VER

+ 1 - 0
models/action.go

@@ -460,6 +460,7 @@ func CommitRepoAction(opts CommitRepoActionOptions) error {
 		opType = ACTION_PUSH_TAG
 		opts.Commits = &PushCommits{}
 	} else {
+		// TODO: detect branch deletion
 		// if not the first commit, set the compare URL.
 		if opts.OldCommitID == git.EMPTY_SHA {
 			isNewBranch = true

+ 2 - 2
models/models.go

@@ -65,8 +65,8 @@ func init() {
 		new(Watch), new(Star), new(Follow), new(Action),
 		new(Issue), new(PullRequest), new(Comment), new(Attachment), new(IssueUser),
 		new(Label), new(IssueLabel), new(Milestone),
-		new(Mirror), new(Release), new(LoginSource), new(Webhook),
-		new(HookTask),
+		new(Mirror), new(Release), new(LoginSource), new(Webhook), new(HookTask),
+		new(ProtectBranch),
 		new(Team), new(OrgUser), new(TeamUser), new(TeamRepo),
 		new(Notice), new(EmailAddress))
 

+ 4 - 0
models/repo.go

@@ -441,6 +441,10 @@ func (repo *Repository) AllowsPulls() bool {
 	return repo.CanEnablePulls() && repo.EnablePulls
 }
 
+func (repo *Repository) IsBranchRequirePullRequest(name string) bool {
+	return IsBranchOfRepoRequirePullRequest(repo.ID, name)
+}
+
 // CanEnableEditor returns true if repository meets the requirements of web editor.
 func (repo *Repository) CanEnableEditor() bool {
 	return !repo.IsMirror

+ 56 - 1
models/repo_branch.go

@@ -5,6 +5,8 @@
 package models
 
 import (
+	"fmt"
+
 	"github.com/gogits/git-module"
 )
 
@@ -36,7 +38,7 @@ func GetBranchesByPath(path string) ([]*Branch, error) {
 
 func (repo *Repository) GetBranch(br string) (*Branch, error) {
 	if !git.IsBranchExist(repo.RepoPath(), br) {
-		return nil, &ErrBranchNotExist{br}
+		return nil, ErrBranchNotExist{br}
 	}
 	return &Branch{
 		Path: repo.RepoPath(),
@@ -55,3 +57,56 @@ func (br *Branch) GetCommit() (*git.Commit, error) {
 	}
 	return gitRepo.GetBranchCommit(br.Name)
 }
+
+// ProtectBranch contains options of a protected branch.
+type ProtectBranch struct {
+	ID                 int64
+	RepoID             int64  `xorm:"UNIQUE(protect_branch)"`
+	Name               string `xorm:"UNIQUE(protect_branch)"`
+	Protected          bool
+	RequirePullRequest bool
+}
+
+// GetProtectBranchOfRepoByName returns *ProtectBranch by branch name in given repostiory.
+func GetProtectBranchOfRepoByName(repoID int64, name string) (*ProtectBranch, error) {
+	protectBranch := &ProtectBranch{
+		RepoID: repoID,
+		Name:   name,
+	}
+	has, err := x.Get(protectBranch)
+	if err != nil {
+		return nil, err
+	} else if !has {
+		return nil, ErrBranchNotExist{name}
+	}
+	return protectBranch, nil
+}
+
+// IsBranchOfRepoRequirePullRequest returns true if branch requires pull request in given repository.
+func IsBranchOfRepoRequirePullRequest(repoID int64, name string) bool {
+	protectBranch, err := GetProtectBranchOfRepoByName(repoID, name)
+	if err != nil {
+		return false
+	}
+	return protectBranch.Protected && protectBranch.RequirePullRequest
+}
+
+// UpdateProtectBranch saves branch protection options.
+// If ID is 0, it creates a new record. Otherwise, updates existing record.
+func UpdateProtectBranch(protectBranch *ProtectBranch) (err error) {
+	if protectBranch.ID == 0 {
+		if _, err = x.Insert(protectBranch); err != nil {
+			return fmt.Errorf("Insert: %v", err)
+		}
+		return
+	}
+
+	_, err = x.Id(protectBranch.ID).AllCols().Update(protectBranch)
+	return err
+}
+
+// GetProtectBranchesByRepoID returns a list of *ProtectBranch in given repostiory.
+func GetProtectBranchesByRepoID(repoID int64) ([]*ProtectBranch, error) {
+	protectBranches := make([]*ProtectBranch, 0, 2)
+	return protectBranches, x.Where("repo_id = ?", repoID).Asc("name").Find(&protectBranches)
+}

+ 16 - 0
modules/auth/repo_form.go

@@ -106,6 +106,22 @@ func (f *RepoSettingForm) Validate(ctx *macaron.Context, errs binding.Errors) bi
 	return validate(errs, ctx.Data, f, ctx.Locale)
 }
 
+// __________                             .__
+// \______   \____________    ____   ____ |  |__
+//  |    |  _/\_  __ \__  \  /    \_/ ___\|  |  \
+//  |    |   \ |  | \// __ \|   |  \  \___|   Y  \
+//  |______  / |__|  (____  /___|  /\___  >___|  /
+//         \/             \/     \/     \/     \/
+
+type ProtectBranchForm struct {
+	Protected          bool
+	RequirePullRequest bool
+}
+
+func (f *ProtectBranchForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
+	return validate(errs, ctx.Data, f, ctx.Locale)
+}
+
 //  __      __      ___.   .__    .__            __
 // /  \    /  \ ____\_ |__ |  |__ |  |__   ____ |  | __
 // \   \/\/   // __ \| __ \|  |  \|  |  \ /  _ \|  |/ /

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
modules/bindata/bindata.go


+ 1 - 1
modules/context/repo.go

@@ -73,7 +73,7 @@ func (r *Repository) HasAccess() bool {
 
 // CanEnableEditor returns true if repository is editable and user has proper access level.
 func (r *Repository) CanEnableEditor() bool {
-	return r.Repository.CanEnableEditor() && r.IsViewBranch && r.IsWriter()
+	return r.Repository.CanEnableEditor() && r.IsViewBranch && r.IsWriter() && !r.Repository.IsBranchRequirePullRequest(r.BranchName)
 }
 
 // GetEditorconfig returns the .editorconfig definition if found in the

+ 18 - 1
public/css/gogs.css

@@ -1225,7 +1225,6 @@ footer .ui.language .menu {
 }
 .repository.file.list #file-buttons {
   font-weight: normal;
-  margin-top: -3px;
 }
 .repository.file.list #file-buttons .ui.button {
   padding: 8px 10px;
@@ -2274,6 +2273,24 @@ footer .ui.language .menu {
   margin-left: 5px;
   margin-top: -3px;
 }
+.repository.settings.branches .protected-branches .selection.dropdown {
+  width: 300px;
+}
+.repository.settings.branches .protected-branches .item {
+  border: 1px solid #eaeaea;
+  padding: 10px 15px;
+}
+.repository.settings.branches .protected-branches .item:not(:last-child) {
+  border-bottom: 0;
+}
+.repository.settings.branches .branch-protection .help {
+  margin-left: 26px;
+  padding-top: 0;
+}
+.repository.settings.branches .branch-protection .fields {
+  margin-left: 20px;
+  display: block;
+}
 .repository.settings.webhook .events .column {
   padding-bottom: 0;
 }

+ 12 - 0
public/js/gogs.js

@@ -341,6 +341,18 @@ function initRepository() {
         });
     }
 
+    // Branches
+    if ($('.repository.settings.branches').length > 0) {
+        initFilterSearchDropdown('.protected-branches .dropdown');
+        $('.enable-protection').change(function () {
+            if (this.checked) {
+                $($(this).data('target')).removeClass('disabled');
+            } else {
+                $($(this).data('target')).addClass('disabled');
+            }
+        });
+    }
+
     // Labels
     if ($('.repository.labels').length > 0) {
         // Create label

+ 27 - 1
public/less/_repository.less

@@ -161,7 +161,7 @@
 		}
 		#file-buttons {
 			font-weight: normal;
-			margin-top: -3px;
+			
 			.ui.button {
 				padding: 8px 10px;
 				font-weight: normal;
@@ -1303,6 +1303,32 @@
 			}
 		}
 
+		&.branches {
+			.protected-branches {
+				.selection.dropdown {
+					width: 300px;
+				}
+				.item {
+			    border: 1px solid #eaeaea;
+			    padding: 10px 15px;
+
+			    &:not(:last-child) {
+				    border-bottom: 0;
+			    }
+				}
+			}
+			.branch-protection {
+				.help {
+					margin-left: 26px;
+					padding-top: 0;
+				}
+				.fields {
+					margin-left: 20px;
+					display: block;
+				}
+			}
+		}
+
 		&.webhook {
 			.events {
 				.column {

+ 36 - 14
routers/repo/http.go

@@ -28,18 +28,20 @@ import (
 )
 
 const (
-	ENV_AUTH_USER_ID           = "AUTH_USER_ID"
-	ENV_AUTH_USER_NAME         = "AUTH_USER_NAME"
-	ENV_REPO_OWNER_NAME        = "REPO_OWNER_NAME"
-	ENV_REPO_OWNER_SALT_MD5    = "REPO_OWNER_SALT_MD5"
-	ENV_REPO_NAME              = "REPO_NAME"
-	ENV_REPO_CUSTOM_HOOKS_PATH = "REPO_CUSTOM_HOOKS_PATH"
+	ENV_AUTH_USER_ID           = "GOGS_AUTH_USER_ID"
+	ENV_AUTH_USER_NAME         = "GOGS_AUTH_USER_NAME"
+	ENV_REPO_OWNER_NAME        = "GOGS_REPO_OWNER_NAME"
+	ENV_REPO_OWNER_SALT_MD5    = "GOGS_REPO_OWNER_SALT_MD5"
+	ENV_REPO_ID                = "GOGS_REPO_ID"
+	ENV_REPO_NAME              = "GOGS_REPO_NAME"
+	ENV_REPO_CUSTOM_HOOKS_PATH = "GOGS_REPO_CUSTOM_HOOKS_PATH"
 )
 
 type HTTPContext struct {
 	*context.Context
 	OwnerName string
 	OwnerSalt string
+	RepoID    int64
 	RepoName  string
 	AuthUser  *models.User
 }
@@ -143,6 +145,7 @@ func HTTPContexter() macaron.Handler {
 			Context:   ctx,
 			OwnerName: ownerName,
 			OwnerSalt: owner.Salt,
+			RepoID:    repo.ID,
 			RepoName:  repoName,
 			AuthUser:  authUser,
 		})
@@ -158,6 +161,7 @@ type serviceHandler struct {
 	authUser  *models.User
 	ownerName string
 	ownerSalt string
+	repoID    int64
 	repoName  string
 }
 
@@ -189,15 +193,25 @@ func (h *serviceHandler) sendFile(contentType string) {
 	http.ServeFile(h.w, h.r, reqFile)
 }
 
-func ComposeHookEnvs(repoPath, ownerName, ownerSalt, repoName string, authUser *models.User) []string {
+type ComposeHookEnvsOptions struct {
+	AuthUser  *models.User
+	OwnerName string
+	OwnerSalt string
+	RepoID    int64
+	RepoName  string
+	RepoPath  string
+}
+
+func ComposeHookEnvs(opts ComposeHookEnvsOptions) []string {
 	envs := []string{
 		"SSH_ORIGINAL_COMMAND=1",
-		ENV_AUTH_USER_ID + "=" + com.ToStr(authUser.ID),
-		ENV_AUTH_USER_NAME + "=" + authUser.Name,
-		ENV_REPO_OWNER_NAME + "=" + ownerName,
-		ENV_REPO_OWNER_SALT_MD5 + "=" + base.EncodeMD5(ownerSalt),
-		ENV_REPO_NAME + "=" + repoName,
-		ENV_REPO_CUSTOM_HOOKS_PATH + "=" + path.Join(repoPath, "custom_hooks"),
+		ENV_AUTH_USER_ID + "=" + com.ToStr(opts.AuthUser.ID),
+		ENV_AUTH_USER_NAME + "=" + opts.AuthUser.Name,
+		ENV_REPO_OWNER_NAME + "=" + opts.OwnerName,
+		ENV_REPO_OWNER_SALT_MD5 + "=" + base.EncodeMD5(opts.OwnerSalt),
+		ENV_REPO_ID + "=" + com.ToStr(opts.RepoID),
+		ENV_REPO_NAME + "=" + opts.RepoName,
+		ENV_REPO_CUSTOM_HOOKS_PATH + "=" + path.Join(opts.RepoPath, "custom_hooks"),
 	}
 	return envs
 }
@@ -229,7 +243,14 @@ func serviceRPC(h serviceHandler, service string) {
 	var stderr bytes.Buffer
 	cmd := exec.Command("git", service, "--stateless-rpc", h.dir)
 	if service == "receive-pack" {
-		cmd.Env = append(os.Environ(), ComposeHookEnvs(h.dir, h.ownerName, h.ownerSalt, h.repoName, h.authUser)...)
+		cmd.Env = append(os.Environ(), ComposeHookEnvs(ComposeHookEnvsOptions{
+			AuthUser:  h.authUser,
+			OwnerName: h.ownerName,
+			OwnerSalt: h.ownerSalt,
+			RepoID:    h.repoID,
+			RepoName:  h.repoName,
+			RepoPath:  h.dir,
+		})...)
 	}
 	cmd.Dir = h.dir
 	cmd.Stdout = h.w
@@ -392,6 +413,7 @@ func HTTP(ctx *HTTPContext) {
 			authUser:  ctx.AuthUser,
 			ownerName: ctx.OwnerName,
 			ownerSalt: ctx.OwnerSalt,
+			repoID:    ctx.RepoID,
 			repoName:  ctx.RepoName,
 		})
 		return

+ 16 - 0
routers/repo/pull.go

@@ -711,6 +711,22 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm)
 	ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(pullIssue.Index))
 }
 
+func parseOwnerAndRepo(ctx *context.Context) (*models.User, *models.Repository) {
+	owner, err := models.GetUserByName(ctx.Params(":username"))
+	if err != nil {
+		ctx.NotFoundOrServerError("GetUserByName", models.IsErrUserNotExist, err)
+		return nil, nil
+	}
+
+	repo, err := models.GetRepositoryByName(owner.ID, ctx.Params(":reponame"))
+	if err != nil {
+		ctx.NotFoundOrServerError("GetRepositoryByName", models.IsErrRepoNotExist, err)
+		return nil, nil
+	}
+
+	return owner, repo
+}
+
 func TriggerTask(ctx *context.Context) {
 	pusherID := ctx.QueryInt64("pusher")
 	branch := ctx.Query("branch")

+ 107 - 43
routers/repo/setting.go

@@ -5,6 +5,7 @@
 package repo
 
 import (
+	"fmt"
 	"strings"
 	"time"
 
@@ -21,11 +22,13 @@ import (
 )
 
 const (
-	SETTINGS_OPTIONS base.TplName = "repo/settings/options"
-	COLLABORATION    base.TplName = "repo/settings/collaboration"
-	GITHOOKS         base.TplName = "repo/settings/githooks"
-	GITHOOK_EDIT     base.TplName = "repo/settings/githook_edit"
-	DEPLOY_KEYS      base.TplName = "repo/settings/deploy_keys"
+	SETTINGS_OPTIONS          base.TplName = "repo/settings/options"
+	SETTINGS_COLLABORATION    base.TplName = "repo/settings/collaboration"
+	SETTINGS_BRANCHES         base.TplName = "repo/settings/branches"
+	SETTINGS_PROTECTED_BRANCH base.TplName = "repo/settings/protected_branch"
+	SETTINGS_GITHOOKS         base.TplName = "repo/settings/githooks"
+	SETTINGS_GITHOOK_EDIT     base.TplName = "repo/settings/githook_edit"
+	SETTINGS_DEPLOY_KEYS      base.TplName = "repo/settings/deploy_keys"
 )
 
 func Settings(ctx *context.Context) {
@@ -74,16 +77,6 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) {
 		repo.Name = newRepoName
 		repo.LowerName = strings.ToLower(newRepoName)
 
-		if ctx.Repo.GitRepo.IsBranchExist(form.Branch) &&
-			repo.DefaultBranch != form.Branch {
-			repo.DefaultBranch = form.Branch
-			if err := ctx.Repo.GitRepo.SetDefaultBranch(form.Branch); err != nil {
-				if !git.IsErrUnsupportedVersion(err) {
-					ctx.Handle(500, "SetDefaultBranch", err)
-					return
-				}
-			}
-		}
 		repo.Description = form.Description
 		repo.Website = form.Website
 
@@ -295,7 +288,7 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) {
 	}
 }
 
-func Collaboration(ctx *context.Context) {
+func SettingsCollaboration(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.settings")
 	ctx.Data["PageIsSettingsCollaboration"] = true
 
@@ -306,10 +299,10 @@ func Collaboration(ctx *context.Context) {
 	}
 	ctx.Data["Collaborators"] = users
 
-	ctx.HTML(200, COLLABORATION)
+	ctx.HTML(200, SETTINGS_COLLABORATION)
 }
 
-func CollaborationPost(ctx *context.Context) {
+func SettingsCollaborationPost(ctx *context.Context) {
 	name := strings.ToLower(ctx.Query("collaborator"))
 	if len(name) == 0 || ctx.Repo.Owner.LowerName == name {
 		ctx.Redirect(setting.AppSubUrl + ctx.Req.URL.Path)
@@ -374,31 +367,102 @@ func DeleteCollaboration(ctx *context.Context) {
 	})
 }
 
-func parseOwnerAndRepo(ctx *context.Context) (*models.User, *models.Repository) {
-	owner, err := models.GetUserByName(ctx.Params(":username"))
+func SettingsBranches(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("repo.settings.branches")
+	ctx.Data["PageIsSettingsBranches"] = true
+
+	protectBranches, err := models.GetProtectBranchesByRepoID(ctx.Repo.Repository.ID)
 	if err != nil {
-		if models.IsErrUserNotExist(err) {
-			ctx.Handle(404, "GetUserByName", nil)
-		} else {
-			ctx.Handle(500, "GetUserByName", err)
+		ctx.Handle(500, "GetProtectBranchesByRepoID", err)
+		return
+	}
+	ctx.Data["ProtectBranches"] = protectBranches
+
+	ctx.HTML(200, SETTINGS_BRANCHES)
+}
+
+func UpdateDefaultBranch(ctx *context.Context) {
+	branch := ctx.Query("branch")
+	if ctx.Repo.GitRepo.IsBranchExist(branch) &&
+		ctx.Repo.Repository.DefaultBranch != branch {
+		ctx.Repo.Repository.DefaultBranch = branch
+		if err := ctx.Repo.GitRepo.SetDefaultBranch(branch); err != nil {
+			if !git.IsErrUnsupportedVersion(err) {
+				ctx.Handle(500, "SetDefaultBranch", err)
+				return
+			}
+		}
+	}
+
+	if err := models.UpdateRepository(ctx.Repo.Repository, false); err != nil {
+		ctx.Handle(500, "UpdateRepository", err)
+		return
+	}
+
+	ctx.Flash.Success(ctx.Tr("repo.settings.update_default_branch_success"))
+	ctx.Redirect(ctx.Repo.RepoLink + "/settings/branches")
+}
+
+func SettingsProtectedBranch(ctx *context.Context) {
+	branch := ctx.Params("*")
+	if !ctx.Repo.GitRepo.IsBranchExist(branch) {
+		ctx.NotFound()
+		return
+	}
+
+	ctx.Data["Title"] = ctx.Tr("repo.settings.protected_branches") + " - " + branch
+	ctx.Data["PageIsSettingsBranches"] = true
+	ctx.Data["IsOrgRepo"] = ctx.Repo.Owner.IsOrganization()
+
+	protectBranch, err := models.GetProtectBranchOfRepoByName(ctx.Repo.Repository.ID, branch)
+	if err != nil {
+		if !models.IsErrBranchNotExist(err) {
+			ctx.Handle(500, "GetProtectBranchOfRepoByName", err)
+			return
+		}
+
+		// No options found, create defaults.
+		protectBranch = &models.ProtectBranch{
+			Name: branch,
 		}
-		return nil, nil
 	}
 
-	repo, err := models.GetRepositoryByName(owner.ID, ctx.Params(":reponame"))
+	ctx.Data["Branch"] = protectBranch
+	ctx.HTML(200, SETTINGS_PROTECTED_BRANCH)
+}
+
+func SettingsProtectedBranchPost(ctx *context.Context, form auth.ProtectBranchForm) {
+	branch := ctx.Params("*")
+	if !ctx.Repo.GitRepo.IsBranchExist(branch) {
+		ctx.NotFound()
+		return
+	}
+
+	protectBranch, err := models.GetProtectBranchOfRepoByName(ctx.Repo.Repository.ID, branch)
 	if err != nil {
-		if models.IsErrRepoNotExist(err) {
-			ctx.Handle(404, "GetRepositoryByName", nil)
-		} else {
-			ctx.Handle(500, "GetRepositoryByName", err)
+		if !models.IsErrBranchNotExist(err) {
+			ctx.Handle(500, "GetProtectBranchOfRepoByName", err)
+			return
 		}
-		return nil, nil
+
+		// No options found, create defaults.
+		protectBranch = &models.ProtectBranch{
+			RepoID: ctx.Repo.Repository.ID,
+			Name:   branch,
+		}
+	}
+
+	protectBranch.Protected = form.Protected
+	protectBranch.RequirePullRequest = form.RequirePullRequest
+	if err = models.UpdateProtectBranch(protectBranch); err != nil {
+		ctx.Handle(500, "UpdateProtectBranch", err)
+		return
 	}
 
-	return owner, repo
+	ctx.Redirect(fmt.Sprintf("%s/settings/branches/%s", ctx.Repo.RepoLink, branch))
 }
 
-func GitHooks(ctx *context.Context) {
+func SettingsGitHooks(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.settings.githooks")
 	ctx.Data["PageIsSettingsGitHooks"] = true
 
@@ -409,10 +473,10 @@ func GitHooks(ctx *context.Context) {
 	}
 	ctx.Data["Hooks"] = hooks
 
-	ctx.HTML(200, GITHOOKS)
+	ctx.HTML(200, SETTINGS_GITHOOKS)
 }
 
-func GitHooksEdit(ctx *context.Context) {
+func SettingsGitHooksEdit(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.settings.githooks")
 	ctx.Data["PageIsSettingsGitHooks"] = true
 
@@ -427,10 +491,10 @@ func GitHooksEdit(ctx *context.Context) {
 		return
 	}
 	ctx.Data["Hook"] = hook
-	ctx.HTML(200, GITHOOK_EDIT)
+	ctx.HTML(200, SETTINGS_GITHOOK_EDIT)
 }
 
-func GitHooksEditPost(ctx *context.Context) {
+func SettingsGitHooksEditPost(ctx *context.Context) {
 	name := ctx.Params(":name")
 	hook, err := ctx.Repo.GitRepo.GetHook(name)
 	if err != nil {
@@ -449,7 +513,7 @@ func GitHooksEditPost(ctx *context.Context) {
 	ctx.Redirect(ctx.Repo.RepoLink + "/settings/hooks/git")
 }
 
-func DeployKeys(ctx *context.Context) {
+func SettingsDeployKeys(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys")
 	ctx.Data["PageIsSettingsKeys"] = true
 
@@ -460,10 +524,10 @@ func DeployKeys(ctx *context.Context) {
 	}
 	ctx.Data["Deploykeys"] = keys
 
-	ctx.HTML(200, DEPLOY_KEYS)
+	ctx.HTML(200, SETTINGS_DEPLOY_KEYS)
 }
 
-func DeployKeysPost(ctx *context.Context, form auth.AddSSHKeyForm) {
+func SettingsDeployKeysPost(ctx *context.Context, form auth.AddSSHKeyForm) {
 	ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys")
 	ctx.Data["PageIsSettingsKeys"] = true
 
@@ -475,7 +539,7 @@ func DeployKeysPost(ctx *context.Context, form auth.AddSSHKeyForm) {
 	ctx.Data["Deploykeys"] = keys
 
 	if ctx.HasError() {
-		ctx.HTML(200, DEPLOY_KEYS)
+		ctx.HTML(200, SETTINGS_DEPLOY_KEYS)
 		return
 	}
 
@@ -498,10 +562,10 @@ func DeployKeysPost(ctx *context.Context, form auth.AddSSHKeyForm) {
 		switch {
 		case models.IsErrKeyAlreadyExist(err):
 			ctx.Data["Err_Content"] = true
-			ctx.RenderWithErr(ctx.Tr("repo.settings.key_been_used"), DEPLOY_KEYS, &form)
+			ctx.RenderWithErr(ctx.Tr("repo.settings.key_been_used"), SETTINGS_DEPLOY_KEYS, &form)
 		case models.IsErrKeyNameAlreadyUsed(err):
 			ctx.Data["Err_Title"] = true
-			ctx.RenderWithErr(ctx.Tr("repo.settings.key_name_used"), DEPLOY_KEYS, &form)
+			ctx.RenderWithErr(ctx.Tr("repo.settings.key_name_used"), SETTINGS_DEPLOY_KEYS, &form)
 		default:
 			ctx.Handle(500, "AddDeployKey", err)
 		}

+ 8 - 5
routers/repo/view.go

@@ -108,8 +108,7 @@ func renderDirectory(ctx *context.Context, treeLink string) {
 	ctx.Data["LatestCommit"] = latestCommit
 	ctx.Data["LatestCommitUser"] = models.ValidateCommitWithEmail(latestCommit)
 
-	// Check permission to add or upload new file.
-	if ctx.Repo.IsWriter() && ctx.Repo.IsViewBranch {
+	if ctx.Repo.CanEnableEditor() {
 		ctx.Data["CanAddFile"] = true
 		ctx.Data["CanUploadFile"] = setting.Repository.Upload.Enabled
 	}
@@ -142,6 +141,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
 		ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_non_text_files")
 	}
 
+	canEnableEditor := ctx.Repo.CanEnableEditor()
 	switch {
 	case isTextFile:
 		if blob.Size() >= setting.UI.MaxDisplayFileSize {
@@ -186,7 +186,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
 			ctx.Data["LineNums"] = gotemplate.HTML(output.String())
 		}
 
-		if ctx.Repo.CanEnableEditor() {
+		if canEnableEditor {
 			ctx.Data["CanEditFile"] = true
 			ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file")
 		} else if !ctx.Repo.IsViewBranch {
@@ -203,7 +203,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
 		ctx.Data["IsImageFile"] = true
 	}
 
-	if ctx.Repo.CanEnableEditor() {
+	if canEnableEditor {
 		ctx.Data["CanDeleteFile"] = true
 		ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.delete_this_file")
 	} else if !ctx.Repo.IsViewBranch {
@@ -216,7 +216,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
 func setEditorconfigIfExists(ctx *context.Context) {
 	ec, err := ctx.Repo.GetEditorconfig()
 	if err != nil && !git.IsErrNotExist(err) {
-		log.Error(4, "Fail to get '.editorconfig' [%d]: %v", ctx.Repo.Repository.ID, err)
+		log.Trace("setEditorconfigIfExists.GetEditorconfig [%d]: %v", ctx.Repo.Repository.ID, err)
 		return
 	}
 	ctx.Data["Editorconfig"] = ec
@@ -228,6 +228,9 @@ func Home(ctx *context.Context) {
 		title += ": " + ctx.Repo.Repository.Description
 	}
 	ctx.Data["Title"] = title
+	if ctx.Repo.BranchName != ctx.Repo.Repository.DefaultBranch {
+		ctx.Data["Title"] = title + " @ " + ctx.Repo.BranchName
+	}
 	ctx.Data["PageIsViewCode"] = true
 	ctx.Data["RequireHighlightJS"] = true
 

+ 1 - 1
templates/.VERSION

@@ -1 +1 @@
-0.9.153.0217
+0.9.154.0217

+ 62 - 0
templates/repo/settings/branches.tmpl

@@ -0,0 +1,62 @@
+{{template "base/head" .}}
+<div class="repository settings branches">
+	{{template "repo/header" .}}
+	<div class="ui container">
+		<div class="ui grid">
+			{{template "repo/settings/navbar" .}}
+			<div class="twelve wide column content">
+				{{template "base/alert" .}}
+				<h4 class="ui top attached header">
+					{{.i18n.Tr "repo.settings.default_branch"}}
+				</h4>
+				<div class="ui attached segment default-branch">
+					<p>{{.i18n.Tr "repo.settings.default_branch_desc"}}</p>
+					<form class="ui form" action="{{.Link}}/default_branch" method="post">
+						{{.CsrfTokenHtml}}
+						<div class="required inline field {{if .Repository.IsBare}}disabled{{end}}">
+							<div class="ui selection dropdown">
+								<input type="hidden" id="branch" name="branch" value="{{.Repository.DefaultBranch}}">
+								<div class="text">{{.Repository.DefaultBranch}}</div>
+								<i class="dropdown icon"></i>
+								<div class="menu">
+									{{range .Branches}}
+										<div class="item" data-value="{{.}}">{{.}}</div>
+									{{end}}
+								</div>
+							</div>
+							<button class="ui green button">{{$.i18n.Tr "repo.settings.update"}}</button>
+						</div>
+					</form>
+				</div>
+
+				<h4 class="ui top attached header">
+					{{.i18n.Tr "repo.settings.protected_branches"}}
+				</h4>
+				<div class="ui attached segment protected-branches">
+					<p>{{.i18n.Tr "repo.settings.protected_branches_desc"}}</p>
+					<div class="ui form">
+						<div class="required inline field {{if .Repository.IsBare}}disabled{{end}}">
+							<div class="ui selection dropdown">
+								<div class="text">{{.i18n.Tr "repo.settings.choose_a_branch"}}</div>
+								<i class="dropdown icon"></i>
+								<div class="menu">
+									{{range .Branches}}
+										<div class="item" data-url="{{$.Link}}/{{.}}">{{.}}</div>
+									{{end}}
+								</div>
+							</div>
+						</div>
+					</div>
+					<div class="ui protected-branches list">
+						{{range .ProtectBranches}}
+							<div class="item">
+								<a href="{{$.Link}}/{{.Name}}"><code>{{.Name}}</code></a>
+							</div>
+						{{end}}
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+{{template "base/footer" .}}

+ 5 - 0
templates/repo/settings/navbar.tmpl

@@ -7,6 +7,11 @@
 		<a class="{{if .PageIsSettingsCollaboration}}active{{end}} item" href="{{.RepoLink}}/settings/collaboration">
 			{{.i18n.Tr "repo.settings.collaboration"}}
 		</a>
+		{{if not .Repository.IsMirror}}
+		<a class="{{if .PageIsSettingsBranches}}active{{end}} item" href="{{.RepoLink}}/settings/branches">
+			{{.i18n.Tr "repo.settings.branches"}}
+		</a>
+		{{end}}
 		<a class="{{if .PageIsSettingsHooks}}active{{end}} item" href="{{.RepoLink}}/settings/hooks">
 			{{.i18n.Tr "repo.settings.hooks"}}
 		</a>

+ 0 - 15
templates/repo/settings/options.tmpl

@@ -26,21 +26,6 @@
 							<input id="website" name="website" type="url" value="{{.Repository.Website}}">
 						</div>
 
-						{{if not .Repository.IsBare}}
-							<div class="required inline field">
-								<label>{{.i18n.Tr "repo.default_branch"}}</label>
-								<div class="ui selection dropdown">
-									<input type="hidden" id="branch" name="branch" value="{{.Repository.DefaultBranch}}">
-									<div class="text">{{.Repository.DefaultBranch}}</div>
-									<i class="dropdown icon"></i>
-									<div class="menu">
-										{{range .Branches}}
-											<div class="item" data-value="{{.}}">{{.}}</div>
-										{{end}}
-									</div>
-								</div>
-							</div>
-						{{end}}
 						{{if not .Repository.IsFork}}
 							<div class="inline field">
 								<label>{{.i18n.Tr "repo.visibility"}}</label>

+ 53 - 0
templates/repo/settings/protected_branch.tmpl

@@ -0,0 +1,53 @@
+{{template "base/head" .}}
+<div class="repository settings branches">
+	{{template "repo/header" .}}
+	<div class="ui container">
+		<div class="ui grid">
+			{{template "repo/settings/navbar" .}}
+			<div class="twelve wide column content">
+				{{template "base/alert" .}}
+				<h4 class="ui top attached header">
+					{{.i18n.Tr "repo.settings.branch_protection"}}
+				</h4>
+				<div class="ui attached segment branch-protection">
+					<p>{{.i18n.Tr "repo.settings.branch_protection_desc" .Branch.Name | Str2html}}</p>
+					<form class="ui form" action="{{.Link}}" method="post">
+						{{.CsrfTokenHtml}}
+						<div class="inline field">
+							<div class="ui checkbox">
+								<input class="enable-protection" name="protected" type="checkbox" data-target="#protection_box" {{if .Branch.Protected}}checked{{end}}>
+								<label>{{.i18n.Tr "repo.settings.protect_this_branch"}}</label>
+								<p class="help">{{.i18n.Tr "repo.settings.protect_this_branch_desc"}}</p>
+							</div>
+						</div>
+						<div id="protection_box" class="fields {{if not .Branch.Protected}}disabled{{end}}">
+							<div class="field">
+								<div class="ui checkbox">
+									<input name="require_pull_request" type="checkbox" {{if .Branch.RequirePullRequest}}checked{{end}}>
+									<label>{{.i18n.Tr "repo.settings.protect_require_pull_request"}}</label>
+									<p class="help">{{.i18n.Tr "repo.settings.protect_require_pull_request_desc"}}</p>
+								</div>
+							</div>
+							{{if .IsOrgRepo}}
+								<div class="field">
+									<div class="ui checkbox">
+										<input name="whitelist_committers" type="checkbox" data-target="#whitelist_box">
+										<label>{{.i18n.Tr "repo.settings.protect_whitelist_committers"}}</label>
+										<p class="help">{{.i18n.Tr "repo.settings.protect_whitelist_committers_desc"}}</p>
+									</div>
+								</div>
+							{{end}}
+						</div>
+
+						<div class="ui divider"></div>
+
+						<div class="field">
+							<button class="ui green button">{{$.i18n.Tr "repo.settings.update_settings"}}</button>
+						</div>
+					</form>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+{{template "base/footer" .}}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů