Add merge style fast-forward-only (#28954)

With this option, it is possible to require a linear commit history with
the following benefits over the next best option `Rebase+fast-forward`:
The original commits continue existing, with the original signatures
continuing to stay valid instead of being rewritten, there is no merge
commit, and reverting commits becomes easier.

Closes #24906
This commit is contained in:
Chris Copeland 2024-02-12 14:37:23 -08:00 committed by Gergely Nagy
parent 05eaf1cf3e
commit 83123b493f
No known key found for this signature in database
25 changed files with 204 additions and 11 deletions

View file

@ -1061,7 +1061,7 @@ LEVEL = Info
;; List of keywords used in Pull Request comments to automatically reopen a related issue ;; List of keywords used in Pull Request comments to automatically reopen a related issue
;REOPEN_KEYWORDS = reopen,reopens,reopened ;REOPEN_KEYWORDS = reopen,reopens,reopened
;; ;;
;; Set default merge style for repository creating, valid options: merge, rebase, rebase-merge, squash ;; Set default merge style for repository creating, valid options: merge, rebase, rebase-merge, squash, fast-forward-only
;DEFAULT_MERGE_STYLE = merge ;DEFAULT_MERGE_STYLE = merge
;; ;;
;; In the default merge message for squash commits include at most this many commits ;; In the default merge message for squash commits include at most this many commits

View file

@ -126,7 +126,7 @@ In addition, there is _`StaticRootPath`_ which can be set as a built-in at build
keywords used in Pull Request comments to automatically close a related issue keywords used in Pull Request comments to automatically close a related issue
- `REOPEN_KEYWORDS`: **reopen**, **reopens**, **reopened**: List of keywords used in Pull Request comments to automatically reopen - `REOPEN_KEYWORDS`: **reopen**, **reopens**, **reopened**: List of keywords used in Pull Request comments to automatically reopen
a related issue a related issue
- `DEFAULT_MERGE_STYLE`: **merge**: Set default merge style for repository creating, valid options: `merge`, `rebase`, `rebase-merge`, `squash` - `DEFAULT_MERGE_STYLE`: **merge**: Set default merge style for repository creating, valid options: `merge`, `rebase`, `rebase-merge`, `squash`, `fast-forward-only`
- `DEFAULT_MERGE_MESSAGE_COMMITS_LIMIT`: **50**: In the default merge message for squash commits include at most this many commits. Set to `-1` to include all commits - `DEFAULT_MERGE_MESSAGE_COMMITS_LIMIT`: **50**: In the default merge message for squash commits include at most this many commits. Set to `-1` to include all commits
- `DEFAULT_MERGE_MESSAGE_SIZE`: **5120**: In the default merge message for squash commits limit the size of the commit messages. Set to `-1` to have no limit. Only used if `POPULATE_SQUASH_COMMENT_WITH_COMMIT_MESSAGES` is `true`. - `DEFAULT_MERGE_MESSAGE_SIZE`: **5120**: In the default merge message for squash commits limit the size of the commit messages. Set to `-1` to have no limit. Only used if `POPULATE_SQUASH_COMMENT_WITH_COMMIT_MESSAGES` is `true`.
- `DEFAULT_MERGE_MESSAGE_ALL_AUTHORS`: **false**: In the default merge message for squash commits walk all commits to include all authors in the Co-authored-by otherwise just use those in the limited list - `DEFAULT_MERGE_MESSAGE_ALL_AUTHORS`: **false**: In the default merge message for squash commits walk all commits to include all authors in the Co-authored-by otherwise just use those in the limited list

View file

@ -125,7 +125,7 @@ menu:
- `CLOSE_KEYWORDS`: **close**, **closes**, **closed**, **fix**, **fixes**, **fixed**, **resolve**, **resolves**, **resolved**: 在拉取请求评论中用于自动关闭相关问题的关键词列表。 - `CLOSE_KEYWORDS`: **close**, **closes**, **closed**, **fix**, **fixes**, **fixed**, **resolve**, **resolves**, **resolved**: 在拉取请求评论中用于自动关闭相关问题的关键词列表。
- `REOPEN_KEYWORDS`: **reopen**, **reopens**, **reopened**: 在拉取请求评论中用于自动重新打开相关问题的 - `REOPEN_KEYWORDS`: **reopen**, **reopens**, **reopened**: 在拉取请求评论中用于自动重新打开相关问题的
关键词列表。 关键词列表。
- `DEFAULT_MERGE_STYLE`: **merge**: 设置创建仓库的默认合并方式,可选: `merge`, `rebase`, `rebase-merge`, `squash` - `DEFAULT_MERGE_STYLE`: **merge**: 设置创建仓库的默认合并方式,可选: `merge`, `rebase`, `rebase-merge`, `squash`, `fast-forward-only`
- `DEFAULT_MERGE_MESSAGE_COMMITS_LIMIT`: **50**: 在默认合并消息中,对于`squash`提交,最多包括此数量的提交。设置为 -1 以包括所有提交。 - `DEFAULT_MERGE_MESSAGE_COMMITS_LIMIT`: **50**: 在默认合并消息中,对于`squash`提交,最多包括此数量的提交。设置为 -1 以包括所有提交。
- `DEFAULT_MERGE_MESSAGE_SIZE`: **5120**: 在默认的合并消息中,对于`squash`提交,限制提交消息的大小。设置为 `-1`以取消限制。仅在`POPULATE_SQUASH_COMMENT_WITH_COMMIT_MESSAGES`为`true`时使用。 - `DEFAULT_MERGE_MESSAGE_SIZE`: **5120**: 在默认的合并消息中,对于`squash`提交,限制提交消息的大小。设置为 `-1`以取消限制。仅在`POPULATE_SQUASH_COMMENT_WITH_COMMIT_MESSAGES`为`true`时使用。
- `DEFAULT_MERGE_MESSAGE_ALL_AUTHORS`: **false**: 在默认合并消息中,对于`squash`提交,遍历所有提交以包括所有作者的`Co-authored-by`,否则仅使用限定列表中的作者。 - `DEFAULT_MERGE_MESSAGE_ALL_AUTHORS`: **false**: 在默认合并消息中,对于`squash`提交,遍历所有提交以包括所有作者的`Co-authored-by`,否则仅使用限定列表中的作者。

View file

@ -493,6 +493,23 @@ func (err ErrMergeUnrelatedHistories) Error() string {
return fmt.Sprintf("Merge UnrelatedHistories Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut) return fmt.Sprintf("Merge UnrelatedHistories Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
} }
// ErrMergeDivergingFastForwardOnly represents an error if a fast-forward-only merge fails because the branches diverge
type ErrMergeDivergingFastForwardOnly struct {
StdOut string
StdErr string
Err error
}
// IsErrMergeDivergingFastForwardOnly checks if an error is a ErrMergeDivergingFastForwardOnly.
func IsErrMergeDivergingFastForwardOnly(err error) bool {
_, ok := err.(ErrMergeDivergingFastForwardOnly)
return ok
}
func (err ErrMergeDivergingFastForwardOnly) Error() string {
return fmt.Sprintf("Merge DivergingFastForwardOnly Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
}
// ErrRebaseConflicts represents an error if rebase fails with a conflict // ErrRebaseConflicts represents an error if rebase fails with a conflict
type ErrRebaseConflicts struct { type ErrRebaseConflicts struct {
Style repo_model.MergeStyle Style repo_model.MergeStyle

View file

@ -21,6 +21,8 @@ const (
MergeStyleRebaseMerge MergeStyle = "rebase-merge" MergeStyleRebaseMerge MergeStyle = "rebase-merge"
// MergeStyleSquash squash commits into single commit before merging // MergeStyleSquash squash commits into single commit before merging
MergeStyleSquash MergeStyle = "squash" MergeStyleSquash MergeStyle = "squash"
// MergeStyleFastForwardOnly fast-forward merge if possible, otherwise fail
MergeStyleFastForwardOnly MergeStyle = "fast-forward-only"
// MergeStyleManuallyMerged pr has been merged manually, just mark it as merged directly // MergeStyleManuallyMerged pr has been merged manually, just mark it as merged directly
MergeStyleManuallyMerged MergeStyle = "manually-merged" MergeStyleManuallyMerged MergeStyle = "manually-merged"
// MergeStyleRebaseUpdate not a merge style, used to update pull head by rebase // MergeStyleRebaseUpdate not a merge style, used to update pull head by rebase

View file

@ -153,6 +153,7 @@ type PullRequestsConfig struct {
AllowRebase bool AllowRebase bool
AllowRebaseMerge bool AllowRebaseMerge bool
AllowSquash bool AllowSquash bool
AllowFastForwardOnly bool
AllowManualMerge bool AllowManualMerge bool
AutodetectManualMerge bool AutodetectManualMerge bool
AllowRebaseUpdate bool AllowRebaseUpdate bool
@ -179,6 +180,7 @@ func (cfg *PullRequestsConfig) IsMergeStyleAllowed(mergeStyle MergeStyle) bool {
mergeStyle == MergeStyleRebase && cfg.AllowRebase || mergeStyle == MergeStyleRebase && cfg.AllowRebase ||
mergeStyle == MergeStyleRebaseMerge && cfg.AllowRebaseMerge || mergeStyle == MergeStyleRebaseMerge && cfg.AllowRebaseMerge ||
mergeStyle == MergeStyleSquash && cfg.AllowSquash || mergeStyle == MergeStyleSquash && cfg.AllowSquash ||
mergeStyle == MergeStyleFastForwardOnly && cfg.AllowFastForwardOnly ||
mergeStyle == MergeStyleManuallyMerged && cfg.AllowManualMerge mergeStyle == MergeStyleManuallyMerged && cfg.AllowManualMerge
} }

View file

@ -96,7 +96,7 @@ func (err ErrBranchNotExist) Unwrap() error {
return util.ErrNotExist return util.ErrNotExist
} }
// ErrPushOutOfDate represents an error if merging fails due to unrelated histories // ErrPushOutOfDate represents an error if merging fails due to the base branch being updated
type ErrPushOutOfDate struct { type ErrPushOutOfDate struct {
StdOut string StdOut string
StdErr string StdErr string

View file

@ -87,7 +87,11 @@ func CreateRepositoryByExample(ctx context.Context, doer, u *user_model.User, re
units = append(units, repo_model.RepoUnit{ units = append(units, repo_model.RepoUnit{
RepoID: repo.ID, RepoID: repo.ID,
Type: tp, Type: tp,
Config: &repo_model.PullRequestsConfig{AllowMerge: true, AllowRebase: true, AllowRebaseMerge: true, AllowSquash: true, DefaultMergeStyle: repo_model.MergeStyle(setting.Repository.PullRequest.DefaultMergeStyle), AllowRebaseUpdate: true}, Config: &repo_model.PullRequestsConfig{
AllowMerge: true, AllowRebase: true, AllowRebaseMerge: true, AllowSquash: true, AllowFastForwardOnly: true,
DefaultMergeStyle: repo_model.MergeStyle(setting.Repository.PullRequest.DefaultMergeStyle),
AllowRebaseUpdate: true,
},
}) })
} else { } else {
units = append(units, repo_model.RepoUnit{ units = append(units, repo_model.RepoUnit{

View file

@ -99,6 +99,7 @@ type Repository struct {
AllowRebase bool `json:"allow_rebase"` AllowRebase bool `json:"allow_rebase"`
AllowRebaseMerge bool `json:"allow_rebase_explicit"` AllowRebaseMerge bool `json:"allow_rebase_explicit"`
AllowSquash bool `json:"allow_squash_merge"` AllowSquash bool `json:"allow_squash_merge"`
AllowFastForwardOnly bool `json:"allow_fast_forward_only_merge"`
AllowRebaseUpdate bool `json:"allow_rebase_update"` AllowRebaseUpdate bool `json:"allow_rebase_update"`
DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge"` DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge"`
DefaultMergeStyle string `json:"default_merge_style"` DefaultMergeStyle string `json:"default_merge_style"`
@ -198,6 +199,8 @@ type EditRepoOption struct {
AllowRebaseMerge *bool `json:"allow_rebase_explicit,omitempty"` AllowRebaseMerge *bool `json:"allow_rebase_explicit,omitempty"`
// either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging. // either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging.
AllowSquash *bool `json:"allow_squash_merge,omitempty"` AllowSquash *bool `json:"allow_squash_merge,omitempty"`
// either `true` to allow fast-forward-only merging pull requests, or `false` to prevent fast-forward-only merging.
AllowFastForwardOnly *bool `json:"allow_fast_forward_only_merge,omitempty"`
// either `true` to allow mark pr as merged manually, or `false` to prevent it. // either `true` to allow mark pr as merged manually, or `false` to prevent it.
AllowManualMerge *bool `json:"allow_manual_merge,omitempty"` AllowManualMerge *bool `json:"allow_manual_merge,omitempty"`
// either `true` to enable AutodetectManualMerge, or `false` to prevent it. Note: In some special cases, misjudgments can occur. // either `true` to enable AutodetectManualMerge, or `false` to prevent it. Note: In some special cases, misjudgments can occur.
@ -206,7 +209,7 @@ type EditRepoOption struct {
AllowRebaseUpdate *bool `json:"allow_rebase_update,omitempty"` AllowRebaseUpdate *bool `json:"allow_rebase_update,omitempty"`
// set to `true` to delete pr branch after merge by default // set to `true` to delete pr branch after merge by default
DefaultDeleteBranchAfterMerge *bool `json:"default_delete_branch_after_merge,omitempty"` DefaultDeleteBranchAfterMerge *bool `json:"default_delete_branch_after_merge,omitempty"`
// set to a merge style to be used by this repository: "merge", "rebase", "rebase-merge", or "squash". // set to a merge style to be used by this repository: "merge", "rebase", "rebase-merge", "squash", or "fast-forward-only".
DefaultMergeStyle *string `json:"default_merge_style,omitempty"` DefaultMergeStyle *string `json:"default_merge_style,omitempty"`
// set to `true` to allow edits from maintainers by default // set to `true` to allow edits from maintainers by default
DefaultAllowMaintainerEdit *bool `json:"default_allow_maintainer_edit,omitempty"` DefaultAllowMaintainerEdit *bool `json:"default_allow_maintainer_edit,omitempty"`

View file

@ -1816,6 +1816,7 @@ pulls.merge_pull_request = Create merge commit
pulls.rebase_merge_pull_request = Rebase then fast-forward pulls.rebase_merge_pull_request = Rebase then fast-forward
pulls.rebase_merge_commit_pull_request = Rebase then create merge commit pulls.rebase_merge_commit_pull_request = Rebase then create merge commit
pulls.squash_merge_pull_request = Create squash commit pulls.squash_merge_pull_request = Create squash commit
pulls.fast_forward_only_merge_pull_request = Fast-forward only
pulls.merge_manually = Manually merged pulls.merge_manually = Manually merged
pulls.merge_commit_id = The merge commit ID pulls.merge_commit_id = The merge commit ID
pulls.require_signed_wont_sign = The branch requires signed commits but this merge will not be signed pulls.require_signed_wont_sign = The branch requires signed commits but this merge will not be signed

View file

@ -897,6 +897,7 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error {
AllowRebase: true, AllowRebase: true,
AllowRebaseMerge: true, AllowRebaseMerge: true,
AllowSquash: true, AllowSquash: true,
AllowFastForwardOnly: true,
AllowManualMerge: true, AllowManualMerge: true,
AutodetectManualMerge: false, AutodetectManualMerge: false,
AllowRebaseUpdate: true, AllowRebaseUpdate: true,
@ -923,6 +924,9 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error {
if opts.AllowSquash != nil { if opts.AllowSquash != nil {
config.AllowSquash = *opts.AllowSquash config.AllowSquash = *opts.AllowSquash
} }
if opts.AllowFastForwardOnly != nil {
config.AllowFastForwardOnly = *opts.AllowFastForwardOnly
}
if opts.AllowManualMerge != nil { if opts.AllowManualMerge != nil {
config.AllowManualMerge = *opts.AllowManualMerge config.AllowManualMerge = *opts.AllowManualMerge
} }

View file

@ -35,6 +35,7 @@ func TestRepoEdit(t *testing.T) {
allowRebase := false allowRebase := false
allowRebaseMerge := false allowRebaseMerge := false
allowSquashMerge := false allowSquashMerge := false
allowFastForwardOnlyMerge := false
archived := true archived := true
opts := api.EditRepoOption{ opts := api.EditRepoOption{
Name: &ctx.Repo.Repository.Name, Name: &ctx.Repo.Repository.Name,
@ -50,6 +51,7 @@ func TestRepoEdit(t *testing.T) {
AllowRebase: &allowRebase, AllowRebase: &allowRebase,
AllowRebaseMerge: &allowRebaseMerge, AllowRebaseMerge: &allowRebaseMerge,
AllowSquash: &allowSquashMerge, AllowSquash: &allowSquashMerge,
AllowFastForwardOnly: &allowFastForwardOnlyMerge,
Archived: &archived, Archived: &archived,
} }

View file

@ -1871,6 +1871,8 @@ func ViewIssue(ctx *context.Context) {
mergeStyle = repo_model.MergeStyleRebaseMerge mergeStyle = repo_model.MergeStyleRebaseMerge
} else if prConfig.AllowSquash { } else if prConfig.AllowSquash {
mergeStyle = repo_model.MergeStyleSquash mergeStyle = repo_model.MergeStyleSquash
} else if prConfig.AllowFastForwardOnly {
mergeStyle = repo_model.MergeStyleFastForwardOnly
} else if prConfig.AllowManualMerge { } else if prConfig.AllowManualMerge {
mergeStyle = repo_model.MergeStyleManuallyMerged mergeStyle = repo_model.MergeStyleManuallyMerged
} }

View file

@ -251,6 +251,7 @@ func UnitsPost(ctx *context.Context) {
AllowRebase: form.PullsAllowRebase, AllowRebase: form.PullsAllowRebase,
AllowRebaseMerge: form.PullsAllowRebaseMerge, AllowRebaseMerge: form.PullsAllowRebaseMerge,
AllowSquash: form.PullsAllowSquash, AllowSquash: form.PullsAllowSquash,
AllowFastForwardOnly: form.PullsAllowFastForwardOnly,
AllowManualMerge: form.PullsAllowManualMerge, AllowManualMerge: form.PullsAllowManualMerge,
AutodetectManualMerge: form.EnableAutodetectManualMerge, AutodetectManualMerge: form.EnableAutodetectManualMerge,
AllowRebaseUpdate: form.PullsAllowRebaseUpdate, AllowRebaseUpdate: form.PullsAllowRebaseUpdate,

View file

@ -93,6 +93,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
allowRebase := false allowRebase := false
allowRebaseMerge := false allowRebaseMerge := false
allowSquash := false allowSquash := false
allowFastForwardOnly := false
allowRebaseUpdate := false allowRebaseUpdate := false
defaultDeleteBranchAfterMerge := false defaultDeleteBranchAfterMerge := false
defaultMergeStyle := repo_model.MergeStyleMerge defaultMergeStyle := repo_model.MergeStyleMerge
@ -105,6 +106,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
allowRebase = config.AllowRebase allowRebase = config.AllowRebase
allowRebaseMerge = config.AllowRebaseMerge allowRebaseMerge = config.AllowRebaseMerge
allowSquash = config.AllowSquash allowSquash = config.AllowSquash
allowFastForwardOnly = config.AllowFastForwardOnly
allowRebaseUpdate = config.AllowRebaseUpdate allowRebaseUpdate = config.AllowRebaseUpdate
defaultDeleteBranchAfterMerge = config.DefaultDeleteBranchAfterMerge defaultDeleteBranchAfterMerge = config.DefaultDeleteBranchAfterMerge
defaultMergeStyle = config.GetDefaultMergeStyle() defaultMergeStyle = config.GetDefaultMergeStyle()
@ -220,6 +222,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
AllowRebase: allowRebase, AllowRebase: allowRebase,
AllowRebaseMerge: allowRebaseMerge, AllowRebaseMerge: allowRebaseMerge,
AllowSquash: allowSquash, AllowSquash: allowSquash,
AllowFastForwardOnly: allowFastForwardOnly,
AllowRebaseUpdate: allowRebaseUpdate, AllowRebaseUpdate: allowRebaseUpdate,
DefaultDeleteBranchAfterMerge: defaultDeleteBranchAfterMerge, DefaultDeleteBranchAfterMerge: defaultDeleteBranchAfterMerge,
DefaultMergeStyle: string(defaultMergeStyle), DefaultMergeStyle: string(defaultMergeStyle),

View file

@ -170,6 +170,7 @@ type RepoUnitSettingForm struct {
PullsAllowRebase bool PullsAllowRebase bool
PullsAllowRebaseMerge bool PullsAllowRebaseMerge bool
PullsAllowSquash bool PullsAllowSquash bool
PullsAllowFastForwardOnly bool
PullsAllowManualMerge bool PullsAllowManualMerge bool
PullsDefaultMergeStyle string PullsDefaultMergeStyle string
EnableAutodetectManualMerge bool EnableAutodetectManualMerge bool
@ -609,8 +610,8 @@ func (f *InitializeLabelsForm) Validate(req *http.Request, errs binding.Errors)
// swagger:model MergePullRequestOption // swagger:model MergePullRequestOption
type MergePullRequestForm struct { type MergePullRequestForm struct {
// required: true // required: true
// enum: merge,rebase,rebase-merge,squash,manually-merged // enum: merge,rebase,rebase-merge,squash,fast-forward-only,manually-merged
Do string `binding:"Required;In(merge,rebase,rebase-merge,squash,manually-merged)"` Do string `binding:"Required;In(merge,rebase,rebase-merge,squash,fast-forward-only,manually-merged)"`
MergeTitleField string MergeTitleField string
MergeMessageField string MergeMessageField string
MergeCommitID string // only used for manually-merged MergeCommitID string // only used for manually-merged

View file

@ -273,6 +273,10 @@ func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *use
if err := doMergeStyleSquash(mergeCtx, message); err != nil { if err := doMergeStyleSquash(mergeCtx, message); err != nil {
return "", err return "", err
} }
case repo_model.MergeStyleFastForwardOnly:
if err := doMergeStyleFastForwardOnly(mergeCtx); err != nil {
return "", err
}
default: default:
return "", models.ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle} return "", models.ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle}
} }
@ -383,6 +387,13 @@ func runMergeCommand(ctx *mergeContext, mergeStyle repo_model.MergeStyle, cmd *g
StdErr: ctx.errbuf.String(), StdErr: ctx.errbuf.String(),
Err: err, Err: err,
} }
} else if mergeStyle == repo_model.MergeStyleFastForwardOnly && strings.Contains(ctx.errbuf.String(), "Not possible to fast-forward, aborting") {
log.Debug("MergeDivergingFastForwardOnly %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
return models.ErrMergeDivergingFastForwardOnly{
StdOut: ctx.outbuf.String(),
StdErr: ctx.errbuf.String(),
Err: err,
}
} }
log.Error("git merge %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String()) log.Error("git merge %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
return fmt.Errorf("git merge %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String()) return fmt.Errorf("git merge %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())

View file

@ -0,0 +1,21 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pull
import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
)
// doMergeStyleFastForwardOnly merges the tracking into the current HEAD - which is assumed to be staging branch (equal to the pr.BaseBranch)
func doMergeStyleFastForwardOnly(ctx *mergeContext) error {
cmd := git.NewCommand(ctx, "merge", "--ff-only").AddDynamicArguments(trackingBranch)
if err := runMergeCommand(ctx, repo_model.MergeStyleFastForwardOnly, cmd); err != nil {
log.Error("%-v Unable to merge tracking into base: %v", ctx.pr, err)
return err
}
return nil
}

View file

@ -9,7 +9,7 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
) )
// doMergeStyleMerge merges the tracking into the current HEAD - which is assumed to tbe staging branch (equal to the pr.BaseBranch) // doMergeStyleMerge merges the tracking branch into the current HEAD - which is assumed to be the staging branch (equal to the pr.BaseBranch)
func doMergeStyleMerge(ctx *mergeContext, message string) error { func doMergeStyleMerge(ctx *mergeContext, message string) error {
cmd := git.NewCommand(ctx, "merge", "--no-ff", "--no-commit").AddDynamicArguments(trackingBranch) cmd := git.NewCommand(ctx, "merge", "--no-ff", "--no-commit").AddDynamicArguments(trackingBranch)
if err := runMergeCommand(ctx, repo_model.MergeStyleMerge, cmd); err != nil { if err := runMergeCommand(ctx, repo_model.MergeStyleMerge, cmd); err != nil {

View file

@ -197,7 +197,7 @@
{{if .AllowMerge}} {{/* user is allowed to merge */}} {{if .AllowMerge}} {{/* user is allowed to merge */}}
{{$prUnit := .Repository.MustGetUnit $.Context $.UnitTypePullRequests}} {{$prUnit := .Repository.MustGetUnit $.Context $.UnitTypePullRequests}}
{{$approvers := (.Issue.PullRequest.GetApprovers ctx)}} {{$approvers := (.Issue.PullRequest.GetApprovers ctx)}}
{{if or $prUnit.PullRequestsConfig.AllowMerge $prUnit.PullRequestsConfig.AllowRebase $prUnit.PullRequestsConfig.AllowRebaseMerge $prUnit.PullRequestsConfig.AllowSquash}} {{if or $prUnit.PullRequestsConfig.AllowMerge $prUnit.PullRequestsConfig.AllowRebase $prUnit.PullRequestsConfig.AllowRebaseMerge $prUnit.PullRequestsConfig.AllowSquash $prUnit.PullRequestsConfig.AllowFastForwardOnly}}
{{$hasPendingPullRequestMergeTip := ""}} {{$hasPendingPullRequestMergeTip := ""}}
{{if .HasPendingPullRequestMerge}} {{if .HasPendingPullRequestMerge}}
{{$createdPRMergeStr := TimeSinceUnix .PendingPullRequestMerge.CreatedUnix ctx.Locale}} {{$createdPRMergeStr := TimeSinceUnix .PendingPullRequestMerge.CreatedUnix ctx.Locale}}
@ -268,6 +268,13 @@
'mergeMessageFieldText': {{.GetCommitMessages}} + defaultSquashMergeMessage, 'mergeMessageFieldText': {{.GetCommitMessages}} + defaultSquashMergeMessage,
'hideAutoMerge': generalHideAutoMerge, 'hideAutoMerge': generalHideAutoMerge,
}, },
{
'name': 'fast-forward-only',
'allowed': {{and $prUnit.PullRequestsConfig.AllowFastForwardOnly (eq .Issue.PullRequest.CommitsBehind 0)}},
'textDoMerge': {{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}},
'hideMergeMessageTexts': true,
'hideAutoMerge': generalHideAutoMerge,
},
{ {
'name': 'manually-merged', 'name': 'manually-merged',
'allowed': {{$prUnit.PullRequestsConfig.AllowManualMerge}}, 'allowed': {{$prUnit.PullRequestsConfig.AllowManualMerge}},

View file

@ -35,6 +35,10 @@
<div>git checkout {{.PullRequest.BaseBranch}}</div> <div>git checkout {{.PullRequest.BaseBranch}}</div>
<div>git merge --squash {{$localBranch}}</div> <div>git merge --squash {{$localBranch}}</div>
</div> </div>
<div class="gt-hidden" data-pull-merge-style="fast-forward-only">
<div>git checkout {{.PullRequest.BaseBranch}}</div>
<div>git merge --ff-only {{$localBranch}}</div>
</div>
<div class="gt-hidden" data-pull-merge-style="manually-merged"> <div class="gt-hidden" data-pull-merge-style="manually-merged">
<div>git checkout {{.PullRequest.BaseBranch}}</div> <div>git checkout {{.PullRequest.BaseBranch}}</div>
<div>git merge {{$localBranch}}</div> <div>git merge {{$localBranch}}</div>

View file

@ -42,6 +42,12 @@
<label>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</label> <label>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</label>
</div> </div>
</div> </div>
<div class="field">
<div class="ui checkbox">
<input name="pulls_allow_fast_forward_only" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowFastForwardOnly)}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</label>
</div>
</div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input name="pulls_allow_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowManualMerge)}}checked{{end}}> <input name="pulls_allow_manual_merge" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.AllowManualMerge)}}checked{{end}}>
@ -59,6 +65,7 @@
<option value="rebase" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</option> <option value="rebase" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</option>
<option value="rebase-merge" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase-merge")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</option> <option value="rebase-merge" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "rebase-merge")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</option>
<option value="squash" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</option> <option value="squash" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</option>
<option value="fast-forward-only" {{if or (not $pullRequestEnabled) (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "fast-forward-only")}}selected{{end}}>{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</option>
</select>{{svg "octicon-triangle-down" 14 "dropdown icon"}} </select>{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="default text"> <div class="default text">
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "merge")}} {{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "merge")}}
@ -73,12 +80,16 @@
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}} {{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}
{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}} {{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}
{{end}} {{end}}
{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "fast-forward-only")}}
{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}
{{end}}
</div> </div>
<div class="menu"> <div class="menu">
<div class="item" data-value="merge">{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</div> <div class="item" data-value="merge">{{ctx.Locale.Tr "repo.pulls.merge_pull_request"}}</div>
<div class="item" data-value="rebase">{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</div> <div class="item" data-value="rebase">{{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}}</div>
<div class="item" data-value="rebase-merge">{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</div> <div class="item" data-value="rebase-merge">{{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}}</div>
<div class="item" data-value="squash">{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</div> <div class="item" data-value="squash">{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</div>
<div class="item" data-value="fast-forward-only">{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -19930,6 +19930,11 @@
"description": "EditRepoOption options when editing a repository's properties", "description": "EditRepoOption options when editing a repository's properties",
"type": "object", "type": "object",
"properties": { "properties": {
"allow_fast_forward_only_merge": {
"description": "either `true` to allow fast-forward-only merging pull requests, or `false` to prevent fast-forward-only merging.",
"type": "boolean",
"x-go-name": "AllowFastForwardOnly"
},
"allow_manual_merge": { "allow_manual_merge": {
"description": "either `true` to allow mark pr as merged manually, or `false` to prevent it.", "description": "either `true` to allow mark pr as merged manually, or `false` to prevent it.",
"type": "boolean", "type": "boolean",
@ -19986,7 +19991,7 @@
"x-go-name": "DefaultDeleteBranchAfterMerge" "x-go-name": "DefaultDeleteBranchAfterMerge"
}, },
"default_merge_style": { "default_merge_style": {
"description": "set to a merge style to be used by this repository: \"merge\", \"rebase\", \"rebase-merge\", or \"squash\".", "description": "set to a merge style to be used by this repository: \"merge\", \"rebase\", \"rebase-merge\", \"squash\", or \"fast-forward-only\".",
"type": "string", "type": "string",
"x-go-name": "DefaultMergeStyle" "x-go-name": "DefaultMergeStyle"
}, },
@ -21395,6 +21400,7 @@
"rebase", "rebase",
"rebase-merge", "rebase-merge",
"squash", "squash",
"fast-forward-only",
"manually-merged" "manually-merged"
] ]
}, },
@ -22795,6 +22801,10 @@
"description": "Repository represents a repository", "description": "Repository represents a repository",
"type": "object", "type": "object",
"properties": { "properties": {
"allow_fast_forward_only_merge": {
"type": "boolean",
"x-go-name": "AllowFastForwardOnly"
},
"allow_merge_commits": { "allow_merge_commits": {
"type": "boolean", "type": "boolean",
"x-go-name": "AllowMerge" "x-go-name": "AllowMerge"

View file

@ -65,6 +65,7 @@ func getRepoEditOptionFromRepo(repo *repo_model.Repository) *api.EditRepoOption
allowRebase := false allowRebase := false
allowRebaseMerge := false allowRebaseMerge := false
allowSquash := false allowSquash := false
allowFastForwardOnly := false
if unit, err := repo.GetUnit(db.DefaultContext, unit_model.TypePullRequests); err == nil { if unit, err := repo.GetUnit(db.DefaultContext, unit_model.TypePullRequests); err == nil {
config := unit.PullRequestsConfig() config := unit.PullRequestsConfig()
hasPullRequests = true hasPullRequests = true
@ -73,6 +74,7 @@ func getRepoEditOptionFromRepo(repo *repo_model.Repository) *api.EditRepoOption
allowRebase = config.AllowRebase allowRebase = config.AllowRebase
allowRebaseMerge = config.AllowRebaseMerge allowRebaseMerge = config.AllowRebaseMerge
allowSquash = config.AllowSquash allowSquash = config.AllowSquash
allowFastForwardOnly = config.AllowFastForwardOnly
} }
archived := repo.IsArchived archived := repo.IsArchived
return &api.EditRepoOption{ return &api.EditRepoOption{
@ -92,6 +94,7 @@ func getRepoEditOptionFromRepo(repo *repo_model.Repository) *api.EditRepoOption
AllowRebase: &allowRebase, AllowRebase: &allowRebase,
AllowRebaseMerge: &allowRebaseMerge, AllowRebaseMerge: &allowRebaseMerge,
AllowSquash: &allowSquash, AllowSquash: &allowSquash,
AllowFastForwardOnly: &allowFastForwardOnly,
Archived: &archived, Archived: &archived,
} }
} }

View file

@ -364,6 +364,90 @@ func TestCantMergeUnrelated(t *testing.T) {
}) })
} }
func TestFastForwardOnlyMerge(t *testing.T) {
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
session := loginUser(t, "user1")
testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
testEditFileToNewBranch(t, session, "user1", "repo1", "master", "update", "README.md", "Hello, World 2\n")
// Use API to create a pr from update to master
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", "user1", "repo1"), &api.CreatePullRequestOption{
Head: "update",
Base: "master",
Title: "create a pr that can be fast-forward-only merged",
}).AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusCreated)
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{
Name: "user1",
})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{
OwnerID: user1.ID,
Name: "repo1",
})
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
HeadRepoID: repo1.ID,
BaseRepoID: repo1.ID,
HeadBranch: "update",
BaseBranch: "master",
})
gitRepo, err := git.OpenRepository(git.DefaultContext, repo_model.RepoPath(user1.Name, repo1.Name))
assert.NoError(t, err)
err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)
assert.NoError(t, err)
gitRepo.Close()
})
}
func TestCantFastForwardOnlyMergeDiverging(t *testing.T) {
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
session := loginUser(t, "user1")
testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
testEditFileToNewBranch(t, session, "user1", "repo1", "master", "diverging", "README.md", "Hello, World diverged\n")
testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World 2\n")
// Use API to create a pr from diverging to update
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", "user1", "repo1"), &api.CreatePullRequestOption{
Head: "diverging",
Base: "master",
Title: "create a pr from a diverging branch",
}).AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusCreated)
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{
Name: "user1",
})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{
OwnerID: user1.ID,
Name: "repo1",
})
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
HeadRepoID: repo1.ID,
BaseRepoID: repo1.ID,
HeadBranch: "diverging",
BaseBranch: "master",
})
gitRepo, err := git.OpenRepository(git.DefaultContext, repo_model.RepoPath(user1.Name, repo1.Name))
assert.NoError(t, err)
err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false)
assert.Error(t, err, "Merge should return an error due to being for a diverging branch")
assert.True(t, models.IsErrMergeDivergingFastForwardOnly(err), "Merge error is not a diverging fast-forward-only error")
gitRepo.Close()
})
}
func TestConflictChecking(t *testing.T) { func TestConflictChecking(t *testing.T) {
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})