diff --git a/integrations/rename_branch_test.go b/integrations/rename_branch_test.go new file mode 100644 index 000000000..90c1f4d15 --- /dev/null +++ b/integrations/rename_branch_test.go @@ -0,0 +1,44 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + "github.com/stretchr/testify/assert" +) + +func TestRenameBranch(t *testing.T) { + // get branch setting page + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user2/repo1/settings/branches") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + postData := map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "from": "master", + "to": "main", + } + req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/rename_branch", postData) + session.MakeRequest(t, req, http.StatusFound) + + // check new branch link + req = NewRequestWithValues(t, "GET", "/user2/repo1/src/branch/main/README.md", postData) + session.MakeRequest(t, req, http.StatusOK) + + // check old branch link + req = NewRequestWithValues(t, "GET", "/user2/repo1/src/branch/master/README.md", postData) + resp = session.MakeRequest(t, req, http.StatusFound) + location := resp.HeaderMap.Get("Location") + assert.Equal(t, "/user2/repo1/src/branch/main/README.md", location) + + // check db + repo1 := db.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) + assert.Equal(t, "main", repo1.DefaultBranch) +} diff --git a/models/branches.go b/models/branches.go index 8eaa4b6fd..3c62c7a87 100644 --- a/models/branches.go +++ b/models/branches.go @@ -53,6 +53,7 @@ type ProtectedBranch struct { func init() { db.RegisterModel(new(ProtectedBranch)) db.RegisterModel(new(DeletedBranch)) + db.RegisterModel(new(RenamedBranch)) } // IsProtected returns if the branch is protected @@ -588,3 +589,83 @@ func RemoveOldDeletedBranches(ctx context.Context, olderThan time.Duration) { log.Error("DeletedBranchesCleanup: %v", err) } } + +// RenamedBranch provide renamed branch log +// will check it when a branch can't be found +type RenamedBranch struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + From string + To string + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +// FindRenamedBranch check if a branch was renamed +func FindRenamedBranch(repoID int64, from string) (branch *RenamedBranch, exist bool, err error) { + branch = &RenamedBranch{ + RepoID: repoID, + From: from, + } + exist, err = db.GetEngine(db.DefaultContext).Get(branch) + + return +} + +// RenameBranch rename a branch +func (repo *Repository) RenameBranch(from, to string, gitAction func(isDefault bool) error) (err error) { + sess := db.NewSession(db.DefaultContext) + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + // 1. update default branch if needed + isDefault := repo.DefaultBranch == from + if isDefault { + repo.DefaultBranch = to + _, err = sess.ID(repo.ID).Cols("default_branch").Update(repo) + if err != nil { + return err + } + } + + // 2. Update protected branch if needed + protectedBranch, err := getProtectedBranchBy(sess, repo.ID, from) + if err != nil { + return err + } + + if protectedBranch != nil { + protectedBranch.BranchName = to + _, err = sess.ID(protectedBranch.ID).Cols("branch_name").Update(protectedBranch) + if err != nil { + return err + } + } + + // 3. Update all not merged pull request base branch name + _, err = sess.Table(new(PullRequest)).Where("base_repo_id=? AND base_branch=? AND has_merged=?", + repo.ID, from, false). + Update(map[string]interface{}{"base_branch": to}) + if err != nil { + return err + } + + // 4. do git action + if err = gitAction(isDefault); err != nil { + return err + } + + // 5. insert renamed branch record + renamedBranch := &RenamedBranch{ + RepoID: repo.ID, + From: from, + To: to, + } + _, err = sess.Insert(renamedBranch) + if err != nil { + return err + } + + return sess.Commit() +} diff --git a/models/branches_test.go b/models/branches_test.go index 02a9e81a7..f1dcfecfa 100644 --- a/models/branches_test.go +++ b/models/branches_test.go @@ -79,3 +79,52 @@ func getDeletedBranch(t *testing.T, branch *DeletedBranch) *DeletedBranch { return deletedBranch } + +func TestFindRenamedBranch(t *testing.T) { + assert.NoError(t, db.PrepareTestDatabase()) + branch, exist, err := FindRenamedBranch(1, "dev") + assert.NoError(t, err) + assert.Equal(t, true, exist) + assert.Equal(t, "master", branch.To) + + _, exist, err = FindRenamedBranch(1, "unknow") + assert.NoError(t, err) + assert.Equal(t, false, exist) +} + +func TestRenameBranch(t *testing.T) { + assert.NoError(t, db.PrepareTestDatabase()) + repo1 := db.AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository) + _isDefault := false + + err := UpdateProtectBranch(repo1, &ProtectedBranch{ + RepoID: repo1.ID, + BranchName: "master", + }, WhitelistOptions{}) + assert.NoError(t, err) + + assert.NoError(t, repo1.RenameBranch("master", "main", func(isDefault bool) error { + _isDefault = isDefault + return nil + })) + + assert.Equal(t, true, _isDefault) + repo1 = db.AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository) + assert.Equal(t, "main", repo1.DefaultBranch) + + pull := db.AssertExistsAndLoadBean(t, &PullRequest{ID: 1}).(*PullRequest) // merged + assert.Equal(t, "master", pull.BaseBranch) + + pull = db.AssertExistsAndLoadBean(t, &PullRequest{ID: 2}).(*PullRequest) // open + assert.Equal(t, "main", pull.BaseBranch) + + renamedBranch := db.AssertExistsAndLoadBean(t, &RenamedBranch{ID: 2}).(*RenamedBranch) + assert.Equal(t, "master", renamedBranch.From) + assert.Equal(t, "main", renamedBranch.To) + assert.Equal(t, int64(1), renamedBranch.RepoID) + + db.AssertExistsAndLoadBean(t, &ProtectedBranch{ + RepoID: repo1.ID, + BranchName: "main", + }) +} diff --git a/models/fixtures/renamed_branch.yml b/models/fixtures/renamed_branch.yml new file mode 100644 index 000000000..efa5130a2 --- /dev/null +++ b/models/fixtures/renamed_branch.yml @@ -0,0 +1,5 @@ +- + id: 1 + repo_id: 1 + from: dev + to: master diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 33b094e48..6f6296dab 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -346,6 +346,8 @@ var migrations = []Migration{ NewMigration("Add table commit_status_index", addTableCommitStatusIndex), // v196 -> v197 NewMigration("Add Color to ProjectBoard table", addColorColToProjectBoard), + // v197 -> v198 + NewMigration("Add renamed_branch table", addRenamedBranchTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v197.go b/models/migrations/v197.go new file mode 100644 index 000000000..3517896a2 --- /dev/null +++ b/models/migrations/v197.go @@ -0,0 +1,20 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "xorm.io/xorm" +) + +func addRenamedBranchTable(x *xorm.Engine) error { + type RenamedBranch struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + From string + To string + CreatedUnix int64 `xorm:"created"` + } + return x.Sync2(new(RenamedBranch)) +} diff --git a/modules/context/repo.go b/modules/context/repo.go index eceefd9e5..8972cd28b 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -705,7 +705,28 @@ func getRefName(ctx *Context, pathType RepoRefType) string { ctx.Repo.TreePath = path return ctx.Repo.Repository.DefaultBranch case RepoRefBranch: - return getRefNameFromPath(ctx, path, ctx.Repo.GitRepo.IsBranchExist) + ref := getRefNameFromPath(ctx, path, ctx.Repo.GitRepo.IsBranchExist) + if len(ref) == 0 { + // maybe it's a renamed branch + return getRefNameFromPath(ctx, path, func(s string) bool { + b, exist, err := models.FindRenamedBranch(ctx.Repo.Repository.ID, s) + if err != nil { + log.Error("FindRenamedBranch", err) + return false + } + + if !exist { + return false + } + + ctx.Data["IsRenamedBranch"] = true + ctx.Data["RenamedBranchName"] = b.To + + return true + }) + } + + return ref case RepoRefTag: return getRefNameFromPath(ctx, path, ctx.Repo.GitRepo.IsTagExist) case RepoRefCommit: @@ -784,6 +805,15 @@ func RepoRefByType(refType RepoRefType, ignoreNotExistErr ...bool) func(*Context } else { refName = getRefName(ctx, refType) ctx.Repo.BranchName = refName + isRenamedBranch, has := ctx.Data["IsRenamedBranch"].(bool) + if isRenamedBranch && has { + renamedBranchName := ctx.Data["RenamedBranchName"].(string) + ctx.Flash.Info(ctx.Tr("repo.branch.renamed", refName, renamedBranchName)) + link := strings.Replace(ctx.Req.RequestURI, refName, renamedBranchName, 1) + ctx.Redirect(link) + return + } + if refType.RefTypeIncludesBranches() && ctx.Repo.GitRepo.IsBranchExist(refName) { ctx.Repo.IsViewBranch = true diff --git a/modules/git/repo_branch.go b/modules/git/repo_branch.go index 7c30b1fb2..96f692826 100644 --- a/modules/git/repo_branch.go +++ b/modules/git/repo_branch.go @@ -164,3 +164,9 @@ func (repo *Repository) RemoveRemote(name string) error { func (branch *Branch) GetCommit() (*Commit, error) { return branch.gitRepo.GetBranchCommit(branch.Name) } + +// RenameBranch rename a branch +func (repo *Repository) RenameBranch(from, to string) error { + _, err := NewCommand("branch", "-m", from, to).RunInDir(repo.Path) + return err +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index bc03f8661..d5af933f4 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1985,6 +1985,12 @@ settings.lfs_pointers.inRepo=In Repo settings.lfs_pointers.exists=Exists in store settings.lfs_pointers.accessible=Accessible to User settings.lfs_pointers.associateAccessible=Associate accessible %d OIDs +settings.rename_branch_failed_exist=Cannot rename branch because target branch %s exists. +settings.rename_branch_failed_not_exist=Cannot rename branch %s because it does not exist. +settings.rename_branch_success =Branch %s was successfully renamed to %s. +settings.rename_branch_from=old branch name +settings.rename_branch_to=new branch name +settings.rename_branch=Rename branch diff.browse_source = Browse Source diff.parent = parent @@ -2106,6 +2112,7 @@ branch.create_new_branch = Create branch from branch: branch.confirm_create_branch = Create branch branch.new_branch = Create new branch branch.new_branch_from = Create new branch from '%s' +branch.renamed = Branch %s was renamed to %s. tag.create_tag = Create tag %s tag.create_success = Tag '%s' has been created. diff --git a/routers/web/repo/setting_protected_branch.go b/routers/web/repo/setting_protected_branch.go index c48ab9471..876ff9ba4 100644 --- a/routers/web/repo/setting_protected_branch.go +++ b/routers/web/repo/setting_protected_branch.go @@ -19,6 +19,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/forms" pull_service "code.gitea.io/gitea/services/pull" + "code.gitea.io/gitea/services/repository" ) // ProtectedBranch render the page to protect the repository @@ -285,3 +286,40 @@ func SettingsProtectedBranchPost(ctx *context.Context) { ctx.Redirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) } } + +// RenameBranchPost responses for rename a branch +func RenameBranchPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RenameBranchForm) + + if !ctx.Repo.CanCreateBranch() { + ctx.NotFound("RenameBranch", nil) + return + } + + if ctx.HasError() { + ctx.Flash.Error(ctx.GetErrMsg()) + ctx.Redirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + return + } + + msg, err := repository.RenameBranch(ctx.Repo.Repository, ctx.User, ctx.Repo.GitRepo, form.From, form.To) + if err != nil { + ctx.ServerError("RenameBranch", err) + return + } + + if msg == "target_exist" { + ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_exist", form.To)) + ctx.Redirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + return + } + + if msg == "from_not_exist" { + ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_not_exist", form.From)) + ctx.Redirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.rename_branch_success", form.From, form.To)) + ctx.Redirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) +} diff --git a/routers/web/web.go b/routers/web/web.go index 01d90d206..b4103ccad 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -612,6 +612,7 @@ func RegisterRoutes(m *web.Route) { m.Combo("/*").Get(repo.SettingsProtectedBranch). Post(bindIgnErr(forms.ProtectBranchForm{}), context.RepoMustNotBeArchived(), repo.SettingsProtectedBranchPost) }, repo.MustBeNotEmpty) + m.Post("/rename_branch", bindIgnErr(forms.RenameBranchForm{}), context.RepoMustNotBeArchived(), repo.RenameBranchPost) m.Group("/tags", func() { m.Get("", repo.Tags) diff --git a/services/forms/repo_branch_form.go b/services/forms/repo_branch_form.go index 88a069b83..f9262aaed 100644 --- a/services/forms/repo_branch_form.go +++ b/services/forms/repo_branch_form.go @@ -24,3 +24,15 @@ func (f *NewBranchForm) Validate(req *http.Request, errs binding.Errors) binding ctx := context.GetContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } + +// RenameBranchForm form for rename a branch +type RenameBranchForm struct { + From string `binding:"Required;MaxSize(100);GitRefName"` + To string `binding:"Required;MaxSize(100);GitRefName"` +} + +// Validate validates the fields +func (f *RenameBranchForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/services/repository/branch.go b/services/repository/branch.go index 28d24f121..5e246cbec 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -10,10 +10,49 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/notification" repo_module "code.gitea.io/gitea/modules/repository" pull_service "code.gitea.io/gitea/services/pull" ) +// RenameBranch rename a branch +func RenameBranch(repo *models.Repository, doer *models.User, gitRepo *git.Repository, from, to string) (string, error) { + if from == to { + return "target_exist", nil + } + + if gitRepo.IsBranchExist(to) { + return "target_exist", nil + } + + if !gitRepo.IsBranchExist(from) { + return "from_not_exist", nil + } + + if err := repo.RenameBranch(from, to, func(isDefault bool) error { + err2 := gitRepo.RenameBranch(from, to) + if err2 != nil { + return err2 + } + + if isDefault { + err2 = gitRepo.SetDefaultBranch(to) + if err2 != nil { + return err2 + } + } + + return nil + }); err != nil { + return "", err + } + + notification.NotifyDeleteRef(doer, repo, "branch", "refs/heads/"+from) + notification.NotifyCreateRef(doer, repo, "branch", "refs/heads/"+to) + + return "", nil +} + // enmuerates all branch related errors var ( ErrBranchIsDefault = errors.New("branch is default") diff --git a/templates/repo/settings/branches.tmpl b/templates/repo/settings/branches.tmpl index ccf6abbb8..89d7c6db7 100644 --- a/templates/repo/settings/branches.tmpl +++ b/templates/repo/settings/branches.tmpl @@ -77,6 +77,28 @@ + + {{if $.Repository.CanCreateBranch}} +

+ {{.i18n.Tr "repo.settings.rename_branch"}} +

+
+
+ {{.CsrfTokenHtml}} +
+ + +
+
+ + +
+
+ +
+
+
+ {{end}} {{end}}