From 83123b493f3ae25d07d81c86b1a78afe1c17db53 Mon Sep 17 00:00:00 2001
From: Chris Copeland <chris@chrisnc.net>
Date: Mon, 12 Feb 2024 14:37:23 -0800
Subject: [PATCH] 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
---
 custom/conf/app.example.ini                   |  2 +-
 .../config-cheat-sheet.en-us.md               |  2 +-
 .../config-cheat-sheet.zh-cn.md               |  2 +-
 models/error.go                               | 17 ++++
 models/repo/git.go                            |  2 +
 models/repo/repo_unit.go                      |  2 +
 modules/git/error.go                          |  2 +-
 modules/repository/create.go                  |  6 +-
 modules/structs/repo.go                       |  5 +-
 options/locale/locale_en-US.ini               |  1 +
 routers/api/v1/repo/repo.go                   |  4 +
 routers/api/v1/repo/repo_test.go              |  2 +
 routers/web/repo/issue.go                     |  2 +
 routers/web/repo/setting/setting.go           |  1 +
 services/convert/repository.go                |  3 +
 services/forms/repo_form.go                   |  5 +-
 services/pull/merge.go                        | 11 +++
 services/pull/merge_ff_only.go                | 21 +++++
 services/pull/merge_merge.go                  |  2 +-
 templates/repo/issue/view_content/pull.tmpl   |  9 +-
 .../view_content/pull_merge_instruction.tmpl  |  4 +
 templates/repo/settings/units/pulls.tmpl      | 11 +++
 templates/swagger/v1_json.tmpl                | 12 ++-
 tests/integration/api_repo_edit_test.go       |  3 +
 tests/integration/pull_merge_test.go          | 84 +++++++++++++++++++
 25 files changed, 204 insertions(+), 11 deletions(-)
 create mode 100644 services/pull/merge_ff_only.go

diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 6d7b3bedf6..1b53732b1b 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -1061,7 +1061,7 @@ LEVEL = Info
 ;; List of keywords used in Pull Request comments to automatically reopen a related issue
 ;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
 ;;
 ;; In the default merge message for squash commits include at most this many commits
diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md
index b7c6ceb431..aa2cbcee5d 100644
--- a/docs/content/administration/config-cheat-sheet.en-us.md
+++ b/docs/content/administration/config-cheat-sheet.en-us.md
@@ -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
 - `REOPEN_KEYWORDS`: **reopen**, **reopens**, **reopened**: List of keywords used in Pull Request comments to automatically reopen
  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_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
diff --git a/docs/content/administration/config-cheat-sheet.zh-cn.md b/docs/content/administration/config-cheat-sheet.zh-cn.md
index 8236852ad3..01906930cb 100644
--- a/docs/content/administration/config-cheat-sheet.zh-cn.md
+++ b/docs/content/administration/config-cheat-sheet.zh-cn.md
@@ -125,7 +125,7 @@ menu:
 - `CLOSE_KEYWORDS`: **close**, **closes**, **closed**, **fix**, **fixes**, **fixed**, **resolve**, **resolves**, **resolved**: 在拉取请求评论中用于自动关闭相关问题的关键词列表。
 - `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_SIZE`: **5120**: 在默认的合并消息中,对于`squash`提交,限制提交消息的大小。设置为 `-1`以取消限制。仅在`POPULATE_SQUASH_COMMENT_WITH_COMMIT_MESSAGES`为`true`时使用。
 - `DEFAULT_MERGE_MESSAGE_ALL_AUTHORS`: **false**: 在默认合并消息中,对于`squash`提交,遍历所有提交以包括所有作者的`Co-authored-by`,否则仅使用限定列表中的作者。
diff --git a/models/error.go b/models/error.go
index 83dfe29805..75c53245de 100644
--- a/models/error.go
+++ b/models/error.go
@@ -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)
 }
 
+// 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
 type ErrRebaseConflicts struct {
 	Style     repo_model.MergeStyle
diff --git a/models/repo/git.go b/models/repo/git.go
index 610c554296..388bf86522 100644
--- a/models/repo/git.go
+++ b/models/repo/git.go
@@ -21,6 +21,8 @@ const (
 	MergeStyleRebaseMerge MergeStyle = "rebase-merge"
 	// MergeStyleSquash squash commits into single commit before merging
 	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 MergeStyle = "manually-merged"
 	// MergeStyleRebaseUpdate not a merge style, used to update pull head by rebase
diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go
index 3df5236ea7..08058b0d45 100644
--- a/models/repo/repo_unit.go
+++ b/models/repo/repo_unit.go
@@ -153,6 +153,7 @@ type PullRequestsConfig struct {
 	AllowRebase                   bool
 	AllowRebaseMerge              bool
 	AllowSquash                   bool
+	AllowFastForwardOnly          bool
 	AllowManualMerge              bool
 	AutodetectManualMerge         bool
 	AllowRebaseUpdate             bool
@@ -179,6 +180,7 @@ func (cfg *PullRequestsConfig) IsMergeStyleAllowed(mergeStyle MergeStyle) bool {
 		mergeStyle == MergeStyleRebase && cfg.AllowRebase ||
 		mergeStyle == MergeStyleRebaseMerge && cfg.AllowRebaseMerge ||
 		mergeStyle == MergeStyleSquash && cfg.AllowSquash ||
+		mergeStyle == MergeStyleFastForwardOnly && cfg.AllowFastForwardOnly ||
 		mergeStyle == MergeStyleManuallyMerged && cfg.AllowManualMerge
 }
 
diff --git a/modules/git/error.go b/modules/git/error.go
index dc10d451b3..91d25eca69 100644
--- a/modules/git/error.go
+++ b/modules/git/error.go
@@ -96,7 +96,7 @@ func (err ErrBranchNotExist) Unwrap() error {
 	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 {
 	StdOut string
 	StdErr string
diff --git a/modules/repository/create.go b/modules/repository/create.go
index 7c954a1412..ca2150b972 100644
--- a/modules/repository/create.go
+++ b/modules/repository/create.go
@@ -87,7 +87,11 @@ func CreateRepositoryByExample(ctx context.Context, doer, u *user_model.User, re
 			units = append(units, repo_model.RepoUnit{
 				RepoID: repo.ID,
 				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 {
 			units = append(units, repo_model.RepoUnit{
diff --git a/modules/structs/repo.go b/modules/structs/repo.go
index e20b6bc26e..a50cddaf7e 100644
--- a/modules/structs/repo.go
+++ b/modules/structs/repo.go
@@ -99,6 +99,7 @@ type Repository struct {
 	AllowRebase                   bool             `json:"allow_rebase"`
 	AllowRebaseMerge              bool             `json:"allow_rebase_explicit"`
 	AllowSquash                   bool             `json:"allow_squash_merge"`
+	AllowFastForwardOnly          bool             `json:"allow_fast_forward_only_merge"`
 	AllowRebaseUpdate             bool             `json:"allow_rebase_update"`
 	DefaultDeleteBranchAfterMerge bool             `json:"default_delete_branch_after_merge"`
 	DefaultMergeStyle             string           `json:"default_merge_style"`
@@ -198,6 +199,8 @@ type EditRepoOption struct {
 	AllowRebaseMerge *bool `json:"allow_rebase_explicit,omitempty"`
 	// either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging.
 	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.
 	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.
@@ -206,7 +209,7 @@ type EditRepoOption struct {
 	AllowRebaseUpdate *bool `json:"allow_rebase_update,omitempty"`
 	// set to `true` to delete pr branch after merge by default
 	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"`
 	// set to `true` to allow edits from maintainers by default
 	DefaultAllowMaintainerEdit *bool `json:"default_allow_maintainer_edit,omitempty"`
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 9c8b3fc541..e81d493b19 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1816,6 +1816,7 @@ pulls.merge_pull_request = Create merge commit
 pulls.rebase_merge_pull_request = Rebase then fast-forward
 pulls.rebase_merge_commit_pull_request = Rebase then create merge 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_commit_id = The merge commit ID
 pulls.require_signed_wont_sign = The branch requires signed commits but this merge will not be signed
diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go
index 08e79e544a..de105f474f 100644
--- a/routers/api/v1/repo/repo.go
+++ b/routers/api/v1/repo/repo.go
@@ -897,6 +897,7 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error {
 					AllowRebase:                   true,
 					AllowRebaseMerge:              true,
 					AllowSquash:                   true,
+					AllowFastForwardOnly:          true,
 					AllowManualMerge:              true,
 					AutodetectManualMerge:         false,
 					AllowRebaseUpdate:             true,
@@ -923,6 +924,9 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error {
 			if opts.AllowSquash != nil {
 				config.AllowSquash = *opts.AllowSquash
 			}
+			if opts.AllowFastForwardOnly != nil {
+				config.AllowFastForwardOnly = *opts.AllowFastForwardOnly
+			}
 			if opts.AllowManualMerge != nil {
 				config.AllowManualMerge = *opts.AllowManualMerge
 			}
diff --git a/routers/api/v1/repo/repo_test.go b/routers/api/v1/repo/repo_test.go
index 29e2d1f21d..08ba7fabac 100644
--- a/routers/api/v1/repo/repo_test.go
+++ b/routers/api/v1/repo/repo_test.go
@@ -35,6 +35,7 @@ func TestRepoEdit(t *testing.T) {
 	allowRebase := false
 	allowRebaseMerge := false
 	allowSquashMerge := false
+	allowFastForwardOnlyMerge := false
 	archived := true
 	opts := api.EditRepoOption{
 		Name:                      &ctx.Repo.Repository.Name,
@@ -50,6 +51,7 @@ func TestRepoEdit(t *testing.T) {
 		AllowRebase:               &allowRebase,
 		AllowRebaseMerge:          &allowRebaseMerge,
 		AllowSquash:               &allowSquashMerge,
+		AllowFastForwardOnly:      &allowFastForwardOnlyMerge,
 		Archived:                  &archived,
 	}
 
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 3e7b099bba..fb4f2bad98 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -1871,6 +1871,8 @@ func ViewIssue(ctx *context.Context) {
 				mergeStyle = repo_model.MergeStyleRebaseMerge
 			} else if prConfig.AllowSquash {
 				mergeStyle = repo_model.MergeStyleSquash
+			} else if prConfig.AllowFastForwardOnly {
+				mergeStyle = repo_model.MergeStyleFastForwardOnly
 			} else if prConfig.AllowManualMerge {
 				mergeStyle = repo_model.MergeStyleManuallyMerged
 			}
diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go
index 8a429c359c..dcb4be7ef8 100644
--- a/routers/web/repo/setting/setting.go
+++ b/routers/web/repo/setting/setting.go
@@ -251,6 +251,7 @@ func UnitsPost(ctx *context.Context) {
 				AllowRebase:                   form.PullsAllowRebase,
 				AllowRebaseMerge:              form.PullsAllowRebaseMerge,
 				AllowSquash:                   form.PullsAllowSquash,
+				AllowFastForwardOnly:          form.PullsAllowFastForwardOnly,
 				AllowManualMerge:              form.PullsAllowManualMerge,
 				AutodetectManualMerge:         form.EnableAutodetectManualMerge,
 				AllowRebaseUpdate:             form.PullsAllowRebaseUpdate,
diff --git a/services/convert/repository.go b/services/convert/repository.go
index b032d97d73..466d19d563 100644
--- a/services/convert/repository.go
+++ b/services/convert/repository.go
@@ -93,6 +93,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
 	allowRebase := false
 	allowRebaseMerge := false
 	allowSquash := false
+	allowFastForwardOnly := false
 	allowRebaseUpdate := false
 	defaultDeleteBranchAfterMerge := false
 	defaultMergeStyle := repo_model.MergeStyleMerge
@@ -105,6 +106,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
 		allowRebase = config.AllowRebase
 		allowRebaseMerge = config.AllowRebaseMerge
 		allowSquash = config.AllowSquash
+		allowFastForwardOnly = config.AllowFastForwardOnly
 		allowRebaseUpdate = config.AllowRebaseUpdate
 		defaultDeleteBranchAfterMerge = config.DefaultDeleteBranchAfterMerge
 		defaultMergeStyle = config.GetDefaultMergeStyle()
@@ -220,6 +222,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
 		AllowRebase:                   allowRebase,
 		AllowRebaseMerge:              allowRebaseMerge,
 		AllowSquash:                   allowSquash,
+		AllowFastForwardOnly:          allowFastForwardOnly,
 		AllowRebaseUpdate:             allowRebaseUpdate,
 		DefaultDeleteBranchAfterMerge: defaultDeleteBranchAfterMerge,
 		DefaultMergeStyle:             string(defaultMergeStyle),
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index 9527916ae0..6ecb4ea768 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -170,6 +170,7 @@ type RepoUnitSettingForm struct {
 	PullsAllowRebase                      bool
 	PullsAllowRebaseMerge                 bool
 	PullsAllowSquash                      bool
+	PullsAllowFastForwardOnly             bool
 	PullsAllowManualMerge                 bool
 	PullsDefaultMergeStyle                string
 	EnableAutodetectManualMerge           bool
@@ -609,8 +610,8 @@ func (f *InitializeLabelsForm) Validate(req *http.Request, errs binding.Errors)
 // swagger:model MergePullRequestOption
 type MergePullRequestForm struct {
 	// required: true
-	// enum: merge,rebase,rebase-merge,squash,manually-merged
-	Do                     string `binding:"Required;In(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,fast-forward-only,manually-merged)"`
 	MergeTitleField        string
 	MergeMessageField      string
 	MergeCommitID          string // only used for manually-merged
diff --git a/services/pull/merge.go b/services/pull/merge.go
index 718e964014..f09067996c 100644
--- a/services/pull/merge.go
+++ b/services/pull/merge.go
@@ -273,6 +273,10 @@ func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *use
 		if err := doMergeStyleSquash(mergeCtx, message); err != nil {
 			return "", err
 		}
+	case repo_model.MergeStyleFastForwardOnly:
+		if err := doMergeStyleFastForwardOnly(mergeCtx); err != nil {
+			return "", err
+		}
 	default:
 		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(),
 				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())
 		return fmt.Errorf("git merge %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
diff --git a/services/pull/merge_ff_only.go b/services/pull/merge_ff_only.go
new file mode 100644
index 0000000000..f57c732104
--- /dev/null
+++ b/services/pull/merge_ff_only.go
@@ -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
+}
diff --git a/services/pull/merge_merge.go b/services/pull/merge_merge.go
index 0f7664297a..bf56c071db 100644
--- a/services/pull/merge_merge.go
+++ b/services/pull/merge_merge.go
@@ -9,7 +9,7 @@ import (
 	"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 {
 	cmd := git.NewCommand(ctx, "merge", "--no-ff", "--no-commit").AddDynamicArguments(trackingBranch)
 	if err := runMergeCommand(ctx, repo_model.MergeStyleMerge, cmd); err != nil {
diff --git a/templates/repo/issue/view_content/pull.tmpl b/templates/repo/issue/view_content/pull.tmpl
index 2b5776ea03..f1ab53eb67 100644
--- a/templates/repo/issue/view_content/pull.tmpl
+++ b/templates/repo/issue/view_content/pull.tmpl
@@ -197,7 +197,7 @@
 				{{if .AllowMerge}} {{/* user is allowed to merge */}}
 					{{$prUnit := .Repository.MustGetUnit $.Context $.UnitTypePullRequests}}
 					{{$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 := ""}}
 						{{if .HasPendingPullRequestMerge}}
 							{{$createdPRMergeStr := TimeSinceUnix .PendingPullRequestMerge.CreatedUnix ctx.Locale}}
@@ -268,6 +268,13 @@
 									'mergeMessageFieldText': {{.GetCommitMessages}} + defaultSquashMergeMessage,
 									'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',
 									'allowed': {{$prUnit.PullRequestsConfig.AllowManualMerge}},
diff --git a/templates/repo/issue/view_content/pull_merge_instruction.tmpl b/templates/repo/issue/view_content/pull_merge_instruction.tmpl
index 3dab44710e..a214f29786 100644
--- a/templates/repo/issue/view_content/pull_merge_instruction.tmpl
+++ b/templates/repo/issue/view_content/pull_merge_instruction.tmpl
@@ -35,6 +35,10 @@
 			<div>git checkout {{.PullRequest.BaseBranch}}</div>
 			<div>git merge --squash {{$localBranch}}</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>git checkout {{.PullRequest.BaseBranch}}</div>
 			<div>git merge {{$localBranch}}</div>
diff --git a/templates/repo/settings/units/pulls.tmpl b/templates/repo/settings/units/pulls.tmpl
index e735fe974c..4e9c53e0f4 100644
--- a/templates/repo/settings/units/pulls.tmpl
+++ b/templates/repo/settings/units/pulls.tmpl
@@ -42,6 +42,12 @@
 				<label>{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}</label>
 			</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="ui checkbox">
 				<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-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="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"}}
 				<div class="default text">
 					{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "merge")}}
@@ -73,12 +80,16 @@
 					{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "squash")}}
 						{{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}}
 					{{end}}
+					{{if (eq $prUnit.PullRequestsConfig.DefaultMergeStyle "fast-forward-only")}}
+						{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}
+					{{end}}
 				</div>
 				<div class="menu">
 					<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-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="fast-forward-only">{{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}}</div>
 				</div>
 			</div>
 		</div>
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 67e949f4f2..8a40cf76d4 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -19930,6 +19930,11 @@
       "description": "EditRepoOption options when editing a repository's properties",
       "type": "object",
       "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": {
           "description": "either `true` to allow mark pr as merged manually, or `false` to prevent it.",
           "type": "boolean",
@@ -19986,7 +19991,7 @@
           "x-go-name": "DefaultDeleteBranchAfterMerge"
         },
         "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",
           "x-go-name": "DefaultMergeStyle"
         },
@@ -21395,6 +21400,7 @@
             "rebase",
             "rebase-merge",
             "squash",
+            "fast-forward-only",
             "manually-merged"
           ]
         },
@@ -22795,6 +22801,10 @@
       "description": "Repository represents a repository",
       "type": "object",
       "properties": {
+        "allow_fast_forward_only_merge": {
+          "type": "boolean",
+          "x-go-name": "AllowFastForwardOnly"
+        },
         "allow_merge_commits": {
           "type": "boolean",
           "x-go-name": "AllowMerge"
diff --git a/tests/integration/api_repo_edit_test.go b/tests/integration/api_repo_edit_test.go
index c4fc2177b4..7de8910ee0 100644
--- a/tests/integration/api_repo_edit_test.go
+++ b/tests/integration/api_repo_edit_test.go
@@ -65,6 +65,7 @@ func getRepoEditOptionFromRepo(repo *repo_model.Repository) *api.EditRepoOption
 	allowRebase := false
 	allowRebaseMerge := false
 	allowSquash := false
+	allowFastForwardOnly := false
 	if unit, err := repo.GetUnit(db.DefaultContext, unit_model.TypePullRequests); err == nil {
 		config := unit.PullRequestsConfig()
 		hasPullRequests = true
@@ -73,6 +74,7 @@ func getRepoEditOptionFromRepo(repo *repo_model.Repository) *api.EditRepoOption
 		allowRebase = config.AllowRebase
 		allowRebaseMerge = config.AllowRebaseMerge
 		allowSquash = config.AllowSquash
+		allowFastForwardOnly = config.AllowFastForwardOnly
 	}
 	archived := repo.IsArchived
 	return &api.EditRepoOption{
@@ -92,6 +94,7 @@ func getRepoEditOptionFromRepo(repo *repo_model.Repository) *api.EditRepoOption
 		AllowRebase:               &allowRebase,
 		AllowRebaseMerge:          &allowRebaseMerge,
 		AllowSquash:               &allowSquash,
+		AllowFastForwardOnly:      &allowFastForwardOnly,
 		Archived:                  &archived,
 	}
 }
diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go
index 1f6695ec50..1c5c8f6d45 100644
--- a/tests/integration/pull_merge_test.go
+++ b/tests/integration/pull_merge_test.go
@@ -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) {
 	onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
 		user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})