diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 121ceb7152c5..c497f1be672b 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -682,6 +682,28 @@ LEVEL = Info
 ;; Disable the usage of using partial clones for git.
 ;DISABLE_PARTIAL_CLONE = false
 
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Git Operation timeout in seconds
+;[git.timeout]
+;DEFAULT = 360
+;MIGRATE = 600
+;MIRROR = 300
+;CLONE = 300
+;PULL = 300
+;GC = 60
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Git Reflog timeout in days
+;[git.reflog]
+;ENABLED = true
+;EXPIRATION = 90
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Git config options
+;; This section only does "set" config, a removed config key from this section won't be removed from git config automatically. The format is `some.configKey = value`.
+;[git.config]
+;diff.algorithm = histogram
+
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 [service]
@@ -2176,32 +2198,6 @@ LEVEL = Info
 ;Check at least this proportion of LFSMetaObjects per repo. (This may cause all stale LFSMetaObjects to be checked.)
 ;PROPORTION_TO_CHECK_PER_REPO = 0.6
 
-
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;; Git Operation timeout in seconds
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;[git.timeout]
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;DEFAULT = 360
-;MIGRATE = 600
-;MIRROR = 300
-;CLONE = 300
-;PULL = 300
-;GC = 60
-
-
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;; Git Reflog timeout in days
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;[git.reflog]
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;ENABLED = true
-;EXPIRATION = 90
-
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;[mirror]
diff --git a/docs/content/doc/administration/config-cheat-sheet.en-us.md b/docs/content/doc/administration/config-cheat-sheet.en-us.md
index 349c480ae6b9..035c94cd2771 100644
--- a/docs/content/doc/administration/config-cheat-sheet.en-us.md
+++ b/docs/content/doc/administration/config-cheat-sheet.en-us.md
@@ -1054,12 +1054,7 @@ Default templates for project boards:
 - `DISABLE_CORE_PROTECT_NTFS`: **false** Set to true to forcibly set `core.protectNTFS` to false.
 - `DISABLE_PARTIAL_CLONE`: **false** Disable the usage of using partial clones for git.
 
-## Git - Reflog settings (`git.reflog`)
-
-- `ENABLED`: **true** Set to true to enable Git to write changes to reflogs in each repo.
-- `EXPIRATION`: **90** Reflog entry lifetime, in days. Entries are removed opportunistically by Git.
-
-## Git - Timeout settings (`git.timeout`)
+### Git - Timeout settings (`git.timeout`)
 
 - `DEFAULT`: **360**: Git operations default timeout seconds.
 - `MIGRATE`: **600**: Migrate external repositories timeout seconds.
@@ -1068,6 +1063,18 @@ Default templates for project boards:
 - `PULL`: **300**: Git pull from internal repositories timeout seconds.
 - `GC`: **60**: Git repository GC timeout seconds.
 
+### Git - Reflog settings (`git.reflog`)
+
+- `ENABLED`: **true** Set to true to enable Git to write changes to reflogs in each repo.
+- `EXPIRATION`: **90** Reflog entry lifetime, in days. Entries are removed opportunistically by Git.
+
+### Git - Config options (`git.config`)
+
+The key/value pairs in this section will be used as git config.
+This section only does "set" config, a removed config key from this section won't be removed from git config automatically. The format is `some.configKey = value`.
+
+- `diff.algorithm`: **histogram**
+
 ## Metrics (`metrics`)
 
 - `ENABLED`: **false**: Enables /metrics endpoint for prometheus.
diff --git a/docs/content/doc/administration/customizing-gitea.en-us.md b/docs/content/doc/administration/customizing-gitea.en-us.md
index 54ce2a715f94..4c8b1c90d716 100644
--- a/docs/content/doc/administration/customizing-gitea.en-us.md
+++ b/docs/content/doc/administration/customizing-gitea.en-us.md
@@ -282,6 +282,22 @@ Place custom files in corresponding sub-folder under `custom/options`.
 
 To add custom .gitignore, add a file with existing [.gitignore rules](https://git-scm.com/docs/gitignore) in it to `$GITEA_CUSTOM/options/gitignore`
 
+## Customizing the git configuration
+
+Starting with Gitea 1.20, you can customize the git configuration via the `git.config` section.
+
+### Enabling signed git pushes
+
+To enable signed git pushes, set these two options:
+
+```ini
+[git.config]
+receive.advertisePushOptions = true
+receive.certNonceSeed = <randomstring>
+```
+
+`certNonceSeed` should be set to a random string and be kept secret.
+
 ### Labels
 
 Starting with Gitea 1.19, you can add a file that follows the [YAML label format](https://github.com/go-gitea/gitea/blob/main/options/label/Advanced.yaml) to `$GITEA_CUSTOM/options/label`:
diff --git a/modules/git/git.go b/modules/git/git.go
index a31afc077a5c..2e0a16fb5cb8 100644
--- a/modules/git/git.go
+++ b/modules/git/git.go
@@ -224,6 +224,14 @@ func syncGitConfig() (err error) {
 		return fmt.Errorf("unable to prepare git home directory %s, err: %w", HomeDir(), err)
 	}
 
+	// first, write user's git config options to git config file
+	// user config options could be overwritten by builtin values later, because if a value is builtin, it must have some special purposes
+	for k, v := range setting.GitConfig.Options {
+		if err = configSet(strings.ToLower(k), v); err != nil {
+			return err
+		}
+	}
+
 	// Git requires setting user.name and user.email in order to commit changes - old comment: "if they're not set just add some defaults"
 	// TODO: need to confirm whether users really need to change these values manually. It seems that these values are dummy only and not really used.
 	// If these values are not really used, then they can be set (overwritten) directly without considering about existence.
diff --git a/modules/git/git_test.go b/modules/git/git_test.go
index 25eb3085319d..37ab669ea45f 100644
--- a/modules/git/git_test.go
+++ b/modules/git/git_test.go
@@ -42,14 +42,14 @@ func TestMain(m *testing.M) {
 	}
 }
 
-func TestGitConfig(t *testing.T) {
-	gitConfigContains := func(sub string) bool {
-		if b, err := os.ReadFile(HomeDir() + "/.gitconfig"); err == nil {
-			return strings.Contains(string(b), sub)
-		}
-		return false
+func gitConfigContains(sub string) bool {
+	if b, err := os.ReadFile(HomeDir() + "/.gitconfig"); err == nil {
+		return strings.Contains(string(b), sub)
 	}
+	return false
+}
 
+func TestGitConfig(t *testing.T) {
 	assert.False(t, gitConfigContains("key-a"))
 
 	assert.NoError(t, configSetNonExist("test.key-a", "val-a"))
@@ -81,3 +81,15 @@ func TestGitConfig(t *testing.T) {
 	assert.NoError(t, configUnsetAll("test.key-x", "*"))
 	assert.False(t, gitConfigContains("key-x = *"))
 }
+
+func TestSyncConfig(t *testing.T) {
+	oldGitConfig := setting.GitConfig
+	defer func() {
+		setting.GitConfig = oldGitConfig
+	}()
+
+	setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA"
+	assert.NoError(t, syncGitConfig())
+	assert.True(t, gitConfigContains("[sync-test]"))
+	assert.True(t, gitConfigContains("cfg-key-a = CfgValA"))
+}
diff --git a/modules/setting/git.go b/modules/setting/git.go
index b8e7bb9cf81f..29ec37f866e5 100644
--- a/modules/setting/git.go
+++ b/modules/setting/git.go
@@ -5,6 +5,7 @@ package setting
 
 import (
 	"path/filepath"
+	"strings"
 	"time"
 
 	"code.gitea.io/gitea/modules/log"
@@ -78,12 +79,28 @@ var Git = struct {
 	},
 }
 
+var GitConfig = struct {
+	Options map[string]string
+}{
+	Options: make(map[string]string),
+}
+
 func loadGitFrom(rootCfg ConfigProvider) {
 	sec := rootCfg.Section("git")
 	if err := sec.MapTo(&Git); err != nil {
 		log.Fatal("Failed to map Git settings: %v", err)
 	}
 
+	secGitConfig := rootCfg.Section("git.config")
+	GitConfig.Options = make(map[string]string)
+	for _, key := range secGitConfig.Keys() {
+		// git config key is case-insensitive, so always use lower-case
+		GitConfig.Options[strings.ToLower(key.Name())] = key.String()
+	}
+	if _, ok := GitConfig.Options["diff.algorithm"]; !ok {
+		GitConfig.Options["diff.algorithm"] = "histogram"
+	}
+
 	Git.HomePath = sec.Key("HOME_PATH").MustString("home")
 	if !filepath.IsAbs(Git.HomePath) {
 		Git.HomePath = filepath.Join(AppDataPath, Git.HomePath)
diff --git a/modules/setting/git_test.go b/modules/setting/git_test.go
new file mode 100644
index 000000000000..1da8c87cc86b
--- /dev/null
+++ b/modules/setting/git_test.go
@@ -0,0 +1,40 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGitConfig(t *testing.T) {
+	oldGit := Git
+	oldGitConfig := GitConfig
+	defer func() {
+		Git = oldGit
+		GitConfig = oldGitConfig
+	}()
+
+	cfg, err := NewConfigProviderFromData(`
+[git.config]
+a.b = 1
+`)
+	assert.NoError(t, err)
+	loadGitFrom(cfg)
+
+	assert.Len(t, GitConfig.Options, 2)
+	assert.EqualValues(t, "1", GitConfig.Options["a.b"])
+	assert.EqualValues(t, "histogram", GitConfig.Options["diff.algorithm"])
+
+	cfg, err = NewConfigProviderFromData(`
+[git.config]
+diff.algorithm = other
+`)
+	assert.NoError(t, err)
+	loadGitFrom(cfg)
+
+	assert.Len(t, GitConfig.Options, 1)
+	assert.EqualValues(t, "other", GitConfig.Options["diff.algorithm"])
+}