From 32b97b3ce8eeccdcfd51280f83e659a29a18caa2 Mon Sep 17 00:00:00 2001
From: Lunny Xiao <xiaolunwen@gmail.com>
Date: Tue, 8 Apr 2025 09:15:28 -0700
Subject: [PATCH 01/14] Uniform all temporary directories and allow customizing
 temp path (#32352)

This PR uniform all temporary directory usage so that it will be easier
to manage.

Relate to #31792

- [x] Added a new setting to allow users to configure the global
temporary directory.
- [x] Move all temporary files and directories to be placed under
os.Temp()/gitea.
- [x] `setting.Repository.Local.LocalCopyPath` now will be
`setting.TempPath/local-repo` and the customized path is removed.
```diff
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;[repository.local]
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;;
-;; Path for local repository copy. Defaults to  TEMP_PATH + `local-repo`, this is deprecated and cannot be changed
-;LOCAL_COPY_PATH = local-repo
```

- [x] `setting.Repository.Upload.TempPath` now will be
`settting.TempPath/uploads` and the customized path is removed.
```diff
;[repository.upload]
-;;
-;; Path for uploads. Defaults to TEMP_PATH + `uploads`
-;TEMP_PATH = uploads
```

- [x] `setting.Packages.ChunkedUploadPath` now will be
`settting.TempPath/package-upload` and the customized path is removed.
```diff
;[packages]
-;;
-;; Path for chunked uploads. Defaults it's `package-upload` under `TEMP_PATH` unless it's an absolute path.
-;CHUNKED_UPLOAD_PATH = package-upload
```

- [x] `setting.SSH.KeyTestPath` now will be
`settting.TempPath/ssh_key_test` and the customized path is removed.
```diff
[server]
-;;
-;; Directory to create temporary files in when testing public keys using ssh-keygen,
-;; default is the system temporary directory.
-;SSH_KEY_TEST_PATH =
```

TODO:
- [ ] setting.PprofDataPath haven't been changed because it may need to
be kept until somebody read it but temp path may be clean up any time.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 cmd/web.go                                    |   4 +
 custom/conf/app.example.ini                   |  25 +---
 models/asymkey/ssh_key_fingerprint.go         |  44 +------
 models/asymkey/ssh_key_parse.go               |  71 +----------
 models/asymkey/ssh_key_test.go                |  30 -----
 models/issues/issue_update.go                 |   1 +
 models/migrations/base/tests.go               |   9 +-
 models/repo/upload.go                         |  10 +-
 models/unittest/testdb.go                     |  22 ++--
 modules/git/blame.go                          |  70 ++++++-----
 modules/git/blame_sha256_test.go              |   3 +
 modules/git/blame_test.go                     |   3 +
 modules/git/git_test.go                       |   7 +-
 modules/git/repo.go                           |   5 +-
 modules/git/repo_index.go                     |  17 +--
 modules/git/repo_language_stats_test.go       |   3 +
 modules/markup/external/external.go           |  11 +-
 modules/packages/hashed_buffer.go             |   5 +-
 modules/packages/hashed_buffer_test.go        |   3 +
 .../packages/nuget/symbol_extractor_test.go   |   3 +
 modules/repository/temp.go                    |  32 +----
 modules/setting/packages.go                   |  12 +-
 modules/setting/path.go                       |  16 +++
 modules/setting/repository.go                 |  21 ----
 modules/setting/server.go                     |  16 +++
 modules/setting/ssh.go                        |   6 -
 modules/ssh/init.go                           |   5 -
 modules/tempdir/tempdir.go                    | 112 ++++++++++++++++++
 modules/tempdir/tempdir_test.go               |  75 ++++++++++++
 modules/templates/util_render_test.go         |   2 +-
 modules/util/filebuffer/file_backed_buffer.go |  35 +-----
 .../filebuffer/file_backed_buffer_test.go     |   3 +-
 options/locale/locale_en-US.ini               |   2 -
 routers/web/repo/githttp.go                   |  10 +-
 routers/web/repo/setting/lfs.go               |   8 +-
 services/pull/patch.go                        |   8 +-
 services/pull/temp_repo.go                    |   9 +-
 services/repository/create.go                 |  11 +-
 services/repository/files/temp_repo.go        |   9 +-
 services/repository/generate.go               |  12 +-
 services/repository/repository.go             |   3 -
 services/wiki/wiki.go                         |  16 +--
 templates/admin/config.tmpl                   |   4 -
 .../migration-test/migration_test.go          |   2 +-
 tests/test_utils.go                           |   4 +-
 45 files changed, 361 insertions(+), 418 deletions(-)
 create mode 100644 modules/tempdir/tempdir.go
 create mode 100644 modules/tempdir/tempdir_test.go

diff --git a/cmd/web.go b/cmd/web.go
index dc5c6de48a..e47b171455 100644
--- a/cmd/web.go
+++ b/cmd/web.go
@@ -213,6 +213,10 @@ func serveInstalled(ctx *cli.Context) error {
 		log.Fatal("Can not find APP_DATA_PATH %q", setting.AppDataPath)
 	}
 
+	// the AppDataTempDir is fully managed by us with a safe sub-path
+	// so it's safe to automatically remove the outdated files
+	setting.AppDataTempDir("").RemoveOutdated(3 * 24 * time.Hour)
+
 	// Override the provided port number within the configuration
 	if ctx.IsSet("port") {
 		if err := setPort(ctx.String("port")); err != nil {
diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 07a6ebdcf2..d1124371e0 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -197,13 +197,6 @@ RUN_USER = ; git
 ;; relative paths are made absolute relative to the APP_DATA_PATH
 ;SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gogs.rsa
 ;;
-;; Directory to create temporary files in when testing public keys using ssh-keygen,
-;; default is the system temporary directory.
-;SSH_KEY_TEST_PATH =
-;;
-;; Use `ssh-keygen` to parse public SSH keys. The value is passed to the shell. By default, Gitea does the parsing itself.
-;SSH_KEYGEN_PATH =
-;;
 ;; Enable SSH Authorized Key Backup when rewriting all keys, default is false
 ;SSH_AUTHORIZED_KEYS_BACKUP = false
 ;;
@@ -294,6 +287,9 @@ RUN_USER = ; git
 ;; Default path for App data
 ;APP_DATA_PATH = data ; relative paths will be made absolute with _`AppWorkPath`_
 ;;
+;; Base path for App's temp files, leave empty to use the managed tmp directory in APP_DATA_PATH
+;APP_TEMP_PATH =
+;;
 ;; Enable gzip compression for runtime-generated content, static resources excluded
 ;ENABLE_GZIP = false
 ;;
@@ -1069,15 +1065,6 @@ LEVEL = Info
 ;; Separate extensions with a comma. To line wrap files without an extension, just put a comma
 ;LINE_WRAP_EXTENSIONS = .txt,.md,.markdown,.mdown,.mkd,.livemd,
 
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;[repository.local]
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;;
-;; Path for local repository copy. Defaults to `tmp/local-repo` (content gets deleted on gitea restart)
-;LOCAL_COPY_PATH = tmp/local-repo
-
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;[repository.upload]
@@ -1087,9 +1074,6 @@ LEVEL = Info
 ;; Whether repository file uploads are enabled. Defaults to `true`
 ;ENABLED = true
 ;;
-;; Path for uploads. Defaults to `data/tmp/uploads` (content gets deleted on gitea restart)
-;TEMP_PATH = data/tmp/uploads
-;;
 ;; Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
 ;ALLOWED_TYPES =
 ;;
@@ -2594,9 +2578,6 @@ LEVEL = Info
 ;; Currently, only `minio` and `azureblob` is supported.
 ;SERVE_DIRECT = false
 ;;
-;; Path for chunked uploads. Defaults to APP_DATA_PATH + `tmp/package-upload`
-;CHUNKED_UPLOAD_PATH = tmp/package-upload
-;;
 ;; Maximum count of package versions a single owner can have (`-1` means no limits)
 ;LIMIT_TOTAL_OWNER_COUNT = -1
 ;; Maximum size of packages a single owner can use (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
diff --git a/models/asymkey/ssh_key_fingerprint.go b/models/asymkey/ssh_key_fingerprint.go
index 1ed3b5df2a..4dcfe1f279 100644
--- a/models/asymkey/ssh_key_fingerprint.go
+++ b/models/asymkey/ssh_key_fingerprint.go
@@ -6,27 +6,13 @@ package asymkey
 import (
 	"context"
 	"fmt"
-	"strings"
 
 	"code.gitea.io/gitea/models/db"
-	"code.gitea.io/gitea/modules/log"
-	"code.gitea.io/gitea/modules/process"
-	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/util"
 
 	"golang.org/x/crypto/ssh"
 	"xorm.io/builder"
 )
 
-// ___________.__                                         .__        __
-// \_   _____/|__| ____    ____   ________________________|__| _____/  |_
-//  |    __)  |  |/    \  / ___\_/ __ \_  __ \____ \_  __ \  |/    \   __\
-//  |     \   |  |   |  \/ /_/  >  ___/|  | \/  |_> >  | \/  |   |  \  |
-//  \___  /   |__|___|  /\___  / \___  >__|  |   __/|__|  |__|___|  /__|
-//      \/            \//_____/      \/      |__|                 \/
-//
-// This file contains functions for fingerprinting SSH keys
-//
 // The database is used in checkKeyFingerprint however most of these functions probably belong in a module
 
 // checkKeyFingerprint only checks if key fingerprint has been used as public key,
@@ -41,29 +27,6 @@ func checkKeyFingerprint(ctx context.Context, fingerprint string) error {
 	return nil
 }
 
-func calcFingerprintSSHKeygen(publicKeyContent string) (string, error) {
-	// Calculate fingerprint.
-	tmpPath, err := writeTmpKeyFile(publicKeyContent)
-	if err != nil {
-		return "", err
-	}
-	defer func() {
-		if err := util.Remove(tmpPath); err != nil {
-			log.Warn("Unable to remove temporary key file: %s: Error: %v", tmpPath, err)
-		}
-	}()
-	stdout, stderr, err := process.GetManager().Exec("AddPublicKey", "ssh-keygen", "-lf", tmpPath)
-	if err != nil {
-		if strings.Contains(stderr, "is not a public key file") {
-			return "", ErrKeyUnableVerify{stderr}
-		}
-		return "", util.NewInvalidArgumentErrorf("'ssh-keygen -lf %s' failed with error '%s': %s", tmpPath, err, stderr)
-	} else if len(stdout) < 2 {
-		return "", util.NewInvalidArgumentErrorf("not enough output for calculating fingerprint: %s", stdout)
-	}
-	return strings.Split(stdout, " ")[1], nil
-}
-
 func calcFingerprintNative(publicKeyContent string) (string, error) {
 	// Calculate fingerprint.
 	pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publicKeyContent))
@@ -75,15 +38,12 @@ func calcFingerprintNative(publicKeyContent string) (string, error) {
 
 // CalcFingerprint calculate public key's fingerprint
 func CalcFingerprint(publicKeyContent string) (string, error) {
-	// Call the method based on configuration
-	useNative := setting.SSH.KeygenPath == ""
-	calcFn := util.Iif(useNative, calcFingerprintNative, calcFingerprintSSHKeygen)
-	fp, err := calcFn(publicKeyContent)
+	fp, err := calcFingerprintNative(publicKeyContent)
 	if err != nil {
 		if IsErrKeyUnableVerify(err) {
 			return "", err
 		}
-		return "", fmt.Errorf("CalcFingerprint(%s): %w", util.Iif(useNative, "native", "ssh-keygen"), err)
+		return "", fmt.Errorf("CalcFingerprint: %w", err)
 	}
 	return fp, nil
 }
diff --git a/models/asymkey/ssh_key_parse.go b/models/asymkey/ssh_key_parse.go
index c843525718..46dcf4d894 100644
--- a/models/asymkey/ssh_key_parse.go
+++ b/models/asymkey/ssh_key_parse.go
@@ -13,12 +13,9 @@ import (
 	"errors"
 	"fmt"
 	"math/big"
-	"os"
-	"strconv"
 	"strings"
 
 	"code.gitea.io/gitea/modules/log"
-	"code.gitea.io/gitea/modules/process"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/util"
 
@@ -175,20 +172,9 @@ func CheckPublicKeyString(content string) (_ string, err error) {
 		return content, nil
 	}
 
-	var (
-		fnName  string
-		keyType string
-		length  int
-	)
-	if len(setting.SSH.KeygenPath) == 0 {
-		fnName = "SSHNativeParsePublicKey"
-		keyType, length, err = SSHNativeParsePublicKey(content)
-	} else {
-		fnName = "SSHKeyGenParsePublicKey"
-		keyType, length, err = SSHKeyGenParsePublicKey(content)
-	}
+	keyType, length, err := SSHNativeParsePublicKey(content)
 	if err != nil {
-		return "", fmt.Errorf("%s: %w", fnName, err)
+		return "", fmt.Errorf("SSHNativeParsePublicKey: %w", err)
 	}
 	log.Trace("Key info [native: %v]: %s-%d", setting.SSH.StartBuiltinServer, keyType, length)
 
@@ -258,56 +244,3 @@ func SSHNativeParsePublicKey(keyLine string) (string, int, error) {
 	}
 	return "", 0, fmt.Errorf("unsupported key length detection for type: %s", pkey.Type())
 }
-
-// writeTmpKeyFile writes key content to a temporary file
-// and returns the name of that file, along with any possible errors.
-func writeTmpKeyFile(content string) (string, error) {
-	tmpFile, err := os.CreateTemp(setting.SSH.KeyTestPath, "gitea_keytest")
-	if err != nil {
-		return "", fmt.Errorf("TempFile: %w", err)
-	}
-	defer tmpFile.Close()
-
-	if _, err = tmpFile.WriteString(content); err != nil {
-		return "", fmt.Errorf("WriteString: %w", err)
-	}
-	return tmpFile.Name(), nil
-}
-
-// SSHKeyGenParsePublicKey extracts key type and length using ssh-keygen.
-func SSHKeyGenParsePublicKey(key string) (string, int, error) {
-	tmpName, err := writeTmpKeyFile(key)
-	if err != nil {
-		return "", 0, fmt.Errorf("writeTmpKeyFile: %w", err)
-	}
-	defer func() {
-		if err := util.Remove(tmpName); err != nil {
-			log.Warn("Unable to remove temporary key file: %s: Error: %v", tmpName, err)
-		}
-	}()
-
-	keygenPath := setting.SSH.KeygenPath
-	if len(keygenPath) == 0 {
-		keygenPath = "ssh-keygen"
-	}
-
-	stdout, stderr, err := process.GetManager().Exec("SSHKeyGenParsePublicKey", keygenPath, "-lf", tmpName)
-	if err != nil {
-		return "", 0, fmt.Errorf("fail to parse public key: %s - %s", err, stderr)
-	}
-	if strings.Contains(stdout, "is not a public key file") {
-		return "", 0, ErrKeyUnableVerify{stdout}
-	}
-
-	fields := strings.Split(stdout, " ")
-	if len(fields) < 4 {
-		return "", 0, fmt.Errorf("invalid public key line: %s", stdout)
-	}
-
-	keyType := strings.Trim(fields[len(fields)-1], "()\r\n")
-	length, err := strconv.ParseInt(fields[0], 10, 32)
-	if err != nil {
-		return "", 0, err
-	}
-	return strings.ToLower(keyType), int(length), nil
-}
diff --git a/models/asymkey/ssh_key_test.go b/models/asymkey/ssh_key_test.go
index b33d16030d..21e4ddf62e 100644
--- a/models/asymkey/ssh_key_test.go
+++ b/models/asymkey/ssh_key_test.go
@@ -18,7 +18,6 @@ import (
 
 	"github.com/42wim/sshsig"
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
 )
 
 func Test_SSHParsePublicKey(t *testing.T) {
@@ -45,27 +44,6 @@ func Test_SSHParsePublicKey(t *testing.T) {
 				assert.Equal(t, tc.keyType, keyTypeN)
 				assert.Equal(t, tc.length, lengthN)
 			})
-			if tc.skipSSHKeygen {
-				return
-			}
-			t.Run("SSHKeygen", func(t *testing.T) {
-				keyTypeK, lengthK, err := SSHKeyGenParsePublicKey(tc.content)
-				if err != nil {
-					// Some servers do not support ecdsa format.
-					if !strings.Contains(err.Error(), "line 1 too long:") {
-						require.NoError(t, err)
-					}
-				}
-				assert.Equal(t, tc.keyType, keyTypeK)
-				assert.Equal(t, tc.length, lengthK)
-			})
-			t.Run("SSHParseKeyNative", func(t *testing.T) {
-				keyTypeK, lengthK, err := SSHNativeParsePublicKey(tc.content)
-				require.NoError(t, err)
-
-				assert.Equal(t, tc.keyType, keyTypeK)
-				assert.Equal(t, tc.length, lengthK)
-			})
 		})
 	}
 }
@@ -186,14 +164,6 @@ func Test_calcFingerprint(t *testing.T) {
 				assert.NoError(t, err)
 				assert.Equal(t, tc.fp, fpN)
 			})
-			if tc.skipSSHKeygen {
-				return
-			}
-			t.Run("SSHKeygen", func(t *testing.T) {
-				fpK, err := calcFingerprintSSHKeygen(tc.content)
-				assert.NoError(t, err)
-				assert.Equal(t, tc.fp, fpK)
-			})
 		})
 	}
 }
diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go
index 5e9c012c05..7ddf7ee901 100644
--- a/models/issues/issue_update.go
+++ b/models/issues/issue_update.go
@@ -845,6 +845,7 @@ func DeleteOrphanedIssues(ctx context.Context) error {
 
 	// Remove issue attachment files.
 	for i := range attachmentPaths {
+		// FIXME: it's not right, because the attachment might not be on local filesystem
 		system_model.RemoveAllWithNotice(ctx, "Delete issue attachment", attachmentPaths[i])
 	}
 	return nil
diff --git a/models/migrations/base/tests.go b/models/migrations/base/tests.go
index fe6de9c517..7da426fef0 100644
--- a/models/migrations/base/tests.go
+++ b/models/migrations/base/tests.go
@@ -15,6 +15,7 @@ import (
 	"code.gitea.io/gitea/models/unittest"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/tempdir"
 	"code.gitea.io/gitea/modules/test"
 	"code.gitea.io/gitea/modules/testlogger"
 
@@ -114,15 +115,16 @@ func MainTest(m *testing.M) {
 		setting.CustomConf = giteaConf
 	}
 
-	tmpDataPath, err := os.MkdirTemp("", "data")
+	tmpDataPath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("data")
 	if err != nil {
 		testlogger.Fatalf("Unable to create temporary data path %v\n", err)
 	}
+	defer cleanup()
 
 	setting.CustomPath = filepath.Join(setting.AppWorkPath, "custom")
 	setting.AppDataPath = tmpDataPath
 
-	unittest.InitSettings()
+	unittest.InitSettingsForTesting()
 	if err = git.InitFull(context.Background()); err != nil {
 		testlogger.Fatalf("Unable to InitFull: %v\n", err)
 	}
@@ -134,8 +136,5 @@ func MainTest(m *testing.M) {
 	if err := removeAllWithRetry(setting.RepoRootPath); err != nil {
 		fmt.Fprintf(os.Stderr, "os.RemoveAll: %v\n", err)
 	}
-	if err := removeAllWithRetry(tmpDataPath); err != nil {
-		fmt.Fprintf(os.Stderr, "os.RemoveAll: %v\n", err)
-	}
 	os.Exit(exitStatus)
 }
diff --git a/models/repo/upload.go b/models/repo/upload.go
index cae11df96a..fb57fb6c51 100644
--- a/models/repo/upload.go
+++ b/models/repo/upload.go
@@ -51,14 +51,10 @@ func init() {
 	db.RegisterModel(new(Upload))
 }
 
-// UploadLocalPath returns where uploads is stored in local file system based on given UUID.
-func UploadLocalPath(uuid string) string {
-	return filepath.Join(setting.Repository.Upload.TempPath, uuid[0:1], uuid[1:2], uuid)
-}
-
-// LocalPath returns where uploads are temporarily stored in local file system.
+// LocalPath returns where uploads are temporarily stored in local file system based on given UUID.
 func (upload *Upload) LocalPath() string {
-	return UploadLocalPath(upload.UUID)
+	uuid := upload.UUID
+	return setting.AppDataTempDir("repo-uploads").JoinPath(uuid[0:1], uuid[1:2], uuid)
 }
 
 // NewUpload creates a new upload object.
diff --git a/models/unittest/testdb.go b/models/unittest/testdb.go
index 7a9ca9698d..cb60cf5f85 100644
--- a/models/unittest/testdb.go
+++ b/models/unittest/testdb.go
@@ -20,6 +20,7 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/setting/config"
 	"code.gitea.io/gitea/modules/storage"
+	"code.gitea.io/gitea/modules/tempdir"
 	"code.gitea.io/gitea/modules/test"
 	"code.gitea.io/gitea/modules/util"
 
@@ -35,8 +36,8 @@ func fatalTestError(fmtStr string, args ...any) {
 	os.Exit(1)
 }
 
-// InitSettings initializes config provider and load common settings for tests
-func InitSettings() {
+// InitSettingsForTesting initializes config provider and load common settings for tests
+func InitSettingsForTesting() {
 	setting.IsInTesting = true
 	log.OsExiter = func(code int) {
 		if code != 0 {
@@ -75,7 +76,7 @@ func MainTest(m *testing.M, testOptsArg ...*TestOptions) {
 	testOpts := util.OptionalArg(testOptsArg, &TestOptions{})
 	giteaRoot = test.SetupGiteaRoot()
 	setting.CustomPath = filepath.Join(giteaRoot, "custom")
-	InitSettings()
+	InitSettingsForTesting()
 
 	fixturesOpts := FixturesOptions{Dir: filepath.Join(giteaRoot, "models", "fixtures"), Files: testOpts.FixtureFiles}
 	if err := CreateTestEngine(fixturesOpts); err != nil {
@@ -92,15 +93,19 @@ func MainTest(m *testing.M, testOptsArg ...*TestOptions) {
 	setting.SSH.Domain = "try.gitea.io"
 	setting.Database.Type = "sqlite3"
 	setting.Repository.DefaultBranch = "master" // many test code still assume that default branch is called "master"
-	repoRootPath, err := os.MkdirTemp(os.TempDir(), "repos")
+	repoRootPath, cleanup1, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("repos")
 	if err != nil {
 		fatalTestError("TempDir: %v\n", err)
 	}
+	defer cleanup1()
+
 	setting.RepoRootPath = repoRootPath
-	appDataPath, err := os.MkdirTemp(os.TempDir(), "appdata")
+	appDataPath, cleanup2, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("appdata")
 	if err != nil {
 		fatalTestError("TempDir: %v\n", err)
 	}
+	defer cleanup2()
+
 	setting.AppDataPath = appDataPath
 	setting.AppWorkPath = giteaRoot
 	setting.StaticRootPath = giteaRoot
@@ -153,13 +158,6 @@ func MainTest(m *testing.M, testOptsArg ...*TestOptions) {
 			fatalTestError("tear down failed: %v\n", err)
 		}
 	}
-
-	if err = util.RemoveAll(repoRootPath); err != nil {
-		fatalTestError("util.RemoveAll: %v\n", err)
-	}
-	if err = util.RemoveAll(appDataPath); err != nil {
-		fatalTestError("util.RemoveAll: %v\n", err)
-	}
 	os.Exit(exitStatus)
 }
 
diff --git a/modules/git/blame.go b/modules/git/blame.go
index d1d732c716..6eb583a6b9 100644
--- a/modules/git/blame.go
+++ b/modules/git/blame.go
@@ -11,7 +11,7 @@ import (
 	"os"
 
 	"code.gitea.io/gitea/modules/log"
-	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/modules/setting"
 )
 
 // BlamePart represents block of blame - continuous lines with one sha
@@ -29,12 +29,13 @@ type BlameReader struct {
 	bufferedReader *bufio.Reader
 	done           chan error
 	lastSha        *string
-	ignoreRevsFile *string
+	ignoreRevsFile string
 	objectFormat   ObjectFormat
+	cleanupFuncs   []func()
 }
 
 func (r *BlameReader) UsesIgnoreRevs() bool {
-	return r.ignoreRevsFile != nil
+	return r.ignoreRevsFile != ""
 }
 
 // NextPart returns next part of blame (sequential code lines with the same commit)
@@ -122,36 +123,37 @@ func (r *BlameReader) Close() error {
 	r.bufferedReader = nil
 	_ = r.reader.Close()
 	_ = r.output.Close()
-	if r.ignoreRevsFile != nil {
-		_ = util.Remove(*r.ignoreRevsFile)
+	for _, cleanup := range r.cleanupFuncs {
+		if cleanup != nil {
+			cleanup()
+		}
 	}
 	return err
 }
 
 // CreateBlameReader creates reader for given repository, commit and file
 func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repoPath string, commit *Commit, file string, bypassBlameIgnore bool) (*BlameReader, error) {
-	var ignoreRevsFile *string
-	if DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore {
-		ignoreRevsFile = tryCreateBlameIgnoreRevsFile(commit)
-	}
-
-	cmd := NewCommandNoGlobals("blame", "--porcelain")
-	if ignoreRevsFile != nil {
-		// Possible improvement: use --ignore-revs-file /dev/stdin on unix
-		// There is no equivalent on Windows. May be implemented if Gitea uses an external git backend.
-		cmd.AddOptionValues("--ignore-revs-file", *ignoreRevsFile)
-	}
-	cmd.AddDynamicArguments(commit.ID.String()).AddDashesAndList(file)
 	reader, stdout, err := os.Pipe()
 	if err != nil {
-		if ignoreRevsFile != nil {
-			_ = util.Remove(*ignoreRevsFile)
-		}
 		return nil, err
 	}
 
-	done := make(chan error, 1)
+	cmd := NewCommandNoGlobals("blame", "--porcelain")
 
+	var ignoreRevsFileName string
+	var ignoreRevsFileCleanup func() // TODO: maybe it should check the returned err in a defer func to make sure the cleanup could always be executed correctly
+	if DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore {
+		ignoreRevsFileName, ignoreRevsFileCleanup = tryCreateBlameIgnoreRevsFile(commit)
+		if ignoreRevsFileName != "" {
+			// Possible improvement: use --ignore-revs-file /dev/stdin on unix
+			// There is no equivalent on Windows. May be implemented if Gitea uses an external git backend.
+			cmd.AddOptionValues("--ignore-revs-file", ignoreRevsFileName)
+		}
+	}
+
+	cmd.AddDynamicArguments(commit.ID.String()).AddDashesAndList(file)
+
+	done := make(chan error, 1)
 	go func() {
 		stderr := bytes.Buffer{}
 		// TODO: it doesn't work for directories (the directories shouldn't be "blamed"), and the "err" should be returned by "Read" but not by "Close"
@@ -169,40 +171,44 @@ func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repoPath
 	}()
 
 	bufferedReader := bufio.NewReader(reader)
-
 	return &BlameReader{
 		output:         stdout,
 		reader:         reader,
 		bufferedReader: bufferedReader,
 		done:           done,
-		ignoreRevsFile: ignoreRevsFile,
+		ignoreRevsFile: ignoreRevsFileName,
 		objectFormat:   objectFormat,
+		cleanupFuncs:   []func(){ignoreRevsFileCleanup},
 	}, nil
 }
 
-func tryCreateBlameIgnoreRevsFile(commit *Commit) *string {
+func tryCreateBlameIgnoreRevsFile(commit *Commit) (string, func()) {
 	entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs")
 	if err != nil {
-		return nil
+		log.Error("Unable to get .git-blame-ignore-revs file: GetTreeEntryByPath: %v", err)
+		return "", nil
 	}
 
 	r, err := entry.Blob().DataAsync()
 	if err != nil {
-		return nil
+		log.Error("Unable to get .git-blame-ignore-revs file data: DataAsync: %v", err)
+		return "", nil
 	}
 	defer r.Close()
 
-	f, err := os.CreateTemp("", "gitea_git-blame-ignore-revs")
+	f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("git-blame-ignore-revs")
 	if err != nil {
-		return nil
+		log.Error("Unable to get .git-blame-ignore-revs file data: CreateTempFileRandom: %v", err)
+		return "", nil
 	}
-
+	filename := f.Name()
 	_, err = io.Copy(f, r)
 	_ = f.Close()
 	if err != nil {
-		_ = util.Remove(f.Name())
-		return nil
+		cleanup()
+		log.Error("Unable to get .git-blame-ignore-revs file data: Copy: %v", err)
+		return "", nil
 	}
 
-	return util.ToPointer(f.Name())
+	return filename, cleanup
 }
diff --git a/modules/git/blame_sha256_test.go b/modules/git/blame_sha256_test.go
index 99c23429e2..c0a97bed3b 100644
--- a/modules/git/blame_sha256_test.go
+++ b/modules/git/blame_sha256_test.go
@@ -7,10 +7,13 @@ import (
 	"context"
 	"testing"
 
+	"code.gitea.io/gitea/modules/setting"
+
 	"github.com/stretchr/testify/assert"
 )
 
 func TestReadingBlameOutputSha256(t *testing.T) {
+	setting.AppDataPath = t.TempDir()
 	ctx, cancel := context.WithCancel(t.Context())
 	defer cancel()
 
diff --git a/modules/git/blame_test.go b/modules/git/blame_test.go
index 36b5fb9349..809d6fbcf7 100644
--- a/modules/git/blame_test.go
+++ b/modules/git/blame_test.go
@@ -7,10 +7,13 @@ import (
 	"context"
 	"testing"
 
+	"code.gitea.io/gitea/modules/setting"
+
 	"github.com/stretchr/testify/assert"
 )
 
 func TestReadingBlameOutput(t *testing.T) {
+	setting.AppDataPath = t.TempDir()
 	ctx, cancel := context.WithCancel(t.Context())
 	defer cancel()
 
diff --git a/modules/git/git_test.go b/modules/git/git_test.go
index 5472842b76..58ba01cabc 100644
--- a/modules/git/git_test.go
+++ b/modules/git/git_test.go
@@ -10,18 +10,19 @@ import (
 	"testing"
 
 	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/modules/tempdir"
 
 	"github.com/hashicorp/go-version"
 	"github.com/stretchr/testify/assert"
 )
 
 func testRun(m *testing.M) error {
-	gitHomePath, err := os.MkdirTemp(os.TempDir(), "git-home")
+	gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home")
 	if err != nil {
 		return fmt.Errorf("unable to create temp dir: %w", err)
 	}
-	defer util.RemoveAll(gitHomePath)
+	defer cleanup()
+
 	setting.Git.HomePath = gitHomePath
 
 	if err = InitFull(context.Background()); err != nil {
diff --git a/modules/git/repo.go b/modules/git/repo.go
index 6459adf851..45937a8d5f 100644
--- a/modules/git/repo.go
+++ b/modules/git/repo.go
@@ -18,6 +18,7 @@ import (
 	"time"
 
 	"code.gitea.io/gitea/modules/proxy"
+	"code.gitea.io/gitea/modules/setting"
 )
 
 // GPGSettings represents the default GPG settings for this repository
@@ -266,11 +267,11 @@ func GetDivergingCommits(ctx context.Context, repoPath, baseBranch, targetBranch
 
 // CreateBundle create bundle content to the target path
 func (repo *Repository) CreateBundle(ctx context.Context, commit string, out io.Writer) error {
-	tmp, err := os.MkdirTemp(os.TempDir(), "gitea-bundle")
+	tmp, cleanup, err := setting.AppDataTempDir("git-repo-content").MkdirTempRandom("gitea-bundle")
 	if err != nil {
 		return err
 	}
-	defer os.RemoveAll(tmp)
+	defer cleanup()
 
 	env := append(os.Environ(), "GIT_OBJECT_DIRECTORY="+filepath.Join(repo.Path, "objects"))
 	_, _, err = NewCommand("init", "--bare").RunStdString(ctx, &RunOpts{Dir: tmp, Env: env})
diff --git a/modules/git/repo_index.go b/modules/git/repo_index.go
index 1c7fcc063e..443a3a20d1 100644
--- a/modules/git/repo_index.go
+++ b/modules/git/repo_index.go
@@ -10,8 +10,7 @@ import (
 	"path/filepath"
 	"strings"
 
-	"code.gitea.io/gitea/modules/log"
-	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/modules/setting"
 )
 
 // ReadTreeToIndex reads a treeish to the index
@@ -59,26 +58,18 @@ func (repo *Repository) ReadTreeToTemporaryIndex(treeish string) (tmpIndexFilena
 		}
 	}()
 
-	removeDirFn := func(dir string) func() { // it can't use the return value "tmpDir" directly because it is empty when error occurs
-		return func() {
-			if err := util.RemoveAll(dir); err != nil {
-				log.Error("failed to remove tmp index dir: %v", err)
-			}
-		}
-	}
-
-	tmpDir, err = os.MkdirTemp("", "index")
+	tmpDir, cancel, err = setting.AppDataTempDir("git-repo-content").MkdirTempRandom("index")
 	if err != nil {
 		return "", "", nil, err
 	}
 
 	tmpIndexFilename = filepath.Join(tmpDir, ".tmp-index")
-	cancel = removeDirFn(tmpDir)
+
 	err = repo.ReadTreeToIndex(treeish, tmpIndexFilename)
 	if err != nil {
 		return "", "", cancel, err
 	}
-	return tmpIndexFilename, tmpDir, cancel, err
+	return tmpIndexFilename, tmpDir, cancel, nil
 }
 
 // EmptyIndex empties the index
diff --git a/modules/git/repo_language_stats_test.go b/modules/git/repo_language_stats_test.go
index 81f130bacb..12ce958c6e 100644
--- a/modules/git/repo_language_stats_test.go
+++ b/modules/git/repo_language_stats_test.go
@@ -9,11 +9,14 @@ import (
 	"path/filepath"
 	"testing"
 
+	"code.gitea.io/gitea/modules/setting"
+
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
 func TestRepository_GetLanguageStats(t *testing.T) {
+	setting.AppDataPath = t.TempDir()
 	repoPath := filepath.Join(testReposDir, "language_stats_repo")
 	gitRepo, err := openRepositoryWithDefaultContext(repoPath)
 	require.NoError(t, err)
diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go
index f708457853..39861ade12 100644
--- a/modules/markup/external/external.go
+++ b/modules/markup/external/external.go
@@ -12,11 +12,9 @@ import (
 	"runtime"
 	"strings"
 
-	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/markup"
 	"code.gitea.io/gitea/modules/process"
 	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/util"
 )
 
 // RegisterRenderers registers all supported third part renderers according settings
@@ -88,16 +86,11 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
 
 	if p.IsInputFile {
 		// write to temp file
-		f, err := os.CreateTemp("", "gitea_input")
+		f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
 		if err != nil {
 			return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
 		}
-		tmpPath := f.Name()
-		defer func() {
-			if err := util.Remove(tmpPath); err != nil {
-				log.Warn("Unable to remove temporary file: %s: Error: %v", tmpPath, err)
-			}
-		}()
+		defer cleanup()
 
 		_, err = io.Copy(f, input)
 		if err != nil {
diff --git a/modules/packages/hashed_buffer.go b/modules/packages/hashed_buffer.go
index 4ab45edcec..0cd657cd44 100644
--- a/modules/packages/hashed_buffer.go
+++ b/modules/packages/hashed_buffer.go
@@ -6,6 +6,7 @@ package packages
 import (
 	"io"
 
+	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/util/filebuffer"
 )
 
@@ -34,11 +35,11 @@ func NewHashedBuffer() (*HashedBuffer, error) {
 
 // NewHashedBufferWithSize creates a hashed buffer with a specific memory size
 func NewHashedBufferWithSize(maxMemorySize int) (*HashedBuffer, error) {
-	b, err := filebuffer.New(maxMemorySize)
+	tempDir, err := setting.AppDataTempDir("package-hashed-buffer").MkdirAllSub("")
 	if err != nil {
 		return nil, err
 	}
-
+	b := filebuffer.New(maxMemorySize, tempDir)
 	hash := NewMultiHasher()
 
 	combinedWriter := io.MultiWriter(b, hash)
diff --git a/modules/packages/hashed_buffer_test.go b/modules/packages/hashed_buffer_test.go
index 564e782f18..5104c1fb25 100644
--- a/modules/packages/hashed_buffer_test.go
+++ b/modules/packages/hashed_buffer_test.go
@@ -9,10 +9,13 @@ import (
 	"strings"
 	"testing"
 
+	"code.gitea.io/gitea/modules/setting"
+
 	"github.com/stretchr/testify/assert"
 )
 
 func TestHashedBuffer(t *testing.T) {
+	setting.AppDataPath = t.TempDir()
 	cases := []struct {
 		MaxMemorySize int
 		Data          string
diff --git a/modules/packages/nuget/symbol_extractor_test.go b/modules/packages/nuget/symbol_extractor_test.go
index fa1b80ee82..711ad6d096 100644
--- a/modules/packages/nuget/symbol_extractor_test.go
+++ b/modules/packages/nuget/symbol_extractor_test.go
@@ -9,6 +9,8 @@ import (
 	"encoding/base64"
 	"testing"
 
+	"code.gitea.io/gitea/modules/setting"
+
 	"github.com/stretchr/testify/assert"
 )
 
@@ -17,6 +19,7 @@ fgAA3AEAAAQAAAAjU3RyaW5ncwAAAADgAQAABAAAACNVUwDkAQAAMAAAACNHVUlEAAAAFAIAACgB
 AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`
 
 func TestExtractPortablePdb(t *testing.T) {
+	setting.AppDataPath = t.TempDir()
 	createArchive := func(name string, content []byte) []byte {
 		var buf bytes.Buffer
 		archive := zip.NewWriter(&buf)
diff --git a/modules/repository/temp.go b/modules/repository/temp.go
index 76b9bda4ad..d7253d9e02 100644
--- a/modules/repository/temp.go
+++ b/modules/repository/temp.go
@@ -4,41 +4,19 @@
 package repository
 
 import (
+	"context"
 	"fmt"
-	"os"
-	"path/filepath"
 
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/util"
 )
 
-// LocalCopyPath returns the local repository temporary copy path.
-func LocalCopyPath() string {
-	if filepath.IsAbs(setting.Repository.Local.LocalCopyPath) {
-		return setting.Repository.Local.LocalCopyPath
-	}
-	return filepath.Join(setting.AppDataPath, setting.Repository.Local.LocalCopyPath)
-}
-
 // CreateTemporaryPath creates a temporary path
-func CreateTemporaryPath(prefix string) (string, error) {
-	if err := os.MkdirAll(LocalCopyPath(), os.ModePerm); err != nil {
-		log.Error("Unable to create localcopypath directory: %s (%v)", LocalCopyPath(), err)
-		return "", fmt.Errorf("Failed to create localcopypath directory %s: %w", LocalCopyPath(), err)
-	}
-	basePath, err := os.MkdirTemp(LocalCopyPath(), prefix+".git")
+func CreateTemporaryPath(prefix string) (string, context.CancelFunc, error) {
+	basePath, cleanup, err := setting.AppDataTempDir("local-repo").MkdirTempRandom(prefix + ".git")
 	if err != nil {
 		log.Error("Unable to create temporary directory: %s-*.git (%v)", prefix, err)
-		return "", fmt.Errorf("Failed to create dir %s-*.git: %w", prefix, err)
+		return "", nil, fmt.Errorf("failed to create dir %s-*.git: %w", prefix, err)
 	}
-	return basePath, nil
-}
-
-// RemoveTemporaryPath removes the temporary path
-func RemoveTemporaryPath(basePath string) error {
-	if _, err := os.Stat(basePath); !os.IsNotExist(err) {
-		return util.RemoveAll(basePath)
-	}
-	return nil
+	return basePath, cleanup, nil
 }
diff --git a/modules/setting/packages.go b/modules/setting/packages.go
index 3f618cfd64..845f6d4e12 100644
--- a/modules/setting/packages.go
+++ b/modules/setting/packages.go
@@ -6,8 +6,6 @@ package setting
 import (
 	"fmt"
 	"math"
-	"os"
-	"path/filepath"
 
 	"github.com/dustin/go-humanize"
 )
@@ -67,14 +65,10 @@ func loadPackagesFrom(rootCfg ConfigProvider) (err error) {
 		return err
 	}
 
-	Packages.ChunkedUploadPath = filepath.ToSlash(sec.Key("CHUNKED_UPLOAD_PATH").MustString("tmp/package-upload"))
-	if !filepath.IsAbs(Packages.ChunkedUploadPath) {
-		Packages.ChunkedUploadPath = filepath.ToSlash(filepath.Join(AppDataPath, Packages.ChunkedUploadPath))
-	}
-
 	if HasInstallLock(rootCfg) {
-		if err := os.MkdirAll(Packages.ChunkedUploadPath, os.ModePerm); err != nil {
-			return fmt.Errorf("unable to create chunked upload directory: %s (%v)", Packages.ChunkedUploadPath, err)
+		Packages.ChunkedUploadPath, err = AppDataTempDir("package-upload").MkdirAllSub("")
+		if err != nil {
+			return fmt.Errorf("unable to create chunked upload directory: %w", err)
 		}
 	}
 
diff --git a/modules/setting/path.go b/modules/setting/path.go
index 0fdc305aa1..f51457a620 100644
--- a/modules/setting/path.go
+++ b/modules/setting/path.go
@@ -11,6 +11,7 @@ import (
 	"strings"
 
 	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/tempdir"
 )
 
 var (
@@ -196,3 +197,18 @@ func InitWorkPathAndCfgProvider(getEnvFn func(name string) string, args ArgWorkP
 	CustomPath = tmpCustomPath.Value
 	CustomConf = tmpCustomConf.Value
 }
+
+// AppDataTempDir returns a managed temporary directory for the application data.
+// Using empty sub will get the managed base temp directory, and it's safe to delete it.
+// Gitea only creates subdirectories under it, but not the APP_TEMP_PATH directory itself.
+// * When APP_TEMP_PATH="/tmp": the managed temp directory is "/tmp/gitea-tmp"
+// * When APP_TEMP_PATH is not set: the managed temp directory is "/{APP_DATA_PATH}/tmp"
+func AppDataTempDir(sub string) *tempdir.TempDir {
+	if appTempPathInternal != "" {
+		return tempdir.New(appTempPathInternal, "gitea-tmp/"+sub)
+	}
+	if AppDataPath == "" {
+		panic("setting.AppDataPath is not set")
+	}
+	return tempdir.New(AppDataPath, "tmp/"+sub)
+}
diff --git a/modules/setting/repository.go b/modules/setting/repository.go
index 43bfb3256d..f99c854e4f 100644
--- a/modules/setting/repository.go
+++ b/modules/setting/repository.go
@@ -62,17 +62,11 @@ var (
 		// Repository upload settings
 		Upload struct {
 			Enabled      bool
-			TempPath     string
 			AllowedTypes string
 			FileMaxSize  int64
 			MaxFiles     int
 		} `ini:"-"`
 
-		// Repository local settings
-		Local struct {
-			LocalCopyPath string
-		} `ini:"-"`
-
 		// Pull request settings
 		PullRequest struct {
 			WorkInProgressPrefixes                   []string
@@ -181,25 +175,16 @@ var (
 		// Repository upload settings
 		Upload: struct {
 			Enabled      bool
-			TempPath     string
 			AllowedTypes string
 			FileMaxSize  int64
 			MaxFiles     int
 		}{
 			Enabled:      true,
-			TempPath:     "data/tmp/uploads",
 			AllowedTypes: "",
 			FileMaxSize:  50,
 			MaxFiles:     5,
 		},
 
-		// Repository local settings
-		Local: struct {
-			LocalCopyPath string
-		}{
-			LocalCopyPath: "tmp/local-repo",
-		},
-
 		// Pull request settings
 		PullRequest: struct {
 			WorkInProgressPrefixes                   []string
@@ -308,8 +293,6 @@ func loadRepositoryFrom(rootCfg ConfigProvider) {
 		log.Fatal("Failed to map Repository.Editor settings: %v", err)
 	} else if err = rootCfg.Section("repository.upload").MapTo(&Repository.Upload); err != nil {
 		log.Fatal("Failed to map Repository.Upload settings: %v", err)
-	} else if err = rootCfg.Section("repository.local").MapTo(&Repository.Local); err != nil {
-		log.Fatal("Failed to map Repository.Local settings: %v", err)
 	} else if err = rootCfg.Section("repository.pull-request").MapTo(&Repository.PullRequest); err != nil {
 		log.Fatal("Failed to map Repository.PullRequest settings: %v", err)
 	}
@@ -361,10 +344,6 @@ func loadRepositoryFrom(rootCfg ConfigProvider) {
 		}
 	}
 
-	if !filepath.IsAbs(Repository.Upload.TempPath) {
-		Repository.Upload.TempPath = filepath.Join(AppWorkPath, Repository.Upload.TempPath)
-	}
-
 	if err := loadRepoArchiveFrom(rootCfg); err != nil {
 		log.Fatal("loadRepoArchiveFrom: %v", err)
 	}
diff --git a/modules/setting/server.go b/modules/setting/server.go
index e15b790906..ca635c8abe 100644
--- a/modules/setting/server.go
+++ b/modules/setting/server.go
@@ -7,6 +7,7 @@ import (
 	"encoding/base64"
 	"net"
 	"net/url"
+	"os"
 	"path/filepath"
 	"strconv"
 	"strings"
@@ -59,6 +60,8 @@ var (
 	// AssetVersion holds a opaque value that is used for cache-busting assets
 	AssetVersion string
 
+	appTempPathInternal string // the temporary path for the app, it is only an internal variable, do not use it, always use AppDataTempDir
+
 	Protocol                   Scheme
 	UseProxyProtocol           bool // `ini:"USE_PROXY_PROTOCOL"`
 	ProxyProtocolTLSBridging   bool //`ini:"PROXY_PROTOCOL_TLS_BRIDGING"`
@@ -330,6 +333,19 @@ func loadServerFrom(rootCfg ConfigProvider) {
 	if !filepath.IsAbs(AppDataPath) {
 		AppDataPath = filepath.ToSlash(filepath.Join(AppWorkPath, AppDataPath))
 	}
+	if IsInTesting && HasInstallLock(rootCfg) {
+		// FIXME: in testing, the "app data" directory is not correctly initialized before loading settings
+		if _, err := os.Stat(AppDataPath); err != nil {
+			_ = os.MkdirAll(AppDataPath, os.ModePerm)
+		}
+	}
+
+	appTempPathInternal = sec.Key("APP_TEMP_PATH").String()
+	if appTempPathInternal != "" {
+		if _, err := os.Stat(appTempPathInternal); err != nil {
+			log.Fatal("APP_TEMP_PATH %q is not accessible: %v", appTempPathInternal, err)
+		}
+	}
 
 	EnableGzip = sec.Key("ENABLE_GZIP").MustBool()
 	EnablePprof = sec.Key("ENABLE_PPROF").MustBool(false)
diff --git a/modules/setting/ssh.go b/modules/setting/ssh.go
index 46eb49bfd4..da8cdf58d2 100644
--- a/modules/setting/ssh.go
+++ b/modules/setting/ssh.go
@@ -4,7 +4,6 @@
 package setting
 
 import (
-	"os"
 	"path/filepath"
 	"strings"
 	"text/template"
@@ -31,8 +30,6 @@ var SSH = struct {
 	ServerKeyExchanges                    []string           `ini:"SSH_SERVER_KEY_EXCHANGES"`
 	ServerMACs                            []string           `ini:"SSH_SERVER_MACS"`
 	ServerHostKeys                        []string           `ini:"SSH_SERVER_HOST_KEYS"`
-	KeyTestPath                           string             `ini:"SSH_KEY_TEST_PATH"`
-	KeygenPath                            string             `ini:"SSH_KEYGEN_PATH"`
 	AuthorizedKeysBackup                  bool               `ini:"SSH_AUTHORIZED_KEYS_BACKUP"`
 	AuthorizedPrincipalsBackup            bool               `ini:"SSH_AUTHORIZED_PRINCIPALS_BACKUP"`
 	AuthorizedKeysCommandTemplate         string             `ini:"SSH_AUTHORIZED_KEYS_COMMAND_TEMPLATE"`
@@ -57,7 +54,6 @@ var SSH = struct {
 	ServerCiphers:                 []string{"chacha20-poly1305@openssh.com", "aes128-ctr", "aes192-ctr", "aes256-ctr", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com"},
 	ServerKeyExchanges:            []string{"curve25519-sha256", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1"},
 	ServerMACs:                    []string{"hmac-sha2-256-etm@openssh.com", "hmac-sha2-256", "hmac-sha1"},
-	KeygenPath:                    "",
 	MinimumKeySizeCheck:           true,
 	MinimumKeySizes:               map[string]int{"ed25519": 256, "ed25519-sk": 256, "ecdsa": 256, "ecdsa-sk": 256, "rsa": 3071},
 	ServerHostKeys:                []string{"ssh/gitea.rsa", "ssh/gogs.rsa"},
@@ -123,7 +119,6 @@ func loadSSHFrom(rootCfg ConfigProvider) {
 	if len(serverMACs) > 0 {
 		SSH.ServerMACs = serverMACs
 	}
-	SSH.KeyTestPath = os.TempDir()
 	if err = sec.MapTo(&SSH); err != nil {
 		log.Fatal("Failed to map SSH settings: %v", err)
 	}
@@ -133,7 +128,6 @@ func loadSSHFrom(rootCfg ConfigProvider) {
 		}
 	}
 
-	SSH.KeygenPath = sec.Key("SSH_KEYGEN_PATH").String()
 	SSH.Port = sec.Key("SSH_PORT").MustInt(22)
 	SSH.ListenPort = sec.Key("SSH_LISTEN_PORT").MustInt(SSH.Port)
 	SSH.UseProxyProtocol = sec.Key("SSH_SERVER_USE_PROXY_PROTOCOL").MustBool(false)
diff --git a/modules/ssh/init.go b/modules/ssh/init.go
index 21d4f89936..fdc11632e2 100644
--- a/modules/ssh/init.go
+++ b/modules/ssh/init.go
@@ -32,11 +32,6 @@ func Init() error {
 
 	builtinUnused()
 
-	// FIXME: why 0o644 for a directory .....
-	if err := os.MkdirAll(setting.SSH.KeyTestPath, 0o644); err != nil {
-		return fmt.Errorf("failed to create directory %q for ssh key test: %w", setting.SSH.KeyTestPath, err)
-	}
-
 	if len(setting.SSH.TrustedUserCAKeys) > 0 && setting.SSH.AuthorizedPrincipalsEnabled {
 		caKeysFileName := setting.SSH.TrustedUserCAKeysFile
 		caKeysFileDir := filepath.Dir(caKeysFileName)
diff --git a/modules/tempdir/tempdir.go b/modules/tempdir/tempdir.go
new file mode 100644
index 0000000000..22c2e4ea16
--- /dev/null
+++ b/modules/tempdir/tempdir.go
@@ -0,0 +1,112 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package tempdir
+
+import (
+	"os"
+	"path/filepath"
+	"time"
+
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/util"
+)
+
+type TempDir struct {
+	// base is the base directory for temporary files, it must exist before accessing and won't be created automatically.
+	// for example: base="/system-tmpdir", sub="gitea-tmp"
+	base, sub string
+}
+
+func (td *TempDir) JoinPath(elems ...string) string {
+	return filepath.Join(append([]string{td.base, td.sub}, elems...)...)
+}
+
+// MkdirAllSub works like os.MkdirAll, but the base directory must exist
+func (td *TempDir) MkdirAllSub(dir string) (string, error) {
+	if _, err := os.Stat(td.base); err != nil {
+		return "", err
+	}
+	full := filepath.Join(td.base, td.sub, dir)
+	if err := os.MkdirAll(full, os.ModePerm); err != nil {
+		return "", err
+	}
+	return full, nil
+}
+
+func (td *TempDir) prepareDirWithPattern(elems ...string) (dir, pattern string, err error) {
+	if _, err = os.Stat(td.base); err != nil {
+		return "", "", err
+	}
+	dir, pattern = filepath.Split(filepath.Join(append([]string{td.base, td.sub}, elems...)...))
+	if err = os.MkdirAll(dir, os.ModePerm); err != nil {
+		return "", "", err
+	}
+	return dir, pattern, nil
+}
+
+// MkdirTempRandom works like os.MkdirTemp, the last path field is the "pattern"
+func (td *TempDir) MkdirTempRandom(elems ...string) (string, func(), error) {
+	dir, pattern, err := td.prepareDirWithPattern(elems...)
+	if err != nil {
+		return "", nil, err
+	}
+	dir, err = os.MkdirTemp(dir, pattern)
+	if err != nil {
+		return "", nil, err
+	}
+	return dir, func() {
+		if err := util.RemoveAll(dir); err != nil {
+			log.Error("Failed to remove temp directory %s: %v", dir, err)
+		}
+	}, nil
+}
+
+// CreateTempFileRandom works like os.CreateTemp, the last path field is the "pattern"
+func (td *TempDir) CreateTempFileRandom(elems ...string) (*os.File, func(), error) {
+	dir, pattern, err := td.prepareDirWithPattern(elems...)
+	if err != nil {
+		return nil, nil, err
+	}
+	f, err := os.CreateTemp(dir, pattern)
+	if err != nil {
+		return nil, nil, err
+	}
+	filename := f.Name()
+	return f, func() {
+		_ = f.Close()
+		if err := util.Remove(filename); err != nil {
+			log.Error("Unable to remove temporary file: %s: Error: %v", filename, err)
+		}
+	}, err
+}
+
+func (td *TempDir) RemoveOutdated(d time.Duration) {
+	var remove func(path string)
+	remove = func(path string) {
+		entries, _ := os.ReadDir(path)
+		for _, entry := range entries {
+			full := filepath.Join(path, entry.Name())
+			if entry.IsDir() {
+				remove(full)
+				_ = os.Remove(full)
+				continue
+			}
+			info, err := entry.Info()
+			if err == nil && time.Since(info.ModTime()) > d {
+				_ = os.Remove(full)
+			}
+		}
+	}
+	remove(td.JoinPath(""))
+}
+
+// New create a new TempDir instance, "base" must be an existing directory,
+// "sub" could be a multi-level directory and will be created if not exist
+func New(base, sub string) *TempDir {
+	return &TempDir{base: base, sub: sub}
+}
+
+func OsTempDir(sub string) *TempDir {
+	return New(os.TempDir(), sub)
+}
diff --git a/modules/tempdir/tempdir_test.go b/modules/tempdir/tempdir_test.go
new file mode 100644
index 0000000000..d6afcb7bed
--- /dev/null
+++ b/modules/tempdir/tempdir_test.go
@@ -0,0 +1,75 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package tempdir
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestTempDir(t *testing.T) {
+	base := t.TempDir()
+
+	t.Run("Create", func(t *testing.T) {
+		td := New(base, "sub1/sub2") // make sure the sub dir supports "/" in the path
+		assert.Equal(t, filepath.Join(base, "sub1", "sub2"), td.JoinPath())
+		assert.Equal(t, filepath.Join(base, "sub1", "sub2/test"), td.JoinPath("test"))
+
+		t.Run("MkdirTempRandom", func(t *testing.T) {
+			s, cleanup, err := td.MkdirTempRandom("foo")
+			assert.NoError(t, err)
+			assert.True(t, strings.HasPrefix(s, filepath.Join(base, "sub1/sub2", "foo")))
+
+			_, err = os.Stat(s)
+			assert.NoError(t, err)
+			cleanup()
+			_, err = os.Stat(s)
+			assert.ErrorIs(t, err, os.ErrNotExist)
+		})
+
+		t.Run("CreateTempFileRandom", func(t *testing.T) {
+			f, cleanup, err := td.CreateTempFileRandom("foo", "bar")
+			filename := f.Name()
+			assert.NoError(t, err)
+			assert.True(t, strings.HasPrefix(filename, filepath.Join(base, "sub1/sub2", "foo", "bar")))
+			_, err = os.Stat(filename)
+			assert.NoError(t, err)
+			cleanup()
+			_, err = os.Stat(filename)
+			assert.ErrorIs(t, err, os.ErrNotExist)
+		})
+
+		t.Run("RemoveOutDated", func(t *testing.T) {
+			fa1, _, err := td.CreateTempFileRandom("dir-a", "f1")
+			assert.NoError(t, err)
+			fa2, _, err := td.CreateTempFileRandom("dir-a", "f2")
+			assert.NoError(t, err)
+			_ = os.Chtimes(fa2.Name(), time.Now().Add(-time.Hour), time.Now().Add(-time.Hour))
+			fb1, _, err := td.CreateTempFileRandom("dir-b", "f1")
+			assert.NoError(t, err)
+			_ = os.Chtimes(fb1.Name(), time.Now().Add(-time.Hour), time.Now().Add(-time.Hour))
+			_, _, _ = fa1.Close(), fa2.Close(), fb1.Close()
+
+			td.RemoveOutdated(time.Minute)
+
+			_, err = os.Stat(fa1.Name())
+			assert.NoError(t, err)
+			_, err = os.Stat(fa2.Name())
+			assert.ErrorIs(t, err, os.ErrNotExist)
+			_, err = os.Stat(fb1.Name())
+			assert.ErrorIs(t, err, os.ErrNotExist)
+		})
+	})
+
+	t.Run("BaseNotExist", func(t *testing.T) {
+		td := New(filepath.Join(base, "not-exist"), "sub")
+		_, _, err := td.MkdirTempRandom("foo")
+		assert.ErrorIs(t, err, os.ErrNotExist)
+	})
+}
diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go
index 26cd1eb348..460b9dc190 100644
--- a/modules/templates/util_render_test.go
+++ b/modules/templates/util_render_test.go
@@ -56,7 +56,7 @@ var testMetas = map[string]string{
 }
 
 func TestMain(m *testing.M) {
-	unittest.InitSettings()
+	unittest.InitSettingsForTesting()
 	if err := git.InitSimple(context.Background()); err != nil {
 		log.Fatal("git init failed, err: %v", err)
 	}
diff --git a/modules/util/filebuffer/file_backed_buffer.go b/modules/util/filebuffer/file_backed_buffer.go
index 739543e297..0731ba30c8 100644
--- a/modules/util/filebuffer/file_backed_buffer.go
+++ b/modules/util/filebuffer/file_backed_buffer.go
@@ -7,16 +7,10 @@ import (
 	"bytes"
 	"errors"
 	"io"
-	"math"
 	"os"
 )
 
-var (
-	// ErrInvalidMemorySize occurs if the memory size is not in a valid range
-	ErrInvalidMemorySize = errors.New("Memory size must be greater 0 and lower math.MaxInt32")
-	// ErrWriteAfterRead occurs if Write is called after a read operation
-	ErrWriteAfterRead = errors.New("Write is unsupported after a read operation")
-)
+var ErrWriteAfterRead = errors.New("write is unsupported after a read operation") // occurs if Write is called after a read operation
 
 type readAtSeeker interface {
 	io.ReadSeeker
@@ -30,34 +24,17 @@ type FileBackedBuffer struct {
 	maxMemorySize int64
 	size          int64
 	buffer        bytes.Buffer
+	tempDir       string
 	file          *os.File
 	reader        readAtSeeker
 }
 
 // New creates a file backed buffer with a specific maximum memory size
-func New(maxMemorySize int) (*FileBackedBuffer, error) {
-	if maxMemorySize < 0 || maxMemorySize > math.MaxInt32 {
-		return nil, ErrInvalidMemorySize
-	}
-
+func New(maxMemorySize int, tempDir string) *FileBackedBuffer {
 	return &FileBackedBuffer{
 		maxMemorySize: int64(maxMemorySize),
-	}, nil
-}
-
-// CreateFromReader creates a file backed buffer and copies the provided reader data into it.
-func CreateFromReader(r io.Reader, maxMemorySize int) (*FileBackedBuffer, error) {
-	b, err := New(maxMemorySize)
-	if err != nil {
-		return nil, err
+		tempDir:       tempDir,
 	}
-
-	_, err = io.Copy(b, r)
-	if err != nil {
-		return nil, err
-	}
-
-	return b, nil
 }
 
 // Write implements io.Writer
@@ -73,7 +50,7 @@ func (b *FileBackedBuffer) Write(p []byte) (int, error) {
 		n, err = b.file.Write(p)
 	} else {
 		if b.size+int64(len(p)) > b.maxMemorySize {
-			b.file, err = os.CreateTemp("", "gitea-buffer-")
+			b.file, err = os.CreateTemp(b.tempDir, "gitea-buffer-")
 			if err != nil {
 				return 0, err
 			}
@@ -148,7 +125,7 @@ func (b *FileBackedBuffer) Seek(offset int64, whence int) (int64, error) {
 func (b *FileBackedBuffer) Close() error {
 	if b.file != nil {
 		err := b.file.Close()
-		os.Remove(b.file.Name())
+		_ = os.Remove(b.file.Name())
 		b.file = nil
 		return err
 	}
diff --git a/modules/util/filebuffer/file_backed_buffer_test.go b/modules/util/filebuffer/file_backed_buffer_test.go
index 16d5a1965f..3f13c6ac7b 100644
--- a/modules/util/filebuffer/file_backed_buffer_test.go
+++ b/modules/util/filebuffer/file_backed_buffer_test.go
@@ -21,7 +21,8 @@ func TestFileBackedBuffer(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		buf, err := CreateFromReader(strings.NewReader(c.Data), c.MaxMemorySize)
+		buf := New(c.MaxMemorySize, t.TempDir())
+		_, err := io.Copy(buf, strings.NewReader(c.Data))
 		assert.NoError(t, err)
 
 		assert.EqualValues(t, len(c.Data), buf.Size())
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 96c99615f5..d7da975a21 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -3287,8 +3287,6 @@ config.ssh_domain = SSH Server Domain
 config.ssh_port = Port
 config.ssh_listen_port = Listen Port
 config.ssh_root_path = Root Path
-config.ssh_key_test_path = Key Test Path
-config.ssh_keygen_path = Keygen ('ssh-keygen') Path
 config.ssh_minimum_key_size_check = Minimum Key Size Check
 config.ssh_minimum_key_sizes = Minimum Key Sizes
 
diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go
index f4ac9d769b..61606f8c5f 100644
--- a/routers/web/repo/githttp.go
+++ b/routers/web/repo/githttp.go
@@ -29,7 +29,6 @@ import (
 	repo_module "code.gitea.io/gitea/modules/repository"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/structs"
-	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/services/context"
 	repo_service "code.gitea.io/gitea/services/repository"
 
@@ -303,17 +302,12 @@ var (
 
 func dummyInfoRefs(ctx *context.Context) {
 	infoRefsOnce.Do(func() {
-		tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-info-refs-cache")
+		tmpDir, cleanup, err := setting.AppDataTempDir("git-repo-content").MkdirTempRandom("gitea-info-refs-cache")
 		if err != nil {
 			log.Error("Failed to create temp dir for git-receive-pack cache: %v", err)
 			return
 		}
-
-		defer func() {
-			if err := util.RemoveAll(tmpDir); err != nil {
-				log.Error("RemoveAll: %v", err)
-			}
-		}()
+		defer cleanup()
 
 		if err := git.InitRepository(ctx, tmpDir, true, git.Sha1ObjectFormat.Name()); err != nil {
 			log.Error("Failed to init bare repo for git-receive-pack cache: %v", err)
diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go
index 655291d25c..efda9bda58 100644
--- a/routers/web/repo/setting/lfs.go
+++ b/routers/web/repo/setting/lfs.go
@@ -109,17 +109,13 @@ func LFSLocks(ctx *context.Context) {
 	}
 
 	// Clone base repo.
-	tmpBasePath, err := repo_module.CreateTemporaryPath("locks")
+	tmpBasePath, cleanup, err := repo_module.CreateTemporaryPath("locks")
 	if err != nil {
 		log.Error("Failed to create temporary path: %v", err)
 		ctx.ServerError("LFSLocks", err)
 		return
 	}
-	defer func() {
-		if err := repo_module.RemoveTemporaryPath(tmpBasePath); err != nil {
-			log.Error("LFSLocks: RemoveTemporaryPath: %v", err)
-		}
-	}()
+	defer cleanup()
 
 	if err := git.Clone(ctx, ctx.Repo.Repository.RepoPath(), tmpBasePath, git.CloneRepoOptions{
 		Bare:   true,
diff --git a/services/pull/patch.go b/services/pull/patch.go
index 68f3f02669..7a24237724 100644
--- a/services/pull/patch.go
+++ b/services/pull/patch.go
@@ -355,23 +355,19 @@ func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo *
 	}
 
 	// 3b. Create a plain patch from head to base
-	tmpPatchFile, err := os.CreateTemp("", "patch")
+	tmpPatchFile, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("patch")
 	if err != nil {
 		log.Error("Unable to create temporary patch file! Error: %v", err)
 		return false, fmt.Errorf("unable to create temporary patch file! Error: %w", err)
 	}
-	defer func() {
-		_ = util.Remove(tmpPatchFile.Name())
-	}()
+	defer cleanup()
 
 	if err := gitRepo.GetDiffBinary(pr.MergeBase+"...tracking", tmpPatchFile); err != nil {
-		tmpPatchFile.Close()
 		log.Error("Unable to get patch file from %s to %s in %s Error: %v", pr.MergeBase, pr.HeadBranch, pr.BaseRepo.FullName(), err)
 		return false, fmt.Errorf("unable to get patch file from %s to %s in %s Error: %w", pr.MergeBase, pr.HeadBranch, pr.BaseRepo.FullName(), err)
 	}
 	stat, err := tmpPatchFile.Stat()
 	if err != nil {
-		tmpPatchFile.Close()
 		return false, fmt.Errorf("unable to stat patch file: %w", err)
 	}
 	patchPath := tmpPatchFile.Name()
diff --git a/services/pull/temp_repo.go b/services/pull/temp_repo.go
index 3f33370798..d543e3d4a3 100644
--- a/services/pull/temp_repo.go
+++ b/services/pull/temp_repo.go
@@ -74,11 +74,13 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest)
 	}
 
 	// Clone base repo.
-	tmpBasePath, err := repo_module.CreateTemporaryPath("pull")
+	tmpBasePath, cleanup, err := repo_module.CreateTemporaryPath("pull")
 	if err != nil {
 		log.Error("CreateTemporaryPath[%-v]: %v", pr, err)
 		return nil, nil, err
 	}
+	cancel = cleanup
+
 	prCtx = &prContext{
 		Context:     ctx,
 		tmpBasePath: tmpBasePath,
@@ -86,11 +88,6 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest)
 		outbuf:      &strings.Builder{},
 		errbuf:      &strings.Builder{},
 	}
-	cancel = func() {
-		if err := repo_module.RemoveTemporaryPath(tmpBasePath); err != nil {
-			log.Error("Error whilst removing removing temporary repo for %-v: %v", pr, err)
-		}
-	}
 
 	baseRepoPath := pr.BaseRepo.RepoPath()
 	headRepoPath := pr.HeadRepo.RepoPath()
diff --git a/services/repository/create.go b/services/repository/create.go
index 050544ce11..5a9b2ecd2a 100644
--- a/services/repository/create.go
+++ b/services/repository/create.go
@@ -29,7 +29,6 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/templates/vars"
-	"code.gitea.io/gitea/modules/util"
 )
 
 // CreateRepoOptions contains the create repository options
@@ -150,15 +149,11 @@ func initRepository(ctx context.Context, u *user_model.User, repo *repo_model.Re
 
 	// Initialize repository according to user's choice.
 	if opts.AutoInit {
-		tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-"+repo.Name)
+		tmpDir, cleanup, err := setting.AppDataTempDir("git-repo-content").MkdirTempRandom("repos-" + repo.Name)
 		if err != nil {
-			return fmt.Errorf("Failed to create temp dir for repository %s: %w", repo.FullName(), err)
+			return fmt.Errorf("failed to create temp dir for repository %s: %w", repo.FullName(), err)
 		}
-		defer func() {
-			if err := util.RemoveAll(tmpDir); err != nil {
-				log.Warn("Unable to remove temporary directory: %s: Error: %v", tmpDir, err)
-			}
-		}()
+		defer cleanup()
 
 		if err = prepareRepoCommit(ctx, repo, tmpDir, opts); err != nil {
 			return fmt.Errorf("prepareRepoCommit: %w", err)
diff --git a/services/repository/files/temp_repo.go b/services/repository/files/temp_repo.go
index 1969676ab4..493ff9998d 100644
--- a/services/repository/files/temp_repo.go
+++ b/services/repository/files/temp_repo.go
@@ -30,23 +30,24 @@ type TemporaryUploadRepository struct {
 	repo     *repo_model.Repository
 	gitRepo  *git.Repository
 	basePath string
+	cleanup  func()
 }
 
 // NewTemporaryUploadRepository creates a new temporary upload repository
 func NewTemporaryUploadRepository(repo *repo_model.Repository) (*TemporaryUploadRepository, error) {
-	basePath, err := repo_module.CreateTemporaryPath("upload")
+	basePath, cleanup, err := repo_module.CreateTemporaryPath("upload")
 	if err != nil {
 		return nil, err
 	}
-	t := &TemporaryUploadRepository{repo: repo, basePath: basePath}
+	t := &TemporaryUploadRepository{repo: repo, basePath: basePath, cleanup: cleanup}
 	return t, nil
 }
 
 // Close the repository cleaning up all files
 func (t *TemporaryUploadRepository) Close() {
 	defer t.gitRepo.Close()
-	if err := repo_module.RemoveTemporaryPath(t.basePath); err != nil {
-		log.Error("Failed to remove temporary path %s: %v", t.basePath, err)
+	if t.cleanup != nil {
+		t.cleanup()
 	}
 }
 
diff --git a/services/repository/generate.go b/services/repository/generate.go
index 1c4d3b7b63..b02f7c9482 100644
--- a/services/repository/generate.go
+++ b/services/repository/generate.go
@@ -21,6 +21,7 @@ import (
 	"code.gitea.io/gitea/modules/gitrepo"
 	"code.gitea.io/gitea/modules/log"
 	repo_module "code.gitea.io/gitea/modules/repository"
+	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/util"
 
 	"github.com/gobwas/glob"
@@ -255,16 +256,11 @@ func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *r
 }
 
 func generateGitContent(ctx context.Context, repo, templateRepo, generateRepo *repo_model.Repository) (err error) {
-	tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-"+repo.Name)
+	tmpDir, cleanup, err := setting.AppDataTempDir("git-repo-content").MkdirTempRandom("gitea-" + repo.Name)
 	if err != nil {
-		return fmt.Errorf("Failed to create temp dir for repository %s: %w", repo.FullName(), err)
+		return fmt.Errorf("failed to create temp dir for repository %s: %w", repo.FullName(), err)
 	}
-
-	defer func() {
-		if err := util.RemoveAll(tmpDir); err != nil {
-			log.Error("RemoveAll: %v", err)
-		}
-	}()
+	defer cleanup()
 
 	if err = generateRepoCommit(ctx, repo, templateRepo, generateRepo, tmpDir); err != nil {
 		return fmt.Errorf("generateRepoCommit: %w", err)
diff --git a/services/repository/repository.go b/services/repository/repository.go
index 10f175d989..cd56010546 100644
--- a/services/repository/repository.go
+++ b/services/repository/repository.go
@@ -13,7 +13,6 @@ import (
 	issues_model "code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/models/organization"
 	repo_model "code.gitea.io/gitea/models/repo"
-	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/graceful"
@@ -102,8 +101,6 @@ func Init(ctx context.Context) error {
 	if err := repo_module.LoadRepoConfig(); err != nil {
 		return err
 	}
-	system_model.RemoveAllWithNotice(ctx, "Clean up temporary repository uploads", setting.Repository.Upload.TempPath)
-	system_model.RemoveAllWithNotice(ctx, "Clean up temporary repositories", repo_module.LocalCopyPath())
 	if err := initPushQueue(); err != nil {
 		return err
 	}
diff --git a/services/wiki/wiki.go b/services/wiki/wiki.go
index b21f46639d..45a08dc5d6 100644
--- a/services/wiki/wiki.go
+++ b/services/wiki/wiki.go
@@ -102,15 +102,11 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
 
 	hasDefaultBranch := gitrepo.IsBranchExist(ctx, repo.WikiStorageRepo(), repo.DefaultWikiBranch)
 
-	basePath, err := repo_module.CreateTemporaryPath("update-wiki")
+	basePath, cleanup, err := repo_module.CreateTemporaryPath("update-wiki")
 	if err != nil {
 		return err
 	}
-	defer func() {
-		if err := repo_module.RemoveTemporaryPath(basePath); err != nil {
-			log.Error("Merge: RemoveTemporaryPath: %s", err)
-		}
-	}()
+	defer cleanup()
 
 	cloneOpts := git.CloneRepoOptions{
 		Bare:   true,
@@ -264,15 +260,11 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
 		return fmt.Errorf("InitWiki: %w", err)
 	}
 
-	basePath, err := repo_module.CreateTemporaryPath("update-wiki")
+	basePath, cleanup, err := repo_module.CreateTemporaryPath("update-wiki")
 	if err != nil {
 		return err
 	}
-	defer func() {
-		if err := repo_module.RemoveTemporaryPath(basePath); err != nil {
-			log.Error("Merge: RemoveTemporaryPath: %s", err)
-		}
-	}()
+	defer cleanup()
 
 	if err := git.Clone(ctx, repo.WikiPath(), basePath, git.CloneRepoOptions{
 		Bare:   true,
diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl
index 88dadeb3ee..806347c720 100644
--- a/templates/admin/config.tmpl
+++ b/templates/admin/config.tmpl
@@ -69,10 +69,6 @@
 					{{if not .SSH.StartBuiltinServer}}
 						<dt>{{ctx.Locale.Tr "admin.config.ssh_root_path"}}</dt>
 						<dd>{{.SSH.RootPath}}</dd>
-						<dt>{{ctx.Locale.Tr "admin.config.ssh_key_test_path"}}</dt>
-						<dd>{{.SSH.KeyTestPath}}</dd>
-						<dt>{{ctx.Locale.Tr "admin.config.ssh_keygen_path"}}</dt>
-						<dd>{{.SSH.KeygenPath}}</dd>
 						<dt>{{ctx.Locale.Tr "admin.config.ssh_minimum_key_size_check"}}</dt>
 						<dd>{{svg (Iif .SSH.MinimumKeySizeCheck "octicon-check" "octicon-x")}}</dd>
 						{{if .SSH.MinimumKeySizeCheck}}
diff --git a/tests/integration/migration-test/migration_test.go b/tests/integration/migration-test/migration_test.go
index ffb8afa9c5..0fc8a6e24d 100644
--- a/tests/integration/migration-test/migration_test.go
+++ b/tests/integration/migration-test/migration_test.go
@@ -52,7 +52,7 @@ func initMigrationTest(t *testing.T) func() {
 		setting.CustomConf = giteaConf
 	}
 
-	unittest.InitSettings()
+	unittest.InitSettingsForTesting()
 
 	assert.NotEmpty(t, setting.RepoRootPath)
 	assert.NoError(t, unittest.SyncDirs(filepath.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath))
diff --git a/tests/test_utils.go b/tests/test_utils.go
index 6c95716e67..4d8a8635d6 100644
--- a/tests/test_utils.go
+++ b/tests/test_utils.go
@@ -18,7 +18,6 @@ import (
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/graceful"
 	"code.gitea.io/gitea/modules/log"
-	repo_module "code.gitea.io/gitea/modules/repository"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/storage"
 	"code.gitea.io/gitea/modules/test"
@@ -67,9 +66,8 @@ func InitTest(requireGitea bool) {
 		setting.CustomConf = giteaConf
 	}
 
-	unittest.InitSettings()
+	unittest.InitSettingsForTesting()
 	setting.Repository.DefaultBranch = "master" // many test code still assume that default branch is called "master"
-	_ = util.RemoveAll(repo_module.LocalCopyPath())
 
 	if err := git.InitFull(context.Background()); err != nil {
 		log.Fatal("git.InitOnceWithSync: %v", err)

From c0898f7ed9a1212aed9d94afda03e1831d5efcbb Mon Sep 17 00:00:00 2001
From: GiteaBot <teabot@gitea.io>
Date: Wed, 9 Apr 2025 00:34:55 +0000
Subject: [PATCH 02/14] [skip ci] Updated translations via Crowdin

---
 options/locale/locale_cs-CZ.ini | 2 --
 options/locale/locale_de-DE.ini | 2 --
 options/locale/locale_el-GR.ini | 2 --
 options/locale/locale_es-ES.ini | 2 --
 options/locale/locale_fa-IR.ini | 2 --
 options/locale/locale_fi-FI.ini | 2 --
 options/locale/locale_fr-FR.ini | 2 --
 options/locale/locale_ga-IE.ini | 2 --
 options/locale/locale_hu-HU.ini | 2 --
 options/locale/locale_id-ID.ini | 2 --
 options/locale/locale_it-IT.ini | 2 --
 options/locale/locale_ja-JP.ini | 2 --
 options/locale/locale_ko-KR.ini | 2 --
 options/locale/locale_lv-LV.ini | 2 --
 options/locale/locale_nl-NL.ini | 2 --
 options/locale/locale_pl-PL.ini | 2 --
 options/locale/locale_pt-BR.ini | 2 --
 options/locale/locale_pt-PT.ini | 2 --
 options/locale/locale_ru-RU.ini | 2 --
 options/locale/locale_si-LK.ini | 2 --
 options/locale/locale_sv-SE.ini | 2 --
 options/locale/locale_tr-TR.ini | 2 --
 options/locale/locale_uk-UA.ini | 2 --
 options/locale/locale_zh-CN.ini | 2 --
 options/locale/locale_zh-HK.ini | 2 --
 options/locale/locale_zh-TW.ini | 2 --
 26 files changed, 52 deletions(-)

diff --git a/options/locale/locale_cs-CZ.ini b/options/locale/locale_cs-CZ.ini
index af366868b1..da7aef22d5 100644
--- a/options/locale/locale_cs-CZ.ini
+++ b/options/locale/locale_cs-CZ.ini
@@ -3238,8 +3238,6 @@ config.ssh_domain=Doména SSH serveru
 config.ssh_port=Port
 config.ssh_listen_port=Port pro naslouchání
 config.ssh_root_path=Kořenová cesta
-config.ssh_key_test_path=Cesta testu klíčů
-config.ssh_keygen_path=Cesta ke generátoru klíčů ('ssh-keygen')
 config.ssh_minimum_key_size_check=Kontrola minimální velikosti klíčů
 config.ssh_minimum_key_sizes=Minimální velikost klíčů
 
diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini
index 50ade526ff..57f1c01404 100644
--- a/options/locale/locale_de-DE.ini
+++ b/options/locale/locale_de-DE.ini
@@ -3235,8 +3235,6 @@ config.ssh_domain=SSH-Server-Domain
 config.ssh_port=Port
 config.ssh_listen_port=Listen-Port
 config.ssh_root_path=Wurzelverzeichnis
-config.ssh_key_test_path=Schlüssel-Test-Pfad
-config.ssh_keygen_path=Keygen-Pfad („ssh-keygen“)
 config.ssh_minimum_key_size_check=Prüfung der Mindestschlüssellänge
 config.ssh_minimum_key_sizes=Mindestschlüssellängen
 
diff --git a/options/locale/locale_el-GR.ini b/options/locale/locale_el-GR.ini
index 960e3b1581..c1e2f35abc 100644
--- a/options/locale/locale_el-GR.ini
+++ b/options/locale/locale_el-GR.ini
@@ -2927,8 +2927,6 @@ config.ssh_domain=Domain Διακομιστή SSH
 config.ssh_port=Θύρα
 config.ssh_listen_port=Θύρα Ακρόασης
 config.ssh_root_path=Ριζική Διαδρομή
-config.ssh_key_test_path=Διαδρομή Δοκιμής Κλειδιού
-config.ssh_keygen_path=Διαδρομή Keygen ('ssh-keygen')
 config.ssh_minimum_key_size_check=Έλεγχος Ελάχιστου Μεγέθους Κλειδιού
 config.ssh_minimum_key_sizes=Ελάχιστα Μεγέθη Κλειδιών
 
diff --git a/options/locale/locale_es-ES.ini b/options/locale/locale_es-ES.ini
index 280c735c79..82d2e2a3b9 100644
--- a/options/locale/locale_es-ES.ini
+++ b/options/locale/locale_es-ES.ini
@@ -2907,8 +2907,6 @@ config.ssh_domain=Dominio del servidor SSH
 config.ssh_port=Puerto
 config.ssh_listen_port=Puerto de escucha
 config.ssh_root_path=Ruta raíz
-config.ssh_key_test_path=Ruta de la clave de prueba
-config.ssh_keygen_path=Ruta del generador de claves ('ssh-keygen')
 config.ssh_minimum_key_size_check=Tamaño mínimo de la clave de verificación
 config.ssh_minimum_key_sizes=Tamaños de clave mínimos
 
diff --git a/options/locale/locale_fa-IR.ini b/options/locale/locale_fa-IR.ini
index c7bef6e6dc..b550916fb0 100644
--- a/options/locale/locale_fa-IR.ini
+++ b/options/locale/locale_fa-IR.ini
@@ -2279,8 +2279,6 @@ config.ssh_domain=دامنه سرور SSH
 config.ssh_port=درگاه (پورت)
 config.ssh_listen_port=گوش دادن به پورت
 config.ssh_root_path=مسیر ریشه
-config.ssh_key_test_path=مسیر کلید آزمایش
-config.ssh_keygen_path=مسیر فایل ssh-keygen
 config.ssh_minimum_key_size_check=بررسی حداقل طول کلید
 config.ssh_minimum_key_sizes=حداقل اندازه‌ی کلید ها
 
diff --git a/options/locale/locale_fi-FI.ini b/options/locale/locale_fi-FI.ini
index e853273375..69cee090fe 100644
--- a/options/locale/locale_fi-FI.ini
+++ b/options/locale/locale_fi-FI.ini
@@ -1538,8 +1538,6 @@ config.ssh_enabled=Käytössä
 config.ssh_port=Portti
 config.ssh_listen_port=Kuuntele porttia
 config.ssh_root_path=Juuren polku
-config.ssh_key_test_path=Polku jossa avaimet testataan
-config.ssh_keygen_path=Keygen ('ssh-keygen') polku
 config.ssh_minimum_key_size_check=Avaimen vähimmäiskoko tarkistus
 config.ssh_minimum_key_sizes=Avaimen vähimmäiskoot
 
diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini
index 3c19d053da..6e0f0aab46 100644
--- a/options/locale/locale_fr-FR.ini
+++ b/options/locale/locale_fr-FR.ini
@@ -3274,8 +3274,6 @@ config.ssh_domain=Domaine du serveur SSH
 config.ssh_port=Port
 config.ssh_listen_port=Port d'écoute
 config.ssh_root_path=Emplacement racine
-config.ssh_key_test_path=Chemin de test des clés
-config.ssh_keygen_path=Chemin vers le générateur de clés (« ssh-keygen »)
 config.ssh_minimum_key_size_check=Vérification de la longueur de clé minimale
 config.ssh_minimum_key_sizes=Tailles de clé minimales
 
diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini
index f2e5de942a..a65ed357b1 100644
--- a/options/locale/locale_ga-IE.ini
+++ b/options/locale/locale_ga-IE.ini
@@ -3282,8 +3282,6 @@ config.ssh_domain=Fearainn Freastalaí SSH
 config.ssh_port=Calafort
 config.ssh_listen_port=Éist Calafort
 config.ssh_root_path=Cosán Fréimhe
-config.ssh_key_test_path=Cosán Tástáil Eochair
-config.ssh_keygen_path=Keygen ('ssh-keygen') Cosán
 config.ssh_minimum_key_size_check=Seiceáil Íosta Méid Eochair
 config.ssh_minimum_key_sizes=Méideanna Íosta Eochrach
 
diff --git a/options/locale/locale_hu-HU.ini b/options/locale/locale_hu-HU.ini
index a57d6960dd..9be048c42f 100644
--- a/options/locale/locale_hu-HU.ini
+++ b/options/locale/locale_hu-HU.ini
@@ -1412,8 +1412,6 @@ config.ssh_start_builtin_server=Beépített szerver használata
 config.ssh_port=Port
 config.ssh_listen_port=Figyelő port
 config.ssh_root_path=Gyökérkönyvtár
-config.ssh_key_test_path=Kulcs ellenőrzés útvonala
-config.ssh_keygen_path=Kulcsgeneráló ('ssh-keygen') elérési útja
 config.ssh_minimum_key_size_check=Kulcsok minimum méretének ellenőrzése
 config.ssh_minimum_key_sizes=Minimális kulcsok méretek
 
diff --git a/options/locale/locale_id-ID.ini b/options/locale/locale_id-ID.ini
index c54bfbb924..9e2244c1ab 100644
--- a/options/locale/locale_id-ID.ini
+++ b/options/locale/locale_id-ID.ini
@@ -1219,8 +1219,6 @@ config.ssh_enabled=Aktif
 config.ssh_port=Port
 config.ssh_listen_port=Listen Port
 config.ssh_root_path=Path Induk
-config.ssh_key_test_path=Path Key Test
-config.ssh_keygen_path=Path Keygen ('ssh-keygen')
 config.ssh_minimum_key_size_check=Periksa ukuran kunci minimum
 config.ssh_minimum_key_sizes=Ukuran kunci minimum
 
diff --git a/options/locale/locale_it-IT.ini b/options/locale/locale_it-IT.ini
index 810f1040f5..73d1762c59 100644
--- a/options/locale/locale_it-IT.ini
+++ b/options/locale/locale_it-IT.ini
@@ -2464,8 +2464,6 @@ config.ssh_domain=Dominio Server Ssh
 config.ssh_port=Porta
 config.ssh_listen_port=Porta in ascolto
 config.ssh_root_path=Percorso Root
-config.ssh_key_test_path=Percorso chiave di test
-config.ssh_keygen_path=Percorso Keygen ('ssh-keygen')
 config.ssh_minimum_key_size_check=Verifica delle dimensioni minime della chiave
 config.ssh_minimum_key_sizes=Dimensioni minime della chiave
 
diff --git a/options/locale/locale_ja-JP.ini b/options/locale/locale_ja-JP.ini
index 4fe3a2ef60..e98c453744 100644
--- a/options/locale/locale_ja-JP.ini
+++ b/options/locale/locale_ja-JP.ini
@@ -3272,8 +3272,6 @@ config.ssh_domain=SSHサーバーのドメイン
 config.ssh_port=ポート
 config.ssh_listen_port=待受ポート
 config.ssh_root_path=ルートパス
-config.ssh_key_test_path=キーテストパス
-config.ssh_keygen_path=キージェネレータ('ssh-keygen')パス
 config.ssh_minimum_key_size_check=最小キー長のチェック
 config.ssh_minimum_key_sizes=最小キー長
 
diff --git a/options/locale/locale_ko-KR.ini b/options/locale/locale_ko-KR.ini
index ddb80dde1d..b6b9cf373e 100644
--- a/options/locale/locale_ko-KR.ini
+++ b/options/locale/locale_ko-KR.ini
@@ -1372,8 +1372,6 @@ config.ssh_start_builtin_server=빌트-인 서버 사용
 config.ssh_port=포트
 config.ssh_listen_port=수신 대기 포트
 config.ssh_root_path=최상위 경로
-config.ssh_key_test_path=주 테스트 경로
-config.ssh_keygen_path=키 생성 ('ssh-keygen') 경로
 config.ssh_minimum_key_size_check=최소 키 사이즈 검사
 config.ssh_minimum_key_sizes=최소 키 사이즈
 
diff --git a/options/locale/locale_lv-LV.ini b/options/locale/locale_lv-LV.ini
index 42c12def23..fa6736df1a 100644
--- a/options/locale/locale_lv-LV.ini
+++ b/options/locale/locale_lv-LV.ini
@@ -2930,8 +2930,6 @@ config.ssh_domain=SSH servera domēns
 config.ssh_port=Ports
 config.ssh_listen_port=Klausīšanās ports
 config.ssh_root_path=Saknes ceļš
-config.ssh_key_test_path=Atslēgu pārbaudes ceļš
-config.ssh_keygen_path=Keygen ('ssh-keygen') ceļš
 config.ssh_minimum_key_size_check=Minimālā atslēgas lieluma pārbaude
 config.ssh_minimum_key_sizes=Minimālais atslēgas lielums
 
diff --git a/options/locale/locale_nl-NL.ini b/options/locale/locale_nl-NL.ini
index 0ad14807d6..345990b532 100644
--- a/options/locale/locale_nl-NL.ini
+++ b/options/locale/locale_nl-NL.ini
@@ -2310,8 +2310,6 @@ config.ssh_start_builtin_server=Gebruik de ingebouwde server
 config.ssh_port=Poort
 config.ssh_listen_port=Luister op poort
 config.ssh_root_path=Root-pad
-config.ssh_key_test_path=Pad voor key-tests
-config.ssh_keygen_path=Pad van keygen ('ssh-keygen')
 config.ssh_minimum_key_size_check=Controleer minimale key-lengte
 config.ssh_minimum_key_sizes=Minimale key-lengtes
 
diff --git a/options/locale/locale_pl-PL.ini b/options/locale/locale_pl-PL.ini
index 9673db2a71..30c08bc6db 100644
--- a/options/locale/locale_pl-PL.ini
+++ b/options/locale/locale_pl-PL.ini
@@ -2198,8 +2198,6 @@ config.ssh_start_builtin_server=Wykorzystaj wbudowany serwer
 config.ssh_port=Port
 config.ssh_listen_port=Port nasłuchiwania
 config.ssh_root_path=Ścieżka do katalogu głównego
-config.ssh_key_test_path=Ścieżka do klucza testowego
-config.ssh_keygen_path=Ścieżka do generatora ('ssh-keygen')
 config.ssh_minimum_key_size_check=Sprawdzanie minimalnej długości klucza
 config.ssh_minimum_key_sizes=Minimalne rozmiary kluczy
 
diff --git a/options/locale/locale_pt-BR.ini b/options/locale/locale_pt-BR.ini
index 204fedc311..3dcbdf87aa 100644
--- a/options/locale/locale_pt-BR.ini
+++ b/options/locale/locale_pt-BR.ini
@@ -2878,8 +2878,6 @@ config.ssh_domain=Domínio do servidor SSH
 config.ssh_port=Porta
 config.ssh_listen_port=Porta de escuta
 config.ssh_root_path=Caminho da raiz
-config.ssh_key_test_path=Caminho da chave de teste
-config.ssh_keygen_path=Caminho do keygen ('ssh-keygen')
 config.ssh_minimum_key_size_check=Verificar tamanho mínimo da chave
 config.ssh_minimum_key_sizes=Tamanhos mínimos da chave
 
diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini
index 4beeeb5b0d..fb91a76f02 100644
--- a/options/locale/locale_pt-PT.ini
+++ b/options/locale/locale_pt-PT.ini
@@ -3286,8 +3286,6 @@ config.ssh_domain=Domínio do servidor SSH
 config.ssh_port=Porto
 config.ssh_listen_port=Porto de escuta
 config.ssh_root_path=Localização base
-config.ssh_key_test_path=Localização do teste das chaves
-config.ssh_keygen_path=Localização do gerador de chaves ('ssh-keygen')
 config.ssh_minimum_key_size_check=Verificação de tamanho mínimo da chave
 config.ssh_minimum_key_sizes=Tamanhos mínimos da chave
 
diff --git a/options/locale/locale_ru-RU.ini b/options/locale/locale_ru-RU.ini
index 68246ff751..ae3c9a4ed9 100644
--- a/options/locale/locale_ru-RU.ini
+++ b/options/locale/locale_ru-RU.ini
@@ -2870,8 +2870,6 @@ config.ssh_domain=Домен SSH сервера
 config.ssh_port=Порт
 config.ssh_listen_port=Прослушиваемый порт
 config.ssh_root_path=Корневой путь
-config.ssh_key_test_path=Путь к тестовому ключу
-config.ssh_keygen_path=Путь к генератору ключей ('ssh-keygen')
 config.ssh_minimum_key_size_check=Минимальный размер ключа проверки
 config.ssh_minimum_key_sizes=Минимальные размеры ключа
 
diff --git a/options/locale/locale_si-LK.ini b/options/locale/locale_si-LK.ini
index 7c95b254e8..fbeea4591c 100644
--- a/options/locale/locale_si-LK.ini
+++ b/options/locale/locale_si-LK.ini
@@ -2240,8 +2240,6 @@ config.ssh_domain=SSH සේවාදායකය වසම්
 config.ssh_port=වරාය
 config.ssh_listen_port=සවන් වරාය
 config.ssh_root_path=මූල මාර්ගය
-config.ssh_key_test_path=ප්රධාන ටෙස්ට් මාර්ගය
-config.ssh_keygen_path=Keygen ('ssh-keygen') මාර්ගය
 config.ssh_minimum_key_size_check=අවම කී ප්රමාණය පරීක්ෂා
 config.ssh_minimum_key_sizes=අවම යතුරෙහි ප්‍රමාණ
 
diff --git a/options/locale/locale_sv-SE.ini b/options/locale/locale_sv-SE.ini
index 92917c103a..5a3091adac 100644
--- a/options/locale/locale_sv-SE.ini
+++ b/options/locale/locale_sv-SE.ini
@@ -1794,8 +1794,6 @@ config.ssh_start_builtin_server=Använd inbyggd Server
 config.ssh_port=Port
 config.ssh_listen_port=Lyssningsport
 config.ssh_root_path=Rotsökväg
-config.ssh_key_test_path=Testsökväg för nyckel
-config.ssh_keygen_path=Sökväg för nyckelgenerator ('ssh-keygen')
 config.ssh_minimum_key_size_check=Kontroll av minsta tillåtna nyckelstorlek
 config.ssh_minimum_key_sizes=Minsta tillåtna nyckelstorlek
 
diff --git a/options/locale/locale_tr-TR.ini b/options/locale/locale_tr-TR.ini
index faf442431b..bec38b2ed1 100644
--- a/options/locale/locale_tr-TR.ini
+++ b/options/locale/locale_tr-TR.ini
@@ -3106,8 +3106,6 @@ config.ssh_domain=SSH Sunucusu Alan Adı
 config.ssh_port=Bağlantı Noktası
 config.ssh_listen_port=Port'u Dinle
 config.ssh_root_path=Kök Yol
-config.ssh_key_test_path=Anahtar Test Yolu
-config.ssh_keygen_path=Keygen ('ssh-keygen') Yolu
 config.ssh_minimum_key_size_check=Minimum Anahtar Uzunluğu Kontrolü
 config.ssh_minimum_key_sizes=Minimum Anahtar Uzunlukları
 
diff --git a/options/locale/locale_uk-UA.ini b/options/locale/locale_uk-UA.ini
index 995de50b61..2aae533e7e 100644
--- a/options/locale/locale_uk-UA.ini
+++ b/options/locale/locale_uk-UA.ini
@@ -2290,8 +2290,6 @@ config.ssh_domain=Домен SSH сервера
 config.ssh_port=Порт
 config.ssh_listen_port=Порт що прослуховується
 config.ssh_root_path=Шлях до кореню
-config.ssh_key_test_path=Шлях до тестового ключа
-config.ssh_keygen_path=Шлях до генератора ключів ('ssh-keygen')
 config.ssh_minimum_key_size_check=Мінімальний розмір ключа перевірки
 config.ssh_minimum_key_sizes=Мінімальні розміри ключів
 
diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini
index 7f15c32304..85f5b08a42 100644
--- a/options/locale/locale_zh-CN.ini
+++ b/options/locale/locale_zh-CN.ini
@@ -3221,8 +3221,6 @@ config.ssh_domain=SSH 服务器域名
 config.ssh_port=端口
 config.ssh_listen_port=监听端口
 config.ssh_root_path=根目录
-config.ssh_key_test_path=密钥测试路径
-config.ssh_keygen_path=密钥生成器('ssh-keygen')路径
 config.ssh_minimum_key_size_check=密钥最小长度检查
 config.ssh_minimum_key_sizes=密钥最小长度限制
 
diff --git a/options/locale/locale_zh-HK.ini b/options/locale/locale_zh-HK.ini
index ae6c6c3552..73602fe36a 100644
--- a/options/locale/locale_zh-HK.ini
+++ b/options/locale/locale_zh-HK.ini
@@ -816,8 +816,6 @@ config.ssh_enabled=已啟用
 config.ssh_port=埠
 config.ssh_listen_port=監聽埠
 config.ssh_root_path=根路徑
-config.ssh_key_test_path=金鑰測試路徑
-config.ssh_keygen_path=金鑰產生 (' ssh-keygen ') 路徑
 config.ssh_minimum_key_size_check=金鑰最小大小檢查
 config.ssh_minimum_key_sizes=金鑰最小大小
 
diff --git a/options/locale/locale_zh-TW.ini b/options/locale/locale_zh-TW.ini
index 374ff073f2..343fb401c8 100644
--- a/options/locale/locale_zh-TW.ini
+++ b/options/locale/locale_zh-TW.ini
@@ -3211,8 +3211,6 @@ config.ssh_domain=SSH 伺服器域名
 config.ssh_port=連接埠
 config.ssh_listen_port=監聽埠
 config.ssh_root_path=根路徑
-config.ssh_key_test_path=金鑰測試路徑
-config.ssh_keygen_path=金鑰產生 (' ssh-keygen ') 路徑
 config.ssh_minimum_key_size_check=金鑰最小大小檢查
 config.ssh_minimum_key_sizes=金鑰最小大小
 

From 1b2d8df13d2b5800211342c984440c220e287bd4 Mon Sep 17 00:00:00 2001
From: Will Kelly <67284402+wkelly17@users.noreply.github.com>
Date: Wed, 9 Apr 2025 01:34:50 -0500
Subject: [PATCH 03/14] remove hardcoded 'code' string in clone_panel.tmpl
 (#34153)

This commit replaces the hardcoded string "code" in the clone panel
button with the i18n local for repo.code.
---
 templates/repo/clone_panel.tmpl | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/repo/clone_panel.tmpl b/templates/repo/clone_panel.tmpl
index 2ed8f52fbe..0e3c13eaa2 100644
--- a/templates/repo/clone_panel.tmpl
+++ b/templates/repo/clone_panel.tmpl
@@ -1,6 +1,6 @@
 <button class="ui primary button js-btn-clone-panel">
 	{{svg "octicon-code" 16}}
-	<span>Code</span>
+	<span>{{ctx.Locale.Tr "repo.code"}}</span>
 	{{svg "octicon-triangle-down" 14 "dropdown icon"}}
 </button>
 <div class="clone-panel-popup tippy-target">

From f8edc29f5dd1a99e3be1a2ff764d1d7315862345 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Wed, 9 Apr 2025 15:52:01 +0800
Subject: [PATCH 04/14] Set MERMAID_MAX_SOURCE_CHARACTERS to 50000 (#34152)

Fix #32015
---
 custom/conf/app.example.ini | 2 +-
 modules/setting/markup.go   | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index d1124371e0..8d39551168 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -2457,7 +2457,7 @@ LEVEL = Info
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;; Set the maximum number of characters in a mermaid source. (Set to -1 to disable limits)
-;MERMAID_MAX_SOURCE_CHARACTERS = 5000
+;MERMAID_MAX_SOURCE_CHARACTERS = 50000
 
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/modules/setting/markup.go b/modules/setting/markup.go
index 3bd368f831..365af05fcf 100644
--- a/modules/setting/markup.go
+++ b/modules/setting/markup.go
@@ -127,7 +127,7 @@ func loadMarkupFrom(rootCfg ConfigProvider) {
 		}
 	}
 
-	MermaidMaxSourceCharacters = rootCfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(5000)
+	MermaidMaxSourceCharacters = rootCfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(50000)
 	ExternalMarkupRenderers = make([]*MarkupRenderer, 0, 10)
 	ExternalSanitizerRules = make([]MarkupSanitizerRule, 0, 10)
 

From 4a5af4edca3883bcb45056de11b9fd59f9759b9b Mon Sep 17 00:00:00 2001
From: Lunny Xiao <xiaolunwen@gmail.com>
Date: Wed, 9 Apr 2025 09:34:38 -0700
Subject: [PATCH 05/14] Cache GPG keys, emails and users when list commits
 (#34086)

When list commits, some of the commits authors are the same at many
situations. But current logic will always fetch the same GPG keys from
database. This PR will cache the GPG keys, emails and users for the
context so that reducing the database queries.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 models/asymkey/gpg_key.go            |  7 +++++++
 models/user/user.go                  | 27 +++++++++++++--------------
 modules/cache/context.go             |  8 ++++----
 modules/cache/context_test.go        |  3 ++-
 modules/cachegroup/cachegroup.go     | 12 ++++++++++++
 modules/repository/commits.go        |  3 ++-
 routers/private/hook_post_receive.go |  5 ++---
 services/asymkey/commit.go           | 25 ++++++++++---------------
 services/convert/pull.go             | 13 +++++++------
 services/git/commit.go               | 10 ++++++----
 10 files changed, 65 insertions(+), 48 deletions(-)
 create mode 100644 modules/cachegroup/cachegroup.go

diff --git a/models/asymkey/gpg_key.go b/models/asymkey/gpg_key.go
index 24c76f7b5c..220f46ad1d 100644
--- a/models/asymkey/gpg_key.go
+++ b/models/asymkey/gpg_key.go
@@ -240,3 +240,10 @@ func DeleteGPGKey(ctx context.Context, doer *user_model.User, id int64) (err err
 
 	return committer.Commit()
 }
+
+func FindGPGKeyWithSubKeys(ctx context.Context, keyID string) ([]*GPGKey, error) {
+	return db.Find[GPGKey](ctx, FindGPGKeyOptions{
+		KeyID:          keyID,
+		IncludeSubKeys: true,
+	})
+}
diff --git a/models/user/user.go b/models/user/user.go
index 5989be74f0..100f924cc6 100644
--- a/models/user/user.go
+++ b/models/user/user.go
@@ -1187,29 +1187,28 @@ func GetUsersByEmails(ctx context.Context, emails []string) (map[string]*User, e
 	for _, email := range emailAddresses {
 		userIDs.Add(email.UID)
 	}
-	users, err := GetUsersMapByIDs(ctx, userIDs.Values())
-	if err != nil {
-		return nil, err
-	}
-
 	results := make(map[string]*User, len(emails))
-	for _, email := range emailAddresses {
-		user := users[email.UID]
-		if user != nil {
-			if user.KeepEmailPrivate {
-				results[user.LowerName+"@"+setting.Service.NoReplyAddress] = user
-			} else {
-				results[email.Email] = user
+
+	if len(userIDs) > 0 {
+		users, err := GetUsersMapByIDs(ctx, userIDs.Values())
+		if err != nil {
+			return nil, err
+		}
+
+		for _, email := range emailAddresses {
+			user := users[email.UID]
+			if user != nil {
+				results[user.GetEmail()] = user
 			}
 		}
 	}
 
-	users = make(map[int64]*User, len(needCheckUserNames))
+	users := make(map[int64]*User, len(needCheckUserNames))
 	if err := db.GetEngine(ctx).In("lower_name", needCheckUserNames.Values()).Find(&users); err != nil {
 		return nil, err
 	}
 	for _, user := range users {
-		results[user.LowerName+"@"+setting.Service.NoReplyAddress] = user
+		results[user.GetPlaceholderEmail()] = user
 	}
 	return results, nil
 }
diff --git a/modules/cache/context.go b/modules/cache/context.go
index 484cee659a..85eb9e6790 100644
--- a/modules/cache/context.go
+++ b/modules/cache/context.go
@@ -166,15 +166,15 @@ func RemoveContextData(ctx context.Context, tp, key any) {
 }
 
 // GetWithContextCache returns the cache value of the given key in the given context.
-func GetWithContextCache[T any](ctx context.Context, cacheGroupKey string, cacheTargetID any, f func() (T, error)) (T, error) {
-	v := GetContextData(ctx, cacheGroupKey, cacheTargetID)
+func GetWithContextCache[T, K any](ctx context.Context, groupKey string, targetKey K, f func(context.Context, K) (T, error)) (T, error) {
+	v := GetContextData(ctx, groupKey, targetKey)
 	if vv, ok := v.(T); ok {
 		return vv, nil
 	}
-	t, err := f()
+	t, err := f(ctx, targetKey)
 	if err != nil {
 		return t, err
 	}
-	SetContextData(ctx, cacheGroupKey, cacheTargetID, t)
+	SetContextData(ctx, groupKey, targetKey, t)
 	return t, nil
 }
diff --git a/modules/cache/context_test.go b/modules/cache/context_test.go
index decb532937..23dd789dbc 100644
--- a/modules/cache/context_test.go
+++ b/modules/cache/context_test.go
@@ -4,6 +4,7 @@
 package cache
 
 import (
+	"context"
 	"testing"
 	"time"
 
@@ -30,7 +31,7 @@ func TestWithCacheContext(t *testing.T) {
 	v = GetContextData(ctx, field, "my_config1")
 	assert.Nil(t, v)
 
-	vInt, err := GetWithContextCache(ctx, field, "my_config1", func() (int, error) {
+	vInt, err := GetWithContextCache(ctx, field, "my_config1", func(context.Context, string) (int, error) {
 		return 1, nil
 	})
 	assert.NoError(t, err)
diff --git a/modules/cachegroup/cachegroup.go b/modules/cachegroup/cachegroup.go
new file mode 100644
index 0000000000..06085f860f
--- /dev/null
+++ b/modules/cachegroup/cachegroup.go
@@ -0,0 +1,12 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cachegroup
+
+const (
+	User               = "user"
+	EmailAvatarLink    = "email_avatar_link"
+	UserEmailAddresses = "user_email_addresses"
+	GPGKeyWithSubKeys  = "gpg_key_with_subkeys"
+	RepoUserPermission = "repo_user_permission"
+)
diff --git a/modules/repository/commits.go b/modules/repository/commits.go
index 16520fb28a..878fdc1603 100644
--- a/modules/repository/commits.go
+++ b/modules/repository/commits.go
@@ -13,6 +13,7 @@ import (
 	repo_model "code.gitea.io/gitea/models/repo"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/cache"
+	"code.gitea.io/gitea/modules/cachegroup"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
@@ -131,7 +132,7 @@ func (pc *PushCommits) ToAPIPayloadCommits(ctx context.Context, repo *repo_model
 func (pc *PushCommits) AvatarLink(ctx context.Context, email string) string {
 	size := avatars.DefaultAvatarPixelSize * setting.Avatar.RenderedSizeFactor
 
-	v, _ := cache.GetWithContextCache(ctx, "push_commits", email, func() (string, error) {
+	v, _ := cache.GetWithContextCache(ctx, cachegroup.EmailAvatarLink, email, func(ctx context.Context, email string) (string, error) {
 		u, err := user_model.GetUserByEmail(ctx, email)
 		if err != nil {
 			if !user_model.IsErrUserNotExist(err) {
diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go
index 442d0a76c9..8b1e849e7a 100644
--- a/routers/private/hook_post_receive.go
+++ b/routers/private/hook_post_receive.go
@@ -14,6 +14,7 @@ import (
 	repo_model "code.gitea.io/gitea/models/repo"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/cache"
+	"code.gitea.io/gitea/modules/cachegroup"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/gitrepo"
 	"code.gitea.io/gitea/modules/log"
@@ -326,9 +327,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
 }
 
 func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) {
-	return cache.GetWithContextCache(ctx, "hook_post_receive_user", id, func() (*user_model.User, error) {
-		return user_model.GetUserByID(ctx, id)
-	})
+	return cache.GetWithContextCache(ctx, cachegroup.User, id, user_model.GetUserByID)
 }
 
 // handlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit
diff --git a/services/asymkey/commit.go b/services/asymkey/commit.go
index 5d85be56f1..105782a93a 100644
--- a/services/asymkey/commit.go
+++ b/services/asymkey/commit.go
@@ -11,6 +11,8 @@ import (
 	asymkey_model "code.gitea.io/gitea/models/asymkey"
 	"code.gitea.io/gitea/models/db"
 	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/cache"
+	"code.gitea.io/gitea/modules/cachegroup"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
@@ -115,7 +117,7 @@ func ParseCommitWithSignatureCommitter(ctx context.Context, c *git.Commit, commi
 			}
 		}
 
-		committerEmailAddresses, _ := user_model.GetEmailAddresses(ctx, committer.ID)
+		committerEmailAddresses, _ := cache.GetWithContextCache(ctx, cachegroup.UserEmailAddresses, committer.ID, user_model.GetEmailAddresses)
 		activated := false
 		for _, e := range committerEmailAddresses {
 			if e.IsActivated && strings.EqualFold(e.Email, c.Committer.Email) {
@@ -209,10 +211,9 @@ func checkKeyEmails(ctx context.Context, email string, keys ...*asymkey_model.GP
 		}
 		if key.Verified && key.OwnerID != 0 {
 			if uid != key.OwnerID {
-				userEmails, _ = user_model.GetEmailAddresses(ctx, key.OwnerID)
+				userEmails, _ = cache.GetWithContextCache(ctx, cachegroup.UserEmailAddresses, key.OwnerID, user_model.GetEmailAddresses)
 				uid = key.OwnerID
-				user = &user_model.User{ID: uid}
-				_, _ = user_model.GetUser(ctx, user)
+				user, _ = cache.GetWithContextCache(ctx, cachegroup.User, uid, user_model.GetUserByID)
 			}
 			for _, e := range userEmails {
 				if e.IsActivated && (email == "" || strings.EqualFold(e.Email, email)) {
@@ -231,10 +232,7 @@ func HashAndVerifyForKeyID(ctx context.Context, sig *packet.Signature, payload s
 	if keyID == "" {
 		return nil
 	}
-	keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{
-		KeyID:          keyID,
-		IncludeSubKeys: true,
-	})
+	keys, err := cache.GetWithContextCache(ctx, cachegroup.GPGKeyWithSubKeys, keyID, asymkey_model.FindGPGKeyWithSubKeys)
 	if err != nil {
 		log.Error("GetGPGKeysByKeyID: %v", err)
 		return &asymkey_model.CommitVerification{
@@ -249,10 +247,7 @@ func HashAndVerifyForKeyID(ctx context.Context, sig *packet.Signature, payload s
 	for _, key := range keys {
 		var primaryKeys []*asymkey_model.GPGKey
 		if key.PrimaryKeyID != "" {
-			primaryKeys, err = db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{
-				KeyID:          key.PrimaryKeyID,
-				IncludeSubKeys: true,
-			})
+			primaryKeys, err = cache.GetWithContextCache(ctx, cachegroup.GPGKeyWithSubKeys, key.PrimaryKeyID, asymkey_model.FindGPGKeyWithSubKeys)
 			if err != nil {
 				log.Error("GetGPGKeysByKeyID: %v", err)
 				return &asymkey_model.CommitVerification{
@@ -272,8 +267,8 @@ func HashAndVerifyForKeyID(ctx context.Context, sig *packet.Signature, payload s
 			Name:  name,
 			Email: email,
 		}
-		if key.OwnerID != 0 {
-			owner, err := user_model.GetUserByID(ctx, key.OwnerID)
+		if key.OwnerID > 0 {
+			owner, err := cache.GetWithContextCache(ctx, cachegroup.User, key.OwnerID, user_model.GetUserByID)
 			if err == nil {
 				signer = owner
 			} else if !user_model.IsErrUserNotExist(err) {
@@ -381,7 +376,7 @@ func ParseCommitWithSSHSignature(ctx context.Context, c *git.Commit, committer *
 			}
 		}
 
-		committerEmailAddresses, err := user_model.GetEmailAddresses(ctx, committer.ID)
+		committerEmailAddresses, err := cache.GetWithContextCache(ctx, cachegroup.UserEmailAddresses, committer.ID, user_model.GetEmailAddresses)
 		if err != nil {
 			log.Error("GetEmailAddresses: %v", err)
 		}
diff --git a/services/convert/pull.go b/services/convert/pull.go
index 34c3b1bf9a..7798bebb08 100644
--- a/services/convert/pull.go
+++ b/services/convert/pull.go
@@ -14,6 +14,7 @@ import (
 	repo_model "code.gitea.io/gitea/models/repo"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/cache"
+	"code.gitea.io/gitea/modules/cachegroup"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/gitrepo"
 	"code.gitea.io/gitea/modules/log"
@@ -60,14 +61,14 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
 		doerID = doer.ID
 	}
 
-	const repoDoerPermCacheKey = "repo_doer_perm_cache"
-	p, err := cache.GetWithContextCache(ctx, repoDoerPermCacheKey, fmt.Sprintf("%d_%d", pr.BaseRepoID, doerID),
-		func() (access_model.Permission, error) {
+	repoUserPerm, err := cache.GetWithContextCache(ctx, cachegroup.RepoUserPermission, fmt.Sprintf("%d-%d", pr.BaseRepoID, doerID),
+		func(ctx context.Context, _ string) (access_model.Permission, error) {
 			return access_model.GetUserRepoPermission(ctx, pr.BaseRepo, doer)
-		})
+		},
+	)
 	if err != nil {
 		log.Error("GetUserRepoPermission[%d]: %v", pr.BaseRepoID, err)
-		p.AccessMode = perm.AccessModeNone
+		repoUserPerm.AccessMode = perm.AccessModeNone
 	}
 
 	apiPullRequest := &api.PullRequest{
@@ -107,7 +108,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
 			Name:       pr.BaseBranch,
 			Ref:        pr.BaseBranch,
 			RepoID:     pr.BaseRepoID,
-			Repository: ToRepo(ctx, pr.BaseRepo, p),
+			Repository: ToRepo(ctx, pr.BaseRepo, repoUserPerm),
 		},
 		Head: &api.PRBranchInfo{
 			Name:   pr.HeadBranch,
diff --git a/services/git/commit.go b/services/git/commit.go
index 8ab8f3d369..3faef76782 100644
--- a/services/git/commit.go
+++ b/services/git/commit.go
@@ -17,7 +17,7 @@ import (
 )
 
 // ParseCommitsWithSignature checks if signaute of commits are corresponding to users gpg keys.
-func ParseCommitsWithSignature(ctx context.Context, oldCommits []*user_model.UserCommit, repoTrustModel repo_model.TrustModelType, isOwnerMemberCollaborator func(*user_model.User) (bool, error)) ([]*asymkey_model.SignCommit, error) {
+func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository, oldCommits []*user_model.UserCommit, repoTrustModel repo_model.TrustModelType) ([]*asymkey_model.SignCommit, error) {
 	newCommits := make([]*asymkey_model.SignCommit, 0, len(oldCommits))
 	keyMap := map[string]bool{}
 
@@ -47,6 +47,10 @@ func ParseCommitsWithSignature(ctx context.Context, oldCommits []*user_model.Use
 			Verification: asymkey_service.ParseCommitWithSignatureCommitter(ctx, c.Commit, committer),
 		}
 
+		isOwnerMemberCollaborator := func(user *user_model.User) (bool, error) {
+			return repo_model.IsOwnerMemberCollaborator(ctx, repo, user.ID)
+		}
+
 		_ = asymkey_model.CalculateTrustStatus(signCommit.Verification, repoTrustModel, isOwnerMemberCollaborator, &keyMap)
 
 		newCommits = append(newCommits, signCommit)
@@ -62,11 +66,9 @@ func ConvertFromGitCommit(ctx context.Context, commits []*git.Commit, repo *repo
 	}
 	signedCommits, err := ParseCommitsWithSignature(
 		ctx,
+		repo,
 		validatedCommits,
 		repo.GetTrustModel(),
-		func(user *user_model.User) (bool, error) {
-			return repo_model.IsOwnerMemberCollaborator(ctx, repo, user.ID)
-		},
 	)
 	if err != nil {
 		return nil, err

From fac6b87dd24be5021d9c656edc2072397cfd6bed Mon Sep 17 00:00:00 2001
From: ManInDark <61268856+ManInDark@users.noreply.github.com>
Date: Wed, 9 Apr 2025 21:21:54 +0200
Subject: [PATCH 06/14] bugfix check for alternate ssh host certificate
 location (#34146)

fixes #34145

Edited all locations to actually be correct.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
---
 docker/root/etc/s6/openssh/setup | 15 +++++++++------
 1 file changed, 9 insertions(+), 6 deletions(-)

diff --git a/docker/root/etc/s6/openssh/setup b/docker/root/etc/s6/openssh/setup
index 6fbc599cc5..48e7d4b211 100755
--- a/docker/root/etc/s6/openssh/setup
+++ b/docker/root/etc/s6/openssh/setup
@@ -31,16 +31,19 @@ if [ -e /data/ssh/ssh_host_ecdsa_cert ]; then
   SSH_ECDSA_CERT=${SSH_ECDSA_CERT:-"/data/ssh/ssh_host_ecdsa_cert"}
 fi
 
-if [ -e /data/ssh/ssh_host_ed25519-cert.pub ]; then
-  SSH_ED25519_CERT=${SSH_ED25519_CERT:-"/data/ssh/ssh_host_ed25519-cert.pub"}
+# In case someone wants to sign the `{keyname}.pub` key by `ssh-keygen -s ca -I identity ...` to
+# make use of the ssh-key certificate authority feature (see ssh-keygen CERTIFICATES section),
+# the generated key file name is `{keyname}-cert.pub`
+if [ -e /data/ssh/ssh_host_ed25519_key-cert.pub ]; then
+  SSH_ED25519_CERT=${SSH_ED25519_CERT:-"/data/ssh/ssh_host_ed25519_key-cert.pub"}
 fi
 
-if [ -e /data/ssh/ssh_host_rsa-cert.pub ]; then
-  SSH_RSA_CERT=${SSH_RSA_CERT:-"/data/ssh/ssh_host_rsa-cert.pub"}
+if [ -e /data/ssh/ssh_host_rsa_key-cert.pub ]; then
+  SSH_RSA_CERT=${SSH_RSA_CERT:-"/data/ssh/ssh_host_rsa_key-cert.pub"}
 fi
 
-if [ -e /data/ssh/ssh_host_ecdsa-cert.pub ]; then
-  SSH_ECDSA_CERT=${SSH_ECDSA_CERT:-"/data/ssh/ssh_host_ecdsa-cert.pub"}
+if [ -e /data/ssh/ssh_host_ecdsa_key-cert.pub ]; then
+  SSH_ECDSA_CERT=${SSH_ECDSA_CERT:-"/data/ssh/ssh_host_ecdsa_key-cert.pub"}
 fi
 
 if [ -d /etc/ssh ]; then

From 02e49a0f471fcd50e70835458d196615f03c39cc Mon Sep 17 00:00:00 2001
From: Kerwin Bryant <kerwin612@qq.com>
Date: Thu, 10 Apr 2025 09:10:16 +0800
Subject: [PATCH 07/14] Fix vertical centering of file tree icons and use
 entryIcon for submodules/symlinks (#34137)

In the file tree, the icons are not vertically centered, which affects
the overall visual consistency.
Currently, the icons of submodules and symlinks do not adopt the value
of entryIcon, resulting in inconsistent icon display.

before:
![3000-gogitea-gitea-y4ulxr46c4k ws-us118 gitpod io_test_test
gitea_src_branch_main_README md
(3)](https://github.com/user-attachments/assets/d521b89f-909a-43f9-8f39-787b0243b159)

after:
![3000-gogitea-gitea-y4ulxr46c4k ws-us118 gitpod io_test_test
gitea_src_branch_main_README md
(2)](https://github.com/user-attachments/assets/4866807f-c890-4709-b595-7086011e5231)

---------

Co-authored-by: silverwind <me@silverwind.io>
---
 templates/repo/actions/status.tmpl          |  2 +-
 web_src/css/base.css                        | 12 ++++++++++++
 web_src/css/modules/animations.css          | 10 ++++++++++
 web_src/css/repo/home-file-list.css         | 11 -----------
 web_src/js/components/ActionRunStatus.vue   |  2 +-
 web_src/js/components/RepoActionView.vue    | 12 +-----------
 web_src/js/components/RepoCodeFrequency.vue |  2 +-
 web_src/js/components/RepoContributors.vue  |  2 +-
 web_src/js/components/RepoRecentCommits.vue |  2 +-
 web_src/js/components/ViewFileTreeItem.vue  | 15 ++++++++-------
 10 files changed, 36 insertions(+), 34 deletions(-)

diff --git a/templates/repo/actions/status.tmpl b/templates/repo/actions/status.tmpl
index 64c2543302..f2020bc160 100644
--- a/templates/repo/actions/status.tmpl
+++ b/templates/repo/actions/status.tmpl
@@ -16,7 +16,7 @@
 {{else if eq .status "blocked"}}
 	{{svg "octicon-blocked" $size (printf "text yellow %s" $className)}}
 {{else if eq .status "running"}}
-	{{svg "octicon-meter" $size (printf "text yellow job-status-rotate %s" $className)}}
+	{{svg "octicon-meter" $size (printf "text yellow circular-spin %s" $className)}}
 {{else}}{{/*failure, unknown*/}}
 	{{svg "octicon-x-circle-fill" $size (printf "text red %s" $className)}}
 {{end}}
diff --git a/web_src/css/base.css b/web_src/css/base.css
index de656c0d95..5a0579f356 100644
--- a/web_src/css/base.css
+++ b/web_src/css/base.css
@@ -1181,3 +1181,15 @@ the "!important" is necessary to override Fomantic UI menu item styles, meanwhil
   flex-grow: 1;
   justify-content: space-between;
 }
+
+.svg.octicon-file-directory-fill,
+.svg.octicon-file-directory-open-fill,
+.svg.octicon-file-submodule {
+  color: var(--color-primary);
+}
+
+.svg.octicon-file,
+.svg.octicon-file-symlink-file,
+.svg.octicon-file-directory-symlink {
+  color: var(--color-secondary-dark-7);
+}
diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css
index 481e997d4f..173ca73314 100644
--- a/web_src/css/modules/animations.css
+++ b/web_src/css/modules/animations.css
@@ -116,3 +116,13 @@ code.language-math.is-loading::after {
   animation-duration: 100ms;
   animation-timing-function: ease-in-out;
 }
+
+.circular-spin {
+  animation: circular-spin-keyframes 1s linear infinite;
+}
+
+@keyframes circular-spin-keyframes {
+  100% {
+    transform: rotate(-360deg);
+  }
+}
diff --git a/web_src/css/repo/home-file-list.css b/web_src/css/repo/home-file-list.css
index 46128457ed..f2ab052a54 100644
--- a/web_src/css/repo/home-file-list.css
+++ b/web_src/css/repo/home-file-list.css
@@ -14,17 +14,6 @@
   }
 }
 
-#repo-files-table .svg.octicon-file-directory-fill,
-#repo-files-table .svg.octicon-file-submodule {
-  color: var(--color-primary);
-}
-
-#repo-files-table .svg.octicon-file,
-#repo-files-table .svg.octicon-file-symlink-file,
-#repo-files-table .svg.octicon-file-directory-symlink {
-  color: var(--color-secondary-dark-7);
-}
-
 #repo-files-table .repo-file-item {
   display: contents;
 }
diff --git a/web_src/js/components/ActionRunStatus.vue b/web_src/js/components/ActionRunStatus.vue
index 487d2460cc..bc3b99ab89 100644
--- a/web_src/js/components/ActionRunStatus.vue
+++ b/web_src/js/components/ActionRunStatus.vue
@@ -24,7 +24,7 @@ withDefaults(defineProps<{
     <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class="className" v-else-if="status === 'cancelled'"/>
     <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class="className" v-else-if="status === 'waiting'"/>
     <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/>
-    <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class="'job-status-rotate ' + className" v-else-if="status === 'running'"/>
+    <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class="'circular-spin ' + className" v-else-if="status === 'running'"/>
     <SvgIcon name="octicon-x-circle-fill" class="text red" :size="size" v-else/><!-- failure, unknown -->
   </span>
 </template>
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index 640ad8341f..5a4c22bc90 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -574,7 +574,7 @@ export default defineComponent({
               <!-- If the job is done and the job step log is loaded for the first time, show the loading icon
                 currentJobStepsStates[i].cursor === null means the log is loaded for the first time
               -->
-              <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+              <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 circular-spin"/>
               <SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/>
               <ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
 
@@ -896,16 +896,6 @@ export default defineComponent({
 
 <style> /* eslint-disable-line vue-scoped-css/enforce-style-type */
 /* some elements are not managed by vue, so we need to use global style */
-.job-status-rotate {
-  animation: job-status-rotate-keyframes 1s linear infinite;
-}
-
-@keyframes job-status-rotate-keyframes {
-  100% {
-    transform: rotate(-360deg);
-  }
-}
-
 .job-step-section {
   margin: 10px;
 }
diff --git a/web_src/js/components/RepoCodeFrequency.vue b/web_src/js/components/RepoCodeFrequency.vue
index 7696996cf6..f04fc065b6 100644
--- a/web_src/js/components/RepoCodeFrequency.vue
+++ b/web_src/js/components/RepoCodeFrequency.vue
@@ -150,7 +150,7 @@ const options: ChartOptions<'line'> = {
     <div class="tw-flex ui segment main-graph">
       <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto">
         <div v-if="isLoading">
-          <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+          <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/>
           {{ locale.loadingInfo }}
         </div>
         <div v-else class="text red">
diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue
index 6ad2c848b1..b725f272a7 100644
--- a/web_src/js/components/RepoContributors.vue
+++ b/web_src/js/components/RepoContributors.vue
@@ -375,7 +375,7 @@ export default defineComponent({
     <div class="tw-flex ui segment main-graph">
       <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto">
         <div v-if="isLoading">
-          <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+          <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/>
           {{ locale.loadingInfo }}
         </div>
         <div v-else class="text red">
diff --git a/web_src/js/components/RepoRecentCommits.vue b/web_src/js/components/RepoRecentCommits.vue
index 10e1fdd70c..1b3d8fd459 100644
--- a/web_src/js/components/RepoRecentCommits.vue
+++ b/web_src/js/components/RepoRecentCommits.vue
@@ -128,7 +128,7 @@ const options: ChartOptions<'bar'> = {
     <div class="tw-flex ui segment main-graph">
       <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto">
         <div v-if="isLoading">
-          <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+          <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/>
           {{ locale.loadingInfo }}
         </div>
         <div v-else class="text red">
diff --git a/web_src/js/components/ViewFileTreeItem.vue b/web_src/js/components/ViewFileTreeItem.vue
index 69e26dbc33..c39fa1f4ae 100644
--- a/web_src/js/components/ViewFileTreeItem.vue
+++ b/web_src/js/components/ViewFileTreeItem.vue
@@ -58,7 +58,8 @@ const doGotoSubModule = () => {
   >
     <!-- submodule -->
     <div class="item-content">
-      <SvgIcon class="text primary" name="octicon-file-submodule"/>
+      <!-- eslint-disable-next-line vue/no-v-html -->
+      <span class="tw-contents" v-html="item.entryIcon"/>
       <span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span>
     </div>
   </div>
@@ -70,7 +71,8 @@ const doGotoSubModule = () => {
   >
     <!-- symlink -->
     <div class="item-content">
-      <SvgIcon name="octicon-file-symlink-file"/>
+      <!-- eslint-disable-next-line vue/no-v-html -->
+      <span class="tw-contents" v-html="item.entryIcon"/>
       <span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span>
     </div>
   </div>
@@ -83,7 +85,7 @@ const doGotoSubModule = () => {
     <!-- file -->
     <div class="item-content">
       <!-- eslint-disable-next-line vue/no-v-html -->
-      <span v-html="item.entryIcon"/>
+      <span class="tw-contents" v-html="item.entryIcon"/>
       <span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span>
     </div>
   </div>
@@ -95,13 +97,12 @@ const doGotoSubModule = () => {
   >
     <!-- directory -->
     <div class="item-toggle">
-      <!-- FIXME: use a general and global class for this animation -->
-      <SvgIcon v-if="isLoading" name="octicon-sync" class="job-status-rotate"/>
+      <SvgIcon v-if="isLoading" name="octicon-sync" class="circular-spin"/>
       <SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop="doLoadChildren"/>
     </div>
     <div class="item-content">
       <!-- eslint-disable-next-line vue/no-v-html -->
-      <span class="text primary" v-html="(!collapsed && item.entryIconOpen) ? item.entryIconOpen : item.entryIcon"/>
+      <span class="tw-contents" v-html="(!collapsed && item.entryIconOpen) ? item.entryIconOpen : item.entryIcon"/>
       <span class="gt-ellipsis">{{ item.entryName }}</span>
     </div>
   </div>
@@ -154,7 +155,7 @@ const doGotoSubModule = () => {
   grid-area: content;
   display: flex;
   align-items: center;
-  gap: 0.25em;
+  gap: 0.5em;
   text-overflow: ellipsis;
   min-width: 0;
 }

From fa49cd719f6e2d12d268a89c9e407ffec44f8a42 Mon Sep 17 00:00:00 2001
From: Thomas E Lackey <telackey@bozemanpass.com>
Date: Thu, 10 Apr 2025 12:18:07 -0500
Subject: [PATCH 08/14] feat: Add sorting by exclusive labels (issue priority)
 (#33206)

Fix #2616

This PR adds a new sort option for exclusive labels.

For exclusive labels, a new property is exposed called "order", while in
the UI options are populated automatically in the `Sort` column (see
screenshot below) for each exclusive label scope.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 models/issues/issue_search.go                 | 13 +++
 models/issues/label.go                        |  3 +-
 models/migrations/migrations.go               |  1 +
 models/migrations/v1_24/v319.go               | 16 ++++
 modules/indexer/issues/db/db.go               | 17 ++--
 modules/indexer/issues/db/options.go          |  8 +-
 modules/indexer/issues/dboptions.go           | 13 ++-
 modules/indexer/issues/indexer.go             | 11 ++-
 modules/label/label.go                        |  9 +-
 modules/repository/init.go                    |  9 +-
 modules/templates/util_render.go              | 17 +++-
 options/label/Advanced.yaml                   | 11 +++
 options/locale/locale_en-US.ini               |  2 +
 routers/web/org/org_labels.go                 | 12 ++-
 routers/web/org/projects.go                   | 10 +-
 routers/web/repo/issue_label.go               | 12 ++-
 routers/web/repo/issue_list.go                | 96 ++++++++++---------
 routers/web/repo/milestone.go                 |  2 +-
 routers/web/repo/projects.go                  | 10 +-
 routers/web/shared/issue/issue_label.go       | 21 ++--
 routers/web/user/setting/applications.go      |  3 +-
 services/forms/repo_form.go                   | 13 +--
 templates/repo/issue/filter_list.tmpl         |  6 ++
 .../repo/issue/labels/label_edit_modal.tmpl   |  8 +-
 templates/repo/issue/labels/label_list.tmpl   |  1 +
 web_src/css/base.css                          |  1 +
 web_src/css/repo.css                          |  6 ++
 web_src/js/features/comp/LabelEdit.ts         | 10 ++
 28 files changed, 236 insertions(+), 105 deletions(-)
 create mode 100644 models/migrations/v1_24/v319.go

diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go
index 737b69f154..f9e1fbeb14 100644
--- a/models/issues/issue_search.go
+++ b/models/issues/issue_search.go
@@ -21,6 +21,8 @@ import (
 	"xorm.io/xorm"
 )
 
+const ScopeSortPrefix = "scope-"
+
 // IssuesOptions represents options of an issue.
 type IssuesOptions struct { //nolint
 	Paginator          *db.ListOptions
@@ -70,6 +72,17 @@ func (o *IssuesOptions) Copy(edit ...func(options *IssuesOptions)) *IssuesOption
 // applySorts sort an issues-related session based on the provided
 // sortType string
 func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {
+	// Since this sortType is dynamically created, it has to be treated specially.
+	if strings.HasPrefix(sortType, ScopeSortPrefix) {
+		scope := strings.TrimPrefix(sortType, ScopeSortPrefix)
+		sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id")
+		// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null
+		sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%")
+		// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int)
+		sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id")
+		return
+	}
+
 	switch sortType {
 	case "oldest":
 		sess.Asc("issue.created_unix").Asc("issue.id")
diff --git a/models/issues/label.go b/models/issues/label.go
index 8a5d9321cc..cfbe100926 100644
--- a/models/issues/label.go
+++ b/models/issues/label.go
@@ -87,6 +87,7 @@ type Label struct {
 	OrgID           int64 `xorm:"INDEX"`
 	Name            string
 	Exclusive       bool
+	ExclusiveOrder  int `xorm:"DEFAULT 0"` // 0 means no exclusive order
 	Description     string
 	Color           string `xorm:"VARCHAR(7)"`
 	NumIssues       int
@@ -236,7 +237,7 @@ func UpdateLabel(ctx context.Context, l *Label) error {
 	}
 	l.Color = color
 
-	return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "archived_unix")
+	return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix")
 }
 
 // DeleteLabel delete a label
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 6a60067782..31b035eb31 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -380,6 +380,7 @@ func prepareMigrationTasks() []*migration {
 		newMigration(316, "Add description for secrets and variables", v1_24.AddDescriptionForSecretsAndVariables),
 		newMigration(317, "Add new index for action for heatmap", v1_24.AddNewIndexForUserDashboard),
 		newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode),
+		newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable),
 	}
 	return preparedMigrations
 }
diff --git a/models/migrations/v1_24/v319.go b/models/migrations/v1_24/v319.go
new file mode 100644
index 0000000000..6983c38605
--- /dev/null
+++ b/models/migrations/v1_24/v319.go
@@ -0,0 +1,16 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_24 //nolint
+
+import (
+	"xorm.io/xorm"
+)
+
+func AddExclusiveOrderColumnToLabelTable(x *xorm.Engine) error {
+	type Label struct {
+		ExclusiveOrder int `xorm:"DEFAULT 0"`
+	}
+
+	return x.Sync(new(Label))
+}
diff --git a/modules/indexer/issues/db/db.go b/modules/indexer/issues/db/db.go
index 6124ad2515..50951f9c88 100644
--- a/modules/indexer/issues/db/db.go
+++ b/modules/indexer/issues/db/db.go
@@ -6,6 +6,7 @@ package db
 import (
 	"context"
 	"strings"
+	"sync"
 
 	"code.gitea.io/gitea/models/db"
 	issue_model "code.gitea.io/gitea/models/issues"
@@ -18,7 +19,7 @@ import (
 	"xorm.io/builder"
 )
 
-var _ internal.Indexer = &Indexer{}
+var _ internal.Indexer = (*Indexer)(nil)
 
 // Indexer implements Indexer interface to use database's like search
 type Indexer struct {
@@ -29,11 +30,9 @@ func (i *Indexer) SupportedSearchModes() []indexer.SearchMode {
 	return indexer.SearchModesExactWords()
 }
 
-func NewIndexer() *Indexer {
-	return &Indexer{
-		Indexer: &inner_db.Indexer{},
-	}
-}
+var GetIndexer = sync.OnceValue(func() *Indexer {
+	return &Indexer{Indexer: &inner_db.Indexer{}}
+})
 
 // Index dummy function
 func (i *Indexer) Index(_ context.Context, _ ...*internal.IndexerData) error {
@@ -122,7 +121,11 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
 		}, nil
 	}
 
-	ids, total, err := issue_model.IssueIDs(ctx, opt, cond)
+	return i.FindWithIssueOptions(ctx, opt, cond)
+}
+
+func (i *Indexer) FindWithIssueOptions(ctx context.Context, opt *issue_model.IssuesOptions, otherConds ...builder.Cond) (*internal.SearchResult, error) {
+	ids, total, err := issue_model.IssueIDs(ctx, opt, otherConds...)
 	if err != nil {
 		return nil, err
 	}
diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go
index 3b19d742ba..380a25dc23 100644
--- a/modules/indexer/issues/db/options.go
+++ b/modules/indexer/issues/db/options.go
@@ -6,6 +6,7 @@ package db
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	"code.gitea.io/gitea/models/db"
 	issue_model "code.gitea.io/gitea/models/issues"
@@ -34,7 +35,11 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
 	case internal.SortByDeadlineAsc:
 		sortType = "nearduedate"
 	default:
-		sortType = "newest"
+		if strings.HasPrefix(string(options.SortBy), issue_model.ScopeSortPrefix) {
+			sortType = string(options.SortBy)
+		} else {
+			sortType = "newest"
+		}
 	}
 
 	// See the comment of issues_model.SearchOptions for the reason why we need to convert
@@ -68,7 +73,6 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
 		ExcludedLabelNames: nil,
 		IncludeMilestones:  nil,
 		SortType:           sortType,
-		IssueIDs:           nil,
 		UpdatedAfterUnix:   options.UpdatedAfterUnix.Value(),
 		UpdatedBeforeUnix:  options.UpdatedBeforeUnix.Value(),
 		PriorityRepoID:     0,
diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go
index 4e2dff572a..f17724664d 100644
--- a/modules/indexer/issues/dboptions.go
+++ b/modules/indexer/issues/dboptions.go
@@ -4,12 +4,19 @@
 package issues
 
 import (
+	"strings"
+
 	"code.gitea.io/gitea/models/db"
 	issues_model "code.gitea.io/gitea/models/issues"
+	"code.gitea.io/gitea/modules/indexer/issues/internal"
 	"code.gitea.io/gitea/modules/optional"
+	"code.gitea.io/gitea/modules/setting"
 )
 
 func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
+	if opts.IssueIDs != nil {
+		setting.PanicInDevOrTesting("Indexer SearchOptions doesn't support IssueIDs")
+	}
 	searchOpt := &SearchOptions{
 		Keyword:    keyword,
 		RepoIDs:    opts.RepoIDs,
@@ -95,7 +102,11 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
 		// Unsupported sort type for search
 		fallthrough
 	default:
-		searchOpt.SortBy = SortByUpdatedDesc
+		if strings.HasPrefix(opts.SortType, issues_model.ScopeSortPrefix) {
+			searchOpt.SortBy = internal.SortBy(opts.SortType)
+		} else {
+			searchOpt.SortBy = SortByUpdatedDesc
+		}
 	}
 
 	return searchOpt
diff --git a/modules/indexer/issues/indexer.go b/modules/indexer/issues/indexer.go
index 4741235d47..9e63ad1ad8 100644
--- a/modules/indexer/issues/indexer.go
+++ b/modules/indexer/issues/indexer.go
@@ -103,7 +103,7 @@ func InitIssueIndexer(syncReindex bool) {
 				log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err)
 			}
 		case "db":
-			issueIndexer = db.NewIndexer()
+			issueIndexer = db.GetIndexer()
 		case "meilisearch":
 			issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName)
 			existed, err = issueIndexer.Init(ctx)
@@ -291,19 +291,22 @@ func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, err
 		// So if the user creates an issue and list issues immediately, the issue may not be listed because the indexer needs time to index the issue.
 		// Even worse, the external indexer like elastic search may not be available for a while,
 		// and the user may not be able to list issues completely until it is available again.
-		ix = db.NewIndexer()
+		ix = db.GetIndexer()
 	}
+
 	result, err := ix.Search(ctx, opts)
 	if err != nil {
 		return nil, 0, err
 	}
+	return SearchResultToIDSlice(result), result.Total, nil
+}
 
+func SearchResultToIDSlice(result *internal.SearchResult) []int64 {
 	ret := make([]int64, 0, len(result.Hits))
 	for _, hit := range result.Hits {
 		ret = append(ret, hit.ID)
 	}
-
-	return ret, result.Total, nil
+	return ret
 }
 
 // CountIssues counts issues by options. It is a shortcut of SearchIssues(ctx, opts) but only returns the total count.
diff --git a/modules/label/label.go b/modules/label/label.go
index d3ef0e1dc9..ce028aa9f3 100644
--- a/modules/label/label.go
+++ b/modules/label/label.go
@@ -14,10 +14,11 @@ var colorPattern = regexp.MustCompile("^#?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
 
 // Label represents label information loaded from template
 type Label struct {
-	Name        string `yaml:"name"`
-	Color       string `yaml:"color"`
-	Description string `yaml:"description,omitempty"`
-	Exclusive   bool   `yaml:"exclusive,omitempty"`
+	Name           string `yaml:"name"`
+	Color          string `yaml:"color"`
+	Description    string `yaml:"description,omitempty"`
+	Exclusive      bool   `yaml:"exclusive,omitempty"`
+	ExclusiveOrder int    `yaml:"exclusive_order,omitempty"`
 }
 
 // NormalizeColor normalizes a color string to a 6-character hex code
diff --git a/modules/repository/init.go b/modules/repository/init.go
index e6331966ba..91d4889782 100644
--- a/modules/repository/init.go
+++ b/modules/repository/init.go
@@ -127,10 +127,11 @@ func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg
 	labels := make([]*issues_model.Label, len(list))
 	for i := 0; i < len(list); i++ {
 		labels[i] = &issues_model.Label{
-			Name:        list[i].Name,
-			Exclusive:   list[i].Exclusive,
-			Description: list[i].Description,
-			Color:       list[i].Color,
+			Name:           list[i].Name,
+			Exclusive:      list[i].Exclusive,
+			ExclusiveOrder: list[i].ExclusiveOrder,
+			Description:    list[i].Description,
+			Color:          list[i].Color,
 		}
 		if isOrg {
 			labels[i].OrgID = id
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
index ae397d87c9..521233db40 100644
--- a/modules/templates/util_render.go
+++ b/modules/templates/util_render.go
@@ -170,13 +170,28 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
 	itemColor := "#" + hex.EncodeToString(itemBytes)
 	scopeColor := "#" + hex.EncodeToString(scopeBytes)
 
+	if label.ExclusiveOrder > 0 {
+		// <scope> | <label> | <order>
+		return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
+			`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
+			`<div class="ui label scope-middle" style="color: %s !important; background-color: %s !important">%s</div>`+
+			`<div class="ui label scope-right">%d</div>`+
+			`</span>`,
+			extraCSSClasses, descriptionText,
+			textColor, scopeColor, scopeHTML,
+			textColor, itemColor, itemHTML,
+			label.ExclusiveOrder)
+	}
+
+	// <scope> | <label>
 	return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
 		`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
 		`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+
 		`</span>`,
 		extraCSSClasses, descriptionText,
 		textColor, scopeColor, scopeHTML,
-		textColor, itemColor, itemHTML)
+		textColor, itemColor, itemHTML,
+	)
 }
 
 // RenderEmoji renders html text with emoji post processors
diff --git a/options/label/Advanced.yaml b/options/label/Advanced.yaml
index b1ecdd6d93..860645d5d5 100644
--- a/options/label/Advanced.yaml
+++ b/options/label/Advanced.yaml
@@ -22,49 +22,60 @@ labels:
     description: Breaking change that won't be backward compatible
   - name: "Reviewed/Duplicate"
     exclusive: true
+    exclusive_order: 2
     color: 616161
     description: This issue or pull request already exists
   - name: "Reviewed/Invalid"
     exclusive: true
+    exclusive_order: 3
     color: 546e7a
     description: Invalid issue
   - name: "Reviewed/Confirmed"
     exclusive: true
+    exclusive_order: 1
     color: 795548
     description: Issue has been confirmed
   - name: "Reviewed/Won't Fix"
     exclusive: true
+    exclusive_order: 3
     color: eeeeee
     description: This issue won't be fixed
   - name: "Status/Need More Info"
     exclusive: true
+    exclusive_order: 2
     color: 424242
     description: Feedback is required to reproduce issue or to continue work
   - name: "Status/Blocked"
     exclusive: true
+    exclusive_order: 1
     color: 880e4f
     description: Something is blocking this issue or pull request
   - name: "Status/Abandoned"
     exclusive: true
+    exclusive_order: 3
     color: "222222"
     description: Somebody has started to work on this but abandoned work
   - name: "Priority/Critical"
     exclusive: true
+    exclusive_order: 1
     color: b71c1c
     description: The priority is critical
     priority: critical
   - name: "Priority/High"
     exclusive: true
+    exclusive_order: 2
     color: d32f2f
     description: The priority is high
     priority: high
   - name: "Priority/Medium"
     exclusive: true
+    exclusive_order: 3
     color: e64a19
     description: The priority is medium
     priority: medium
   - name: "Priority/Low"
     exclusive: true
+    exclusive_order: 4
     color: 4caf50
     description: The priority is low
     priority: low
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index d7da975a21..9e6c5e61ac 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1655,6 +1655,8 @@ issues.label_archived_filter = Show archived labels
 issues.label_archive_tooltip = Archived labels are excluded by default from the suggestions when searching by label.
 issues.label_exclusive_desc = Name the label <code>scope/item</code> to make it mutually exclusive with other <code>scope/</code> labels.
 issues.label_exclusive_warning = Any conflicting scoped labels will be removed when editing the labels of an issue or pull request.
+issues.label_exclusive_order = Sort Order
+issues.label_exclusive_order_tooltip = Exclusive labels in the same scope will be sorted according to this numeric order.
 issues.label_count = %d labels
 issues.label_open_issues = %d open issues/pull requests
 issues.label_edit = Edit
diff --git a/routers/web/org/org_labels.go b/routers/web/org/org_labels.go
index ccab2131db..456ed3f01e 100644
--- a/routers/web/org/org_labels.go
+++ b/routers/web/org/org_labels.go
@@ -44,11 +44,12 @@ func NewLabel(ctx *context.Context) {
 	}
 
 	l := &issues_model.Label{
-		OrgID:       ctx.Org.Organization.ID,
-		Name:        form.Title,
-		Exclusive:   form.Exclusive,
-		Description: form.Description,
-		Color:       form.Color,
+		OrgID:          ctx.Org.Organization.ID,
+		Name:           form.Title,
+		Exclusive:      form.Exclusive,
+		Description:    form.Description,
+		Color:          form.Color,
+		ExclusiveOrder: form.ExclusiveOrder,
 	}
 	if err := issues_model.NewLabel(ctx, l); err != nil {
 		ctx.ServerError("NewLabel", err)
@@ -73,6 +74,7 @@ func UpdateLabel(ctx *context.Context) {
 
 	l.Name = form.Title
 	l.Exclusive = form.Exclusive
+	l.ExclusiveOrder = form.ExclusiveOrder
 	l.Description = form.Description
 	l.Color = form.Color
 	l.SetArchived(form.IsArchived)
diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go
index 49f4792772..cd1d0d4dac 100644
--- a/routers/web/org/projects.go
+++ b/routers/web/org/projects.go
@@ -343,14 +343,14 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	labelIDs := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
+	preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
 	if ctx.Written() {
 		return
 	}
 	assigneeID := ctx.FormString("assignee")
 
 	opts := issues_model.IssuesOptions{
-		LabelIDs:   labelIDs,
+		LabelIDs:   preparedLabelFilter.SelectedLabelIDs,
 		AssigneeID: assigneeID,
 		Owner:      project.Owner,
 		Doer:       ctx.Doer,
@@ -406,8 +406,8 @@ func ViewProject(ctx *context.Context) {
 	}
 
 	// Get the exclusive scope for every label ID
-	labelExclusiveScopes := make([]string, 0, len(labelIDs))
-	for _, labelID := range labelIDs {
+	labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs))
+	for _, labelID := range preparedLabelFilter.SelectedLabelIDs {
 		foundExclusiveScope := false
 		for _, label := range labels {
 			if label.ID == labelID || label.ID == -labelID {
@@ -422,7 +422,7 @@ func ViewProject(ctx *context.Context) {
 	}
 
 	for _, l := range labels {
-		l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
+		l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes)
 	}
 	ctx.Data["Labels"] = labels
 	ctx.Data["NumLabels"] = len(labels)
diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go
index 62c0128f19..f9c41adbcf 100644
--- a/routers/web/repo/issue_label.go
+++ b/routers/web/repo/issue_label.go
@@ -111,11 +111,12 @@ func NewLabel(ctx *context.Context) {
 	}
 
 	l := &issues_model.Label{
-		RepoID:      ctx.Repo.Repository.ID,
-		Name:        form.Title,
-		Exclusive:   form.Exclusive,
-		Description: form.Description,
-		Color:       form.Color,
+		RepoID:         ctx.Repo.Repository.ID,
+		Name:           form.Title,
+		Exclusive:      form.Exclusive,
+		ExclusiveOrder: form.ExclusiveOrder,
+		Description:    form.Description,
+		Color:          form.Color,
 	}
 	if err := issues_model.NewLabel(ctx, l); err != nil {
 		ctx.ServerError("NewLabel", err)
@@ -139,6 +140,7 @@ func UpdateLabel(ctx *context.Context) {
 	}
 	l.Name = form.Title
 	l.Exclusive = form.Exclusive
+	l.ExclusiveOrder = form.ExclusiveOrder
 	l.Description = form.Description
 	l.Color = form.Color
 
diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go
index d8ab653584..35107bc585 100644
--- a/routers/web/repo/issue_list.go
+++ b/routers/web/repo/issue_list.go
@@ -5,8 +5,10 @@ package repo
 
 import (
 	"bytes"
-	"fmt"
+	"maps"
 	"net/http"
+	"slices"
+	"sort"
 	"strconv"
 	"strings"
 
@@ -18,6 +20,7 @@ import (
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
 	issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
+	db_indexer "code.gitea.io/gitea/modules/indexer/issues/db"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/optional"
 	"code.gitea.io/gitea/modules/setting"
@@ -30,14 +33,6 @@ import (
 	pull_service "code.gitea.io/gitea/services/pull"
 )
 
-func issueIDsFromSearch(ctx *context.Context, keyword string, opts *issues_model.IssuesOptions) ([]int64, error) {
-	ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts))
-	if err != nil {
-		return nil, fmt.Errorf("SearchIssues: %w", err)
-	}
-	return ids, nil
-}
-
 func retrieveProjectsForIssueList(ctx *context.Context, repo *repo_model.Repository) {
 	ctx.Data["OpenProjects"], ctx.Data["ClosedProjects"] = retrieveProjectsInternal(ctx, repo)
 }
@@ -459,6 +454,19 @@ func UpdateIssueStatus(ctx *context.Context) {
 	ctx.JSONOK()
 }
 
+func prepareIssueFilterExclusiveOrderScopes(ctx *context.Context, allLabels []*issues_model.Label) {
+	scopeSet := make(map[string]bool)
+	for _, label := range allLabels {
+		scope := label.ExclusiveScope()
+		if len(scope) > 0 && label.ExclusiveOrder > 0 {
+			scopeSet[scope] = true
+		}
+	}
+	scopes := slices.Collect(maps.Keys(scopeSet))
+	sort.Strings(scopes)
+	ctx.Data["ExclusiveLabelScopes"] = scopes
+}
+
 func renderMilestones(ctx *context.Context) {
 	// Get milestones
 	milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
@@ -481,7 +489,7 @@ func renderMilestones(ctx *context.Context) {
 	ctx.Data["ClosedMilestones"] = closedMilestones
 }
 
-func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) {
+func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) {
 	var err error
 	viewType := ctx.FormString("type")
 	sortType := ctx.FormString("sort")
@@ -521,15 +529,18 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 		mileIDs = []int64{milestoneID}
 	}
 
-	labelIDs := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner)
+	preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner)
 	if ctx.Written() {
 		return
 	}
 
+	prepareIssueFilterExclusiveOrderScopes(ctx, preparedLabelFilter.AllLabels)
+
+	var keywordMatchedIssueIDs []int64
 	var issueStats *issues_model.IssueStats
 	statsOpts := &issues_model.IssuesOptions{
 		RepoIDs:           []int64{repo.ID},
-		LabelIDs:          labelIDs,
+		LabelIDs:          preparedLabelFilter.SelectedLabelIDs,
 		MilestoneIDs:      mileIDs,
 		ProjectID:         projectID,
 		AssigneeID:        assigneeID,
@@ -541,7 +552,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 		IssueIDs:          nil,
 	}
 	if keyword != "" {
-		allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts)
+		keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts))
 		if err != nil {
 			if issue_indexer.IsAvailable(ctx) {
 				ctx.ServerError("issueIDsFromSearch", err)
@@ -550,14 +561,17 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 			ctx.Data["IssueIndexerUnavailable"] = true
 			return
 		}
-		statsOpts.IssueIDs = allIssueIDs
+		if len(keywordMatchedIssueIDs) == 0 {
+			// It did search with the keyword, but no issue found, just set issueStats to empty, then no need to do query again.
+			issueStats = &issues_model.IssueStats{}
+			// set keywordMatchedIssueIDs to empty slice, so we can distinguish it from "nil"
+			keywordMatchedIssueIDs = []int64{}
+		}
+		statsOpts.IssueIDs = keywordMatchedIssueIDs
 	}
-	if keyword != "" && len(statsOpts.IssueIDs) == 0 {
-		// So it did search with the keyword, but no issue found.
-		// Just set issueStats to empty.
-		issueStats = &issues_model.IssueStats{}
-	} else {
-		// So it did search with the keyword, and found some issues. It needs to get issueStats of these issues.
+
+	if issueStats == nil {
+		// Either it did search with the keyword, and found some issues, it needs to get issueStats of these issues.
 		// Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts.
 		issueStats, err = issues_model.GetIssueStats(ctx, statsOpts)
 		if err != nil {
@@ -589,25 +603,21 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 		ctx.Data["TotalTrackedTime"] = totalTrackedTime
 	}
 
-	page := ctx.FormInt("page")
-	if page <= 1 {
-		page = 1
-	}
-
-	var total int
-	switch {
-	case isShowClosed.Value():
-		total = int(issueStats.ClosedCount)
-	case !isShowClosed.Has():
-		total = int(issueStats.OpenCount + issueStats.ClosedCount)
-	default:
-		total = int(issueStats.OpenCount)
+	// prepare pager
+	total := int(issueStats.OpenCount + issueStats.ClosedCount)
+	if isShowClosed.Has() {
+		total = util.Iif(isShowClosed.Value(), int(issueStats.ClosedCount), int(issueStats.OpenCount))
 	}
+	page := max(ctx.FormInt("page"), 1)
 	pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5)
 
+	// prepare real issue list:
 	var issues issues_model.IssueList
-	{
-		ids, err := issueIDsFromSearch(ctx, keyword, &issues_model.IssuesOptions{
+	if keywordMatchedIssueIDs == nil || len(keywordMatchedIssueIDs) > 0 {
+		// Either it did search with the keyword, and found some issues, then keywordMatchedIssueIDs is not null, it needs to use db indexer.
+		// Or the keyword is empty, it also needs to usd db indexer.
+		// In either case, no need to use keyword anymore
+		searchResult, err := db_indexer.GetIndexer().FindWithIssueOptions(ctx, &issues_model.IssuesOptions{
 			Paginator: &db.ListOptions{
 				Page:     pager.Paginater.Current(),
 				PageSize: setting.UI.IssuePagingNum,
@@ -622,18 +632,16 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 			ProjectID:         projectID,
 			IsClosed:          isShowClosed,
 			IsPull:            isPullOption,
-			LabelIDs:          labelIDs,
+			LabelIDs:          preparedLabelFilter.SelectedLabelIDs,
 			SortType:          sortType,
+			IssueIDs:          keywordMatchedIssueIDs,
 		})
 		if err != nil {
-			if issue_indexer.IsAvailable(ctx) {
-				ctx.ServerError("issueIDsFromSearch", err)
-				return
-			}
-			ctx.Data["IssueIndexerUnavailable"] = true
+			ctx.ServerError("DBIndexer.Search", err)
 			return
 		}
-		issues, err = issues_model.GetIssuesByIDs(ctx, ids, true)
+		issueIDs := issue_indexer.SearchResultToIDSlice(searchResult)
+		issues, err = issues_model.GetIssuesByIDs(ctx, issueIDs, true)
 		if err != nil {
 			ctx.ServerError("GetIssuesByIDs", err)
 			return
@@ -728,7 +736,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 	ctx.Data["IssueStats"] = issueStats
 	ctx.Data["OpenCount"] = issueStats.OpenCount
 	ctx.Data["ClosedCount"] = issueStats.ClosedCount
-	ctx.Data["SelLabelIDs"] = labelIDs
+	ctx.Data["SelLabelIDs"] = preparedLabelFilter.SelectedLabelIDs
 	ctx.Data["ViewType"] = viewType
 	ctx.Data["SortType"] = sortType
 	ctx.Data["MilestoneID"] = milestoneID
@@ -769,7 +777,7 @@ func Issues(ctx *context.Context) {
 		ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
 	}
 
-	issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList))
+	prepareIssueFilterAndList(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList))
 	if ctx.Written() {
 		return
 	}
diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go
index f1d0a857ea..8a26a0dcc3 100644
--- a/routers/web/repo/milestone.go
+++ b/routers/web/repo/milestone.go
@@ -263,7 +263,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) {
 	ctx.Data["Title"] = milestone.Name
 	ctx.Data["Milestone"] = milestone
 
-	issues(ctx, milestoneID, projectID, optional.None[bool]())
+	prepareIssueFilterAndList(ctx, milestoneID, projectID, optional.None[bool]())
 
 	ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
 	ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 6810025c6f..0bf1f64d09 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -313,13 +313,13 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	labelIDs := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner)
+	preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner)
 
 	assigneeID := ctx.FormString("assignee")
 
 	issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{
 		RepoIDs:    []int64{ctx.Repo.Repository.ID},
-		LabelIDs:   labelIDs,
+		LabelIDs:   preparedLabelFilter.SelectedLabelIDs,
 		AssigneeID: assigneeID,
 	})
 	if err != nil {
@@ -381,8 +381,8 @@ func ViewProject(ctx *context.Context) {
 	}
 
 	// Get the exclusive scope for every label ID
-	labelExclusiveScopes := make([]string, 0, len(labelIDs))
-	for _, labelID := range labelIDs {
+	labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs))
+	for _, labelID := range preparedLabelFilter.SelectedLabelIDs {
 		foundExclusiveScope := false
 		for _, label := range labels {
 			if label.ID == labelID || label.ID == -labelID {
@@ -397,7 +397,7 @@ func ViewProject(ctx *context.Context) {
 	}
 
 	for _, l := range labels {
-		l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
+		l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes)
 	}
 	ctx.Data["Labels"] = labels
 	ctx.Data["NumLabels"] = len(labels)
diff --git a/routers/web/shared/issue/issue_label.go b/routers/web/shared/issue/issue_label.go
index eacea36b02..e2eeaaf0af 100644
--- a/routers/web/shared/issue/issue_label.go
+++ b/routers/web/shared/issue/issue_label.go
@@ -14,14 +14,18 @@ import (
 )
 
 // PrepareFilterIssueLabels reads the "labels" query parameter, sets `ctx.Data["Labels"]` and `ctx.Data["SelectLabels"]`
-func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_model.User) (labelIDs []int64) {
+func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_model.User) (ret struct {
+	AllLabels        []*issues_model.Label
+	SelectedLabelIDs []int64
+},
+) {
 	// 1,-2 means including label 1 and excluding label 2
 	// 0 means issues with no label
 	// blank means labels will not be filtered for issues
 	selectLabels := ctx.FormString("labels")
 	if selectLabels != "" {
 		var err error
-		labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
+		ret.SelectedLabelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
 		if err != nil {
 			ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
 		}
@@ -32,7 +36,7 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo
 		repoLabels, err := issues_model.GetLabelsByRepoID(ctx, repoID, "", db.ListOptions{})
 		if err != nil {
 			ctx.ServerError("GetLabelsByRepoID", err)
-			return nil
+			return ret
 		}
 		allLabels = append(allLabels, repoLabels...)
 	}
@@ -41,14 +45,14 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo
 		orgLabels, err := issues_model.GetLabelsByOrgID(ctx, owner.ID, "", db.ListOptions{})
 		if err != nil {
 			ctx.ServerError("GetLabelsByOrgID", err)
-			return nil
+			return ret
 		}
 		allLabels = append(allLabels, orgLabels...)
 	}
 
 	// Get the exclusive scope for every label ID
-	labelExclusiveScopes := make([]string, 0, len(labelIDs))
-	for _, labelID := range labelIDs {
+	labelExclusiveScopes := make([]string, 0, len(ret.SelectedLabelIDs))
+	for _, labelID := range ret.SelectedLabelIDs {
 		foundExclusiveScope := false
 		for _, label := range allLabels {
 			if label.ID == labelID || label.ID == -labelID {
@@ -63,9 +67,10 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo
 	}
 
 	for _, l := range allLabels {
-		l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
+		l.LoadSelectedLabelsAfterClick(ret.SelectedLabelIDs, labelExclusiveScopes)
 	}
 	ctx.Data["Labels"] = allLabels
 	ctx.Data["SelectLabels"] = selectLabels
-	return labelIDs
+	ret.AllLabels = allLabels
+	return ret
 }
diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go
index c3d8b93adb..9c43ddd3ea 100644
--- a/routers/web/user/setting/applications.go
+++ b/routers/web/user/setting/applications.go
@@ -43,8 +43,9 @@ func ApplicationsPost(ctx *context.Context) {
 
 	_ = ctx.Req.ParseForm()
 	var scopeNames []string
+	const accessTokenScopePrefix = "scope-"
 	for k, v := range ctx.Req.Form {
-		if strings.HasPrefix(k, "scope-") {
+		if strings.HasPrefix(k, accessTokenScopePrefix) {
 			scopeNames = append(scopeNames, v...)
 		}
 	}
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index d20220b784..434274c174 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -519,12 +519,13 @@ func (f *CreateMilestoneForm) Validate(req *http.Request, errs binding.Errors) b
 
 // CreateLabelForm form for creating label
 type CreateLabelForm struct {
-	ID          int64
-	Title       string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"`
-	Exclusive   bool   `form:"exclusive"`
-	IsArchived  bool   `form:"is_archived"`
-	Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"`
-	Color       string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"`
+	ID             int64
+	Title          string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"`
+	Exclusive      bool   `form:"exclusive"`
+	ExclusiveOrder int    `form:"exclusive_order"`
+	IsArchived     bool   `form:"is_archived"`
+	Description    string `binding:"MaxSize(200)" locale:"repo.issues.label_description"`
+	Color          string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"`
 }
 
 // Validate validates the fields
diff --git a/templates/repo/issue/filter_list.tmpl b/templates/repo/issue/filter_list.tmpl
index cd04f1c317..048b5f37b7 100644
--- a/templates/repo/issue/filter_list.tmpl
+++ b/templates/repo/issue/filter_list.tmpl
@@ -133,5 +133,11 @@
 		<a class="{{if eq .SortType "leastcomment"}}active {{end}}item" href="{{QueryBuild $queryLink "sort" "leastcomment"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastcomment"}}</a>
 		<a class="{{if eq .SortType "nearduedate"}}active {{end}}item" href="{{QueryBuild $queryLink "sort" "nearduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.nearduedate"}}</a>
 		<a class="{{if eq .SortType "farduedate"}}active {{end}}item" href="{{QueryBuild $queryLink "sort" "farduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.farduedate"}}</a>
+		<div class="divider"></div>
+		<div class="header">{{ctx.Locale.Tr "repo.issues.filter_label"}}</div>
+		{{range $scope := .ExclusiveLabelScopes}}
+			{{$sortType := (printf "scope-%s" $scope)}}
+			<a class="{{if eq $.SortType $sortType}}active {{end}}item" href="{{QueryBuild $queryLink "sort" $sortType}}">{{$scope}}</a>
+		{{end}}
 	</div>
 </div>
diff --git a/templates/repo/issue/labels/label_edit_modal.tmpl b/templates/repo/issue/labels/label_edit_modal.tmpl
index 527b7ff900..06c397ba8d 100644
--- a/templates/repo/issue/labels/label_edit_modal.tmpl
+++ b/templates/repo/issue/labels/label_edit_modal.tmpl
@@ -24,7 +24,13 @@
 				<div class="desc tw-ml-1 tw-mt-2 tw-hidden label-exclusive-warning">
 					{{svg "octicon-alert"}} {{ctx.Locale.Tr "repo.issues.label_exclusive_warning"}}
 				</div>
-				<br>
+				<div class="field label-exclusive-order-input-field tw-mt-2">
+					<label class="flex-text-block">
+						{{ctx.Locale.Tr "repo.issues.label_exclusive_order"}}
+						<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.label_exclusive_order_tooltip"}}">{{svg "octicon-info"}}</span>
+					</label>
+					<input class="label-exclusive-order-input" name="exclusive_order" type="number" maxlength="4">
+				</div>
 			</div>
 			<div class="field label-is-archived-input-field">
 				<div class="ui checkbox">
diff --git a/templates/repo/issue/labels/label_list.tmpl b/templates/repo/issue/labels/label_list.tmpl
index cdbcc456f0..cc231971e0 100644
--- a/templates/repo/issue/labels/label_list.tmpl
+++ b/templates/repo/issue/labels/label_list.tmpl
@@ -50,6 +50,7 @@
 							data-label-id="{{.ID}}" data-label-name="{{.Name}}" data-label-color="{{.Color}}"
 							data-label-exclusive="{{.Exclusive}}" data-label-is-archived="{{gt .ArchivedUnix 0}}"
 							data-label-num-issues="{{.NumIssues}}" data-label-description="{{.Description}}"
+							data-label-exclusive-order="{{.ExclusiveOrder}}"
 						>{{svg "octicon-pencil"}} {{ctx.Locale.Tr "repo.issues.label_edit"}}</a>
 						<a class="link-action" href="#" data-url="{{$.Link}}/delete?id={{.ID}}"
 							data-modal-confirm-header="{{ctx.Locale.Tr "repo.issues.label_deletion"}}"
diff --git a/web_src/css/base.css b/web_src/css/base.css
index 5a0579f356..37ee7f5832 100644
--- a/web_src/css/base.css
+++ b/web_src/css/base.css
@@ -1127,6 +1127,7 @@ table th[data-sortt-desc] .svg {
 }
 
 .ui.list.flex-items-block > .item,
+.ui.form .field > label.flex-text-block, /* override fomantic "block" style */
 .flex-items-block > .item,
 .flex-text-block {
   display: flex;
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index db44e2a778..91c1ee8607 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -1604,6 +1604,12 @@ td .commit-summary {
   margin-right: 0;
 }
 
+.ui.label.scope-middle {
+  border-radius: 0;
+  margin-left: 0;
+  margin-right: 0;
+}
+
 .ui.label.scope-right {
   border-bottom-left-radius: 0;
   border-top-left-radius: 0;
diff --git a/web_src/js/features/comp/LabelEdit.ts b/web_src/js/features/comp/LabelEdit.ts
index 7bceb636bb..55351cd900 100644
--- a/web_src/js/features/comp/LabelEdit.ts
+++ b/web_src/js/features/comp/LabelEdit.ts
@@ -18,6 +18,8 @@ export function initCompLabelEdit(pageSelector: string) {
   const elExclusiveField = elModal.querySelector('.label-exclusive-input-field');
   const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input');
   const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning');
+  const elExclusiveOrderField = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input-field');
+  const elExclusiveOrderInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input');
   const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field');
   const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input');
   const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-input');
@@ -29,6 +31,13 @@ export function initCompLabelEdit(pageSelector: string) {
     const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive');
     toggleElem(elExclusiveWarning, showExclusiveWarning);
     if (!hasScope) elExclusiveInput.checked = false;
+    toggleElem(elExclusiveOrderField, elExclusiveInput.checked);
+
+    if (parseInt(elExclusiveOrderInput.value) <= 0) {
+      elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important';
+    } else {
+      elExclusiveOrderInput.style.color = null;
+    }
   };
 
   const showLabelEditModal = (btn:HTMLElement) => {
@@ -36,6 +45,7 @@ export function initCompLabelEdit(pageSelector: string) {
     const form = elModal.querySelector<HTMLFormElement>('form');
     elLabelId.value = btn.getAttribute('data-label-id') || '';
     elNameInput.value = btn.getAttribute('data-label-name') || '';
+    elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0';
     elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true';
     elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true';
     elDescInput.value = btn.getAttribute('data-label-description') || '';

From 4ddf94dee5c7b8395c86ab6f5947adf52d01c1d3 Mon Sep 17 00:00:00 2001
From: Exploding Dragon <explodingfkl@gmail.com>
Date: Fri, 11 Apr 2025 03:12:55 +0800
Subject: [PATCH 09/14] refactor organization menu (#33928)

Fix missing items in organization menu.

**Menu**

<details>
<summary>Show</summary>

Before:

![](https://github.com/user-attachments/assets/bcbce97e-84de-44ea-9889-a664979433cd)

After:

![](https://github.com/user-attachments/assets/a169e00f-5212-4733-af9e-e8676ad74376)

</details>

**Packages**

<details>

 keep it consistent with the other pages.

<summary>Show</summary>

Before:

![](https://github.com/user-attachments/assets/170d7b3d-ecac-49b9-8296-44d0b0f2b191)

After:

![](https://github.com/user-attachments/assets/9c3c6915-870c-48cc-8a35-3d615a27d36d)

</details>

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 routers/web/org/block.go                  |  10 ++
 routers/web/org/home.go                   |  12 +--
 routers/web/org/members.go                |   5 +-
 routers/web/org/projects.go               |  42 ++++----
 routers/web/org/setting.go                |  20 ++--
 routers/web/org/setting_oauth2.go         |   5 +-
 routers/web/org/setting_packages.go       |  20 ++--
 routers/web/org/teams.go                  |  46 ++++----
 routers/web/org/worktime.go               |   7 ++
 routers/web/repo/setting/secrets.go       |   5 +-
 routers/web/shared/actions/runners.go     |   5 +-
 routers/web/shared/actions/variables.go   |   5 +-
 routers/web/shared/user/header.go         |  83 +++++++--------
 routers/web/user/code.go                  |   7 +-
 routers/web/user/package.go               |  55 ++++------
 routers/web/user/profile.go               |  22 ++--
 routers/web/user/setting/oauth2_common.go |   4 +-
 templates/org/menu.tmpl                   |   2 +-
 templates/org/projects/new.tmpl           |  23 +++-
 templates/package/shared/view.tmpl        | 106 +++++++++++++++++++
 templates/package/view.tmpl               | 122 +++-------------------
 21 files changed, 304 insertions(+), 302 deletions(-)
 create mode 100644 templates/package/shared/view.tmpl

diff --git a/routers/web/org/block.go b/routers/web/org/block.go
index aeb4bd51a8..60f722dd39 100644
--- a/routers/web/org/block.go
+++ b/routers/web/org/block.go
@@ -20,6 +20,11 @@ func BlockedUsers(ctx *context.Context) {
 	ctx.Data["PageIsOrgSettings"] = true
 	ctx.Data["PageIsSettingsBlockedUsers"] = true
 
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
+
 	shared_user.BlockedUsers(ctx, ctx.ContextUser)
 	if ctx.Written() {
 		return
@@ -29,6 +34,11 @@ func BlockedUsers(ctx *context.Context) {
 }
 
 func BlockedUsersPost(ctx *context.Context) {
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
+
 	shared_user.BlockedUsersPost(ctx, ctx.ContextUser)
 	if ctx.Written() {
 		return
diff --git a/routers/web/org/home.go b/routers/web/org/home.go
index e3c2dcf0bd..8981af1691 100644
--- a/routers/web/org/home.go
+++ b/routers/web/org/home.go
@@ -86,12 +86,6 @@ func home(ctx *context.Context, viewRepositories bool) {
 	private := ctx.FormOptionalBool("private")
 	ctx.Data["IsPrivate"] = private
 
-	err := shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
-		return
-	}
-
 	opts := &organization.FindOrgMembersOpts{
 		Doer:         ctx.Doer,
 		OrgID:        org.ID,
@@ -109,9 +103,9 @@ func home(ctx *context.Context, viewRepositories bool) {
 	ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
 	ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0
 
-	prepareResult, err := shared_user.PrepareOrgHeader(ctx)
+	prepareResult, err := shared_user.RenderUserOrgHeader(ctx)
 	if err != nil {
-		ctx.ServerError("PrepareOrgHeader", err)
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
@@ -154,7 +148,7 @@ func home(ctx *context.Context, viewRepositories bool) {
 	ctx.HTML(http.StatusOK, tplOrgHome)
 }
 
-func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.PrepareOrgHeaderResult) bool {
+func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.PrepareOwnerHeaderResult) bool {
 	viewAs := ctx.FormString("view_as", util.Iif(ctx.Org.IsMember, "member", "public"))
 	viewAsMember := viewAs == "member"
 
diff --git a/routers/web/org/members.go b/routers/web/org/members.go
index 7d88d6b1ad..2cbe75989a 100644
--- a/routers/web/org/members.go
+++ b/routers/web/org/members.go
@@ -54,9 +54,8 @@ func Members(ctx *context.Context) {
 		return
 	}
 
-	_, err = shared_user.PrepareOrgHeader(ctx)
-	if err != nil {
-		ctx.ServerError("PrepareOrgHeader", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go
index cd1d0d4dac..f423e9cb36 100644
--- a/routers/web/org/projects.go
+++ b/routers/web/org/projects.go
@@ -43,7 +43,10 @@ func MustEnableProjects(ctx *context.Context) {
 
 // Projects renders the home page of projects
 func Projects(ctx *context.Context) {
-	shared_user.PrepareContextForProfileBigAvatar(ctx)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
 	ctx.Data["Title"] = ctx.Tr("repo.projects")
 
 	sortType := ctx.FormTrim("sort")
@@ -101,7 +104,6 @@ func Projects(ctx *context.Context) {
 	}
 
 	ctx.Data["Projects"] = projects
-	shared_user.RenderUserHeader(ctx)
 
 	if isShowClosed {
 		ctx.Data["State"] = "closed"
@@ -113,12 +115,6 @@ func Projects(ctx *context.Context) {
 		project.RenderedContent = renderUtils.MarkdownToHtml(project.Description)
 	}
 
-	err = shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
-		return
-	}
-
 	numPages := 0
 	if total > 0 {
 		numPages = (int(total) - 1/setting.UI.IssuePagingNum)
@@ -152,11 +148,8 @@ func RenderNewProject(ctx *context.Context) {
 	ctx.Data["PageIsViewProjects"] = true
 	ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink()
 	ctx.Data["CancelLink"] = ctx.ContextUser.HomeLink() + "/-/projects"
-	shared_user.RenderUserHeader(ctx)
-
-	err := shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
@@ -167,7 +160,10 @@ func RenderNewProject(ctx *context.Context) {
 func NewProjectPost(ctx *context.Context) {
 	form := web.GetForm(ctx).(*forms.CreateProjectForm)
 	ctx.Data["Title"] = ctx.Tr("repo.projects.new")
-	shared_user.RenderUserHeader(ctx)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
 
 	if ctx.HasError() {
 		RenderNewProject(ctx)
@@ -248,7 +244,10 @@ func RenderEditProject(ctx *context.Context) {
 	ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
 	ctx.Data["CardTypes"] = project_model.GetCardConfig()
 
-	shared_user.RenderUserHeader(ctx)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
 
 	p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id"))
 	if err != nil {
@@ -282,11 +281,8 @@ func EditProjectPost(ctx *context.Context) {
 	ctx.Data["CardTypes"] = project_model.GetCardConfig()
 	ctx.Data["CancelLink"] = project_model.ProjectLinkForOrg(ctx.ContextUser, projectID)
 
-	shared_user.RenderUserHeader(ctx)
-
-	err := shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
@@ -443,11 +439,9 @@ func ViewProject(ctx *context.Context) {
 	ctx.Data["Project"] = project
 	ctx.Data["IssuesMap"] = issuesMap
 	ctx.Data["Columns"] = columns
-	shared_user.RenderUserHeader(ctx)
 
-	err = shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go
index a8a81e0ade..82c3bce722 100644
--- a/routers/web/org/setting.go
+++ b/routers/web/org/setting.go
@@ -48,9 +48,8 @@ func Settings(ctx *context.Context) {
 	ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess
 	ctx.Data["ContextUser"] = ctx.ContextUser
 
-	err := shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
@@ -194,9 +193,8 @@ func SettingsDelete(ctx *context.Context) {
 		return
 	}
 
-	err := shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
@@ -218,9 +216,8 @@ func Webhooks(ctx *context.Context) {
 		return
 	}
 
-	err = shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
@@ -246,9 +243,8 @@ func Labels(ctx *context.Context) {
 	ctx.Data["PageIsOrgSettingsLabels"] = true
 	ctx.Data["LabelTemplateFiles"] = repo_module.LabelTemplateFiles
 
-	err := shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
diff --git a/routers/web/org/setting_oauth2.go b/routers/web/org/setting_oauth2.go
index c93058477e..47f653bf88 100644
--- a/routers/web/org/setting_oauth2.go
+++ b/routers/web/org/setting_oauth2.go
@@ -45,9 +45,8 @@ func Applications(ctx *context.Context) {
 	}
 	ctx.Data["Applications"] = apps
 
-	err = shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
diff --git a/routers/web/org/setting_packages.go b/routers/web/org/setting_packages.go
index 0912a9e0fd..ec80e2867c 100644
--- a/routers/web/org/setting_packages.go
+++ b/routers/web/org/setting_packages.go
@@ -25,9 +25,8 @@ func Packages(ctx *context.Context) {
 	ctx.Data["PageIsOrgSettings"] = true
 	ctx.Data["PageIsSettingsPackages"] = true
 
-	err := shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
@@ -41,9 +40,8 @@ func PackagesRuleAdd(ctx *context.Context) {
 	ctx.Data["PageIsOrgSettings"] = true
 	ctx.Data["PageIsSettingsPackages"] = true
 
-	err := shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
@@ -57,9 +55,8 @@ func PackagesRuleEdit(ctx *context.Context) {
 	ctx.Data["PageIsOrgSettings"] = true
 	ctx.Data["PageIsSettingsPackages"] = true
 
-	err := shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
@@ -99,9 +96,8 @@ func PackagesRulePreview(ctx *context.Context) {
 	ctx.Data["PageIsOrgSettings"] = true
 	ctx.Data["PageIsSettingsPackages"] = true
 
-	err := shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go
index b1b0dd2c49..676c6d0c63 100644
--- a/routers/web/org/teams.go
+++ b/routers/web/org/teams.go
@@ -46,6 +46,10 @@ const (
 
 // Teams render teams list page
 func Teams(ctx *context.Context) {
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
 	org := ctx.Org.Organization
 	ctx.Data["Title"] = org.FullName
 	ctx.Data["PageIsOrgTeams"] = true
@@ -58,12 +62,6 @@ func Teams(ctx *context.Context) {
 	}
 	ctx.Data["Teams"] = ctx.Org.Teams
 
-	_, err := shared_user.PrepareOrgHeader(ctx)
-	if err != nil {
-		ctx.ServerError("PrepareOrgHeader", err)
-		return
-	}
-
 	ctx.HTML(http.StatusOK, tplTeams)
 }
 
@@ -272,15 +270,15 @@ func TeamsRepoAction(ctx *context.Context) {
 
 // NewTeam render create new team page
 func NewTeam(ctx *context.Context) {
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
 	ctx.Data["Title"] = ctx.Org.Organization.FullName
 	ctx.Data["PageIsOrgTeams"] = true
 	ctx.Data["PageIsOrgTeamsNew"] = true
 	ctx.Data["Team"] = &org_model.Team{}
 	ctx.Data["Units"] = unit_model.Units
-	if err := shared_user.LoadHeaderCount(ctx); err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
-		return
-	}
 	ctx.HTML(http.StatusOK, tplTeamNew)
 }
 
@@ -370,15 +368,15 @@ func NewTeamPost(ctx *context.Context) {
 
 // TeamMembers render team members page
 func TeamMembers(ctx *context.Context) {
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
+
 	ctx.Data["Title"] = ctx.Org.Team.Name
 	ctx.Data["PageIsOrgTeams"] = true
 	ctx.Data["PageIsOrgTeamMembers"] = true
 
-	if err := shared_user.LoadHeaderCount(ctx); err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
-		return
-	}
-
 	if err := ctx.Org.Team.LoadMembers(ctx); err != nil {
 		ctx.ServerError("GetMembers", err)
 		return
@@ -398,15 +396,15 @@ func TeamMembers(ctx *context.Context) {
 
 // TeamRepositories show the repositories of team
 func TeamRepositories(ctx *context.Context) {
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
+
 	ctx.Data["Title"] = ctx.Org.Team.Name
 	ctx.Data["PageIsOrgTeams"] = true
 	ctx.Data["PageIsOrgTeamRepos"] = true
 
-	if err := shared_user.LoadHeaderCount(ctx); err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
-		return
-	}
-
 	repos, err := repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{
 		TeamID: ctx.Org.Team.ID,
 	})
@@ -463,16 +461,16 @@ func SearchTeam(ctx *context.Context) {
 
 // EditTeam render team edit page
 func EditTeam(ctx *context.Context) {
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
 	ctx.Data["Title"] = ctx.Org.Organization.FullName
 	ctx.Data["PageIsOrgTeams"] = true
 	if err := ctx.Org.Team.LoadUnits(ctx); err != nil {
 		ctx.ServerError("LoadUnits", err)
 		return
 	}
-	if err := shared_user.LoadHeaderCount(ctx); err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
-		return
-	}
 	ctx.Data["Team"] = ctx.Org.Team
 	ctx.Data["Units"] = unit_model.Units
 	ctx.HTML(http.StatusOK, tplTeamNew)
diff --git a/routers/web/org/worktime.go b/routers/web/org/worktime.go
index a576dd9a11..c7b44baf7b 100644
--- a/routers/web/org/worktime.go
+++ b/routers/web/org/worktime.go
@@ -9,6 +9,7 @@ import (
 
 	"code.gitea.io/gitea/models/organization"
 	"code.gitea.io/gitea/modules/templates"
+	shared_user "code.gitea.io/gitea/routers/web/shared/user"
 	"code.gitea.io/gitea/services/context"
 )
 
@@ -70,6 +71,12 @@ func Worktime(ctx *context.Context) {
 		ctx.ServerError("GetWorktime", err)
 		return
 	}
+
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
+
 	ctx.Data["WorktimeSumResult"] = worktimeSumResult
 	ctx.HTML(http.StatusOK, tplByRepos)
 }
diff --git a/routers/web/repo/setting/secrets.go b/routers/web/repo/setting/secrets.go
index 46cb875f9b..c6e2d18249 100644
--- a/routers/web/repo/setting/secrets.go
+++ b/routers/web/repo/setting/secrets.go
@@ -44,9 +44,8 @@ func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) {
 	}
 
 	if ctx.Data["PageIsOrgSettings"] == true {
-		err := shared_user.LoadHeaderCount(ctx)
-		if err != nil {
-			ctx.ServerError("LoadHeaderCount", err)
+		if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+			ctx.ServerError("RenderUserOrgHeader", err)
 			return nil, nil
 		}
 		return &secretsCtx{
diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go
index 444bd960db..a87f6ce4dc 100644
--- a/routers/web/shared/actions/runners.go
+++ b/routers/web/shared/actions/runners.go
@@ -57,9 +57,8 @@ func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) {
 	}
 
 	if ctx.Data["PageIsOrgSettings"] == true {
-		err := shared_user.LoadHeaderCount(ctx)
-		if err != nil {
-			ctx.ServerError("LoadHeaderCount", err)
+		if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+			ctx.ServerError("RenderUserOrgHeader", err)
 			return nil, nil
 		}
 		return &runnersCtx{
diff --git a/routers/web/shared/actions/variables.go b/routers/web/shared/actions/variables.go
index 9cc1676d7b..a43c2c2690 100644
--- a/routers/web/shared/actions/variables.go
+++ b/routers/web/shared/actions/variables.go
@@ -49,9 +49,8 @@ func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) {
 	}
 
 	if ctx.Data["PageIsOrgSettings"] == true {
-		err := shared_user.LoadHeaderCount(ctx)
-		if err != nil {
-			ctx.ServerError("LoadHeaderCount", err)
+		if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+			ctx.ServerError("RenderUserOrgHeader", err)
 			return nil, nil
 		}
 		return &variablesCtx{
diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go
index 62b146c7f3..48a5d58ea4 100644
--- a/routers/web/shared/user/header.go
+++ b/routers/web/shared/user/header.go
@@ -24,19 +24,8 @@ import (
 	"code.gitea.io/gitea/services/context"
 )
 
-// prepareContextForCommonProfile store some common data into context data for user's profile related pages (including the nav menu)
-// It is designed to be fast and safe to be called multiple times in one request
-func prepareContextForCommonProfile(ctx *context.Context) {
-	ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
-	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
-	ctx.Data["EnableFeed"] = setting.Other.EnableFeed
-	ctx.Data["FeedURL"] = ctx.ContextUser.HomeLink()
-}
-
-// PrepareContextForProfileBigAvatar set the context for big avatar view on the profile page
-func PrepareContextForProfileBigAvatar(ctx *context.Context) {
-	prepareContextForCommonProfile(ctx)
-
+// prepareContextForProfileBigAvatar set the context for big avatar view on the profile page
+func prepareContextForProfileBigAvatar(ctx *context.Context) {
 	ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
 	ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate
 	if setting.Service.UserLocationMapURL != "" {
@@ -138,16 +127,44 @@ func FindOwnerProfileReadme(ctx *context.Context, doer *user_model.User, optProf
 	return profileDbRepo, profileReadmeBlob
 }
 
-func RenderUserHeader(ctx *context.Context) {
-	prepareContextForCommonProfile(ctx)
-
-	_, profileReadmeBlob := FindOwnerProfileReadme(ctx, ctx.Doer)
-	ctx.Data["HasUserProfileReadme"] = profileReadmeBlob != nil
+type PrepareOwnerHeaderResult struct {
+	ProfilePublicRepo        *repo_model.Repository
+	ProfilePublicReadmeBlob  *git.Blob
+	ProfilePrivateRepo       *repo_model.Repository
+	ProfilePrivateReadmeBlob *git.Blob
+	HasOrgProfileReadme      bool
 }
 
-func LoadHeaderCount(ctx *context.Context) error {
-	prepareContextForCommonProfile(ctx)
+const (
+	RepoNameProfilePrivate = ".profile-private"
+	RepoNameProfile        = ".profile"
+)
 
+func RenderUserOrgHeader(ctx *context.Context) (result *PrepareOwnerHeaderResult, err error) {
+	ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
+	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
+	ctx.Data["EnableFeed"] = setting.Other.EnableFeed
+	ctx.Data["FeedURL"] = ctx.ContextUser.HomeLink()
+
+	if err := loadHeaderCount(ctx); err != nil {
+		return nil, err
+	}
+
+	result = &PrepareOwnerHeaderResult{}
+	if ctx.ContextUser.IsOrganization() {
+		result.ProfilePublicRepo, result.ProfilePublicReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer)
+		result.ProfilePrivateRepo, result.ProfilePrivateReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer, RepoNameProfilePrivate)
+		result.HasOrgProfileReadme = result.ProfilePublicReadmeBlob != nil || result.ProfilePrivateReadmeBlob != nil
+		ctx.Data["HasOrgProfileReadme"] = result.HasOrgProfileReadme // many pages need it to show the "overview" tab
+	} else {
+		_, profileReadmeBlob := FindOwnerProfileReadme(ctx, ctx.Doer)
+		ctx.Data["HasUserProfileReadme"] = profileReadmeBlob != nil
+		prepareContextForProfileBigAvatar(ctx)
+	}
+	return result, nil
+}
+
+func loadHeaderCount(ctx *context.Context) error {
 	repoCount, err := repo_model.CountRepository(ctx, &repo_model.SearchRepoOptions{
 		Actor:              ctx.Doer,
 		OwnerID:            ctx.ContextUser.ID,
@@ -178,29 +195,3 @@ func LoadHeaderCount(ctx *context.Context) error {
 
 	return nil
 }
-
-const (
-	RepoNameProfilePrivate = ".profile-private"
-	RepoNameProfile        = ".profile"
-)
-
-type PrepareOrgHeaderResult struct {
-	ProfilePublicRepo        *repo_model.Repository
-	ProfilePublicReadmeBlob  *git.Blob
-	ProfilePrivateRepo       *repo_model.Repository
-	ProfilePrivateReadmeBlob *git.Blob
-	HasOrgProfileReadme      bool
-}
-
-func PrepareOrgHeader(ctx *context.Context) (result *PrepareOrgHeaderResult, err error) {
-	if err = LoadHeaderCount(ctx); err != nil {
-		return nil, err
-	}
-
-	result = &PrepareOrgHeaderResult{}
-	result.ProfilePublicRepo, result.ProfilePublicReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer)
-	result.ProfilePrivateRepo, result.ProfilePrivateReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer, RepoNameProfilePrivate)
-	result.HasOrgProfileReadme = result.ProfilePublicReadmeBlob != nil || result.ProfilePrivateReadmeBlob != nil
-	ctx.Data["HasOrgProfileReadme"] = result.HasOrgProfileReadme // many pages need it to show the "overview" tab
-	return result, nil
-}
diff --git a/routers/web/user/code.go b/routers/web/user/code.go
index f9aa58b877..f2153c6d54 100644
--- a/routers/web/user/code.go
+++ b/routers/web/user/code.go
@@ -26,11 +26,8 @@ func CodeSearch(ctx *context.Context) {
 		ctx.Redirect(ctx.ContextUser.HomeLink())
 		return
 	}
-	shared_user.PrepareContextForProfileBigAvatar(ctx)
-	shared_user.RenderUserHeader(ctx)
-
-	if err := shared_user.LoadHeaderCount(ctx); err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
diff --git a/routers/web/user/package.go b/routers/web/user/package.go
index c01bc96e2b..e96f5a04ea 100644
--- a/routers/web/user/package.go
+++ b/routers/web/user/package.go
@@ -42,7 +42,10 @@ const (
 
 // ListPackages displays a list of all packages of the context user
 func ListPackages(ctx *context.Context) {
-	shared_user.PrepareContextForProfileBigAvatar(ctx)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
 	page := ctx.FormInt("page")
 	if page <= 1 {
 		page = 1
@@ -94,8 +97,6 @@ func ListPackages(ctx *context.Context) {
 		return
 	}
 
-	shared_user.RenderUserHeader(ctx)
-
 	ctx.Data["Title"] = ctx.Tr("packages.title")
 	ctx.Data["IsPackagesPage"] = true
 	ctx.Data["Query"] = query
@@ -106,9 +107,8 @@ func ListPackages(ctx *context.Context) {
 	ctx.Data["Total"] = total
 	ctx.Data["RepositoryAccessMap"] = repositoryAccessMap
 
-	err = shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
@@ -126,11 +126,9 @@ func ListPackages(ctx *context.Context) {
 			ctx.Data["IsOrganizationOwner"] = false
 		}
 	}
-
 	pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5)
 	pager.AddParamFromRequest(ctx.Req)
 	ctx.Data["Page"] = pager
-
 	ctx.HTML(http.StatusOK, tplPackagesList)
 }
 
@@ -164,16 +162,17 @@ func RedirectToLastVersion(ctx *context.Context) {
 		ctx.ServerError("GetPackageDescriptor", err)
 		return
 	}
-
 	ctx.Redirect(pd.VersionWebLink())
 }
 
 // ViewPackageVersion displays a single package version
 func ViewPackageVersion(ctx *context.Context) {
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
+
 	pd := ctx.Package.Descriptor
-
-	shared_user.RenderUserHeader(ctx)
-
 	ctx.Data["Title"] = pd.Package.Name
 	ctx.Data["IsPackagesPage"] = true
 	ctx.Data["PackageDescriptor"] = pd
@@ -301,19 +300,16 @@ func ViewPackageVersion(ctx *context.Context) {
 		hasRepositoryAccess = permission.HasAnyUnitAccess()
 	}
 	ctx.Data["HasRepositoryAccess"] = hasRepositoryAccess
-
-	err = shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
-		return
-	}
-
 	ctx.HTML(http.StatusOK, tplPackagesView)
 }
 
 // ListPackageVersions lists all versions of a package
 func ListPackageVersions(ctx *context.Context) {
-	shared_user.PrepareContextForProfileBigAvatar(ctx)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
+
 	p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.PathParam("type")), ctx.PathParam("name"))
 	if err != nil {
 		if err == packages_model.ErrPackageNotExist {
@@ -336,8 +332,6 @@ func ListPackageVersions(ctx *context.Context) {
 	query := ctx.FormTrim("q")
 	sort := ctx.FormTrim("sort")
 
-	shared_user.RenderUserHeader(ctx)
-
 	ctx.Data["Title"] = ctx.Tr("packages.title")
 	ctx.Data["IsPackagesPage"] = true
 	ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{
@@ -393,12 +387,6 @@ func ListPackageVersions(ctx *context.Context) {
 
 	ctx.Data["Total"] = total
 
-	err = shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
-		return
-	}
-
 	pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5)
 	pager.AddParamFromRequest(ctx.Req)
 	ctx.Data["Page"] = pager
@@ -410,7 +398,10 @@ func ListPackageVersions(ctx *context.Context) {
 func PackageSettings(ctx *context.Context) {
 	pd := ctx.Package.Descriptor
 
-	shared_user.RenderUserHeader(ctx)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
 
 	ctx.Data["Title"] = pd.Package.Name
 	ctx.Data["IsPackagesPage"] = true
@@ -423,12 +414,6 @@ func PackageSettings(ctx *context.Context) {
 	ctx.Data["Repos"] = repos
 	ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
 
-	err := shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
-		return
-	}
-
 	ctx.HTML(http.StatusOK, tplPackagesSettings)
 }
 
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
index 39f066a53c..ee19665109 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -78,8 +78,15 @@ func userProfile(ctx *context.Context) {
 
 	showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
 	prepareUserProfileTabData(ctx, showPrivate, profileDbRepo, profileReadmeBlob)
-	// call PrepareContextForProfileBigAvatar later to avoid re-querying the NumFollowers & NumFollowing
-	shared_user.PrepareContextForProfileBigAvatar(ctx)
+
+	// prepare the user nav header data after "prepareUserProfileTabData" to avoid re-querying the NumFollowers & NumFollowing
+	// because ctx.Data["NumFollowers"] and "NumFollowing" logic duplicates in both of them
+	// and the "profile readme" related logic also duplicates in both of FindOwnerProfileReadme and RenderUserOrgHeader
+	// TODO: it is a bad design and should be refactored later,
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
 	ctx.HTML(http.StatusOK, tplProfile)
 }
 
@@ -302,9 +309,8 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
 	ctx.Data["Repos"] = repos
 	ctx.Data["Total"] = total
 
-	err = shared_user.LoadHeaderCount(ctx)
-	if err != nil {
-		ctx.ServerError("LoadHeaderCount", err)
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
 		return
 	}
 
@@ -328,9 +334,11 @@ func ActionUserFollow(ctx *context.Context) {
 		ctx.HTTPError(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action")))
 		return
 	}
-
+	if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+		ctx.ServerError("RenderUserOrgHeader", err)
+		return
+	}
 	if ctx.ContextUser.IsIndividual() {
-		shared_user.PrepareContextForProfileBigAvatar(ctx)
 		ctx.HTML(http.StatusOK, tplProfileBigAvatar)
 		return
 	} else if ctx.ContextUser.IsOrganization() {
diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go
index d4da468a85..f460acce10 100644
--- a/routers/web/user/setting/oauth2_common.go
+++ b/routers/web/user/setting/oauth2_common.go
@@ -28,8 +28,8 @@ func (oa *OAuth2CommonHandlers) renderEditPage(ctx *context.Context) {
 	ctx.Data["FormActionPath"] = fmt.Sprintf("%s/%d", oa.BasePathEditPrefix, app.ID)
 
 	if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() {
-		if err := shared_user.LoadHeaderCount(ctx); err != nil {
-			ctx.ServerError("LoadHeaderCount", err)
+		if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
+			ctx.ServerError("RenderUserOrgHeader", err)
 			return
 		}
 	}
diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl
index 2d3af2d559..d876dabb44 100644
--- a/templates/org/menu.tmpl
+++ b/templates/org/menu.tmpl
@@ -44,7 +44,7 @@
 				{{end}}
 			</a>
 			{{end}}
-			{{if .IsOrganizationOwner}}
+			{{if and EnableTimetracking .IsOrganizationOwner}}
 			<a class="{{if $.PageIsOrgTimes}}active{{end}} item" href="{{$.OrgLink}}/worktime">
 				{{svg "octicon-clock"}} {{ctx.Locale.Tr "org.worktime"}}
 			</a>
diff --git a/templates/org/projects/new.tmpl b/templates/org/projects/new.tmpl
index fc52130f68..c021c5a0fe 100644
--- a/templates/org/projects/new.tmpl
+++ b/templates/org/projects/new.tmpl
@@ -1,9 +1,24 @@
 {{template "base/head" .}}
-<div role="main" aria-label="{{.Title}}" class="page-content organization projects edit-project new">
-	{{template "shared/user/org_profile_avatar" .}}
+{{if .ContextUser.IsOrganization}}
+<div role="main" aria-label="{{.Title}}" class="page-content organization projects">
+	{{template "org/header" .}}
 	<div class="ui container">
-	{{template "user/overview/header" .}}
-	{{template "projects/new" .}}
+		{{template "projects/new" .}}
 	</div>
 </div>
+{{else}}
+<div role="main" aria-label="{{.Title}}" class="page-content user profile">
+	<div class="ui container">
+		<div class="ui stackable grid">
+			<div class="ui four wide column">
+				{{template "shared/user/profile_big_avatar" .}}
+			</div>
+			<div class="ui twelve wide column tw-mb-4">
+				{{template "user/overview/header" .}}
+				{{template "projects/new" .}}
+			</div>
+		</div>
+	</div>
+</div>
+{{end}}
 {{template "base/footer" .}}
diff --git a/templates/package/shared/view.tmpl b/templates/package/shared/view.tmpl
new file mode 100644
index 0000000000..713e1bbfc5
--- /dev/null
+++ b/templates/package/shared/view.tmpl
@@ -0,0 +1,106 @@
+<div class="issue-title-header">
+	<h1>{{.PackageDescriptor.Package.Name}} ({{.PackageDescriptor.Version.Version}})</h1>
+	<div>
+		{{$timeStr := DateUtils.TimeSince .PackageDescriptor.Version.CreatedUnix}}
+		{{if .HasRepositoryAccess}}
+		{{ctx.Locale.Tr "packages.published_by_in" $timeStr .PackageDescriptor.Creator.HomeLink .PackageDescriptor.Creator.GetDisplayName .PackageDescriptor.Repository.Link .PackageDescriptor.Repository.FullName}}
+		{{else}}
+		{{ctx.Locale.Tr "packages.published_by" $timeStr .PackageDescriptor.Creator.HomeLink .PackageDescriptor.Creator.GetDisplayName}}
+		{{end}}
+	</div>
+</div>
+<div class="issue-content">
+	<div class="issue-content-left">
+		{{template "package/content/alpine" .}}
+		{{template "package/content/arch" .}}
+		{{template "package/content/cargo" .}}
+		{{template "package/content/chef" .}}
+		{{template "package/content/composer" .}}
+		{{template "package/content/conan" .}}
+		{{template "package/content/conda" .}}
+		{{template "package/content/container" .}}
+		{{template "package/content/cran" .}}
+		{{template "package/content/debian" .}}
+		{{template "package/content/generic" .}}
+		{{template "package/content/go" .}}
+		{{template "package/content/helm" .}}
+		{{template "package/content/maven" .}}
+		{{template "package/content/npm" .}}
+		{{template "package/content/nuget" .}}
+		{{template "package/content/pub" .}}
+		{{template "package/content/pypi" .}}
+		{{template "package/content/rpm" .}}
+		{{template "package/content/rubygems" .}}
+		{{template "package/content/swift" .}}
+		{{template "package/content/vagrant" .}}
+	</div>
+	<div class="issue-content-right ui segment">
+		<strong>{{ctx.Locale.Tr "packages.details"}}</strong>
+		<div class="ui relaxed list flex-items-block">
+			<div class="item">{{svg .PackageDescriptor.Package.Type.SVGName}} {{.PackageDescriptor.Package.Type.Name}}</div>
+			{{if .HasRepositoryAccess}}
+			<div class="item">{{svg "octicon-repo"}} <a href="{{.PackageDescriptor.Repository.Link}}">{{.PackageDescriptor.Repository.FullName}}</a></div>
+			{{end}}
+			<div class="item">{{svg "octicon-calendar"}} {{DateUtils.TimeSince .PackageDescriptor.Version.CreatedUnix}}</div>
+			<div class="item">{{svg "octicon-download"}} {{.PackageDescriptor.Version.DownloadCount}}</div>
+			{{template "package/metadata/alpine" .}}
+			{{template "package/metadata/arch" .}}
+			{{template "package/metadata/cargo" .}}
+			{{template "package/metadata/chef" .}}
+			{{template "package/metadata/composer" .}}
+			{{template "package/metadata/conan" .}}
+			{{template "package/metadata/conda" .}}
+			{{template "package/metadata/container" .}}
+			{{template "package/metadata/cran" .}}
+			{{template "package/metadata/debian" .}}
+			{{template "package/metadata/generic" .}}
+			{{template "package/metadata/helm" .}}
+			{{template "package/metadata/maven" .}}
+			{{template "package/metadata/npm" .}}
+			{{template "package/metadata/nuget" .}}
+			{{template "package/metadata/pub" .}}
+			{{template "package/metadata/pypi" .}}
+			{{template "package/metadata/rpm" .}}
+			{{template "package/metadata/rubygems" .}}
+			{{template "package/metadata/swift" .}}
+			{{template "package/metadata/vagrant" .}}
+			{{if not (and (eq .PackageDescriptor.Package.Type "container") .PackageDescriptor.Metadata.Manifests)}}
+			<div class="item">{{svg "octicon-database"}} {{FileSize .PackageDescriptor.CalculateBlobSize}}</div>
+			{{end}}
+		</div>
+		{{if not (eq .PackageDescriptor.Package.Type "container")}}
+		<div class="divider"></div>
+		<strong>{{ctx.Locale.Tr "packages.assets"}} ({{len .PackageDescriptor.Files}})</strong>
+		<div class="ui relaxed list">
+			{{range .PackageDescriptor.Files}}
+			<div class="item">
+				<a href="{{$.Link}}/files/{{.File.ID}}">{{.File.Name}}</a>
+				<span class="text small file-size">{{FileSize .Blob.Size}}</span>
+			</div>
+			{{end}}
+		</div>
+		{{end}}
+		<div class="divider"></div>
+		<strong>{{ctx.Locale.Tr "packages.versions"}} ({{.TotalVersionCount}})</strong>
+		<a class="tw-float-right" href="{{$.PackageDescriptor.PackageWebLink}}/versions">{{ctx.Locale.Tr "packages.versions.view_all"}}</a>
+		<div class="ui relaxed list">
+			{{range .LatestVersions}}
+			<div class="item tw-flex">
+				<a class="tw-flex-1 gt-ellipsis" title="{{.Version}}" href="{{$.PackageDescriptor.PackageWebLink}}/{{PathEscape .LowerVersion}}">{{.Version}}</a>
+				<span class="text small">{{DateUtils.AbsoluteShort .CreatedUnix}}</span>
+			</div>
+			{{end}}
+		</div>
+		{{if or .CanWritePackages .HasRepositoryAccess}}
+		<div class="divider"></div>
+		<div class="ui relaxed list flex-items-block">
+			{{if .HasRepositoryAccess}}
+			<div class="item">{{svg "octicon-issue-opened"}} <a href="{{.PackageDescriptor.Repository.Link}}/issues">{{ctx.Locale.Tr "repo.issues"}}</a></div>
+			{{end}}
+			{{if .CanWritePackages}}
+			<div class="item">{{svg "octicon-tools"}} <a href="{{.Link}}/settings">{{ctx.Locale.Tr "repo.settings"}}</a></div>
+			{{end}}
+		</div>
+		{{end}}
+	</div>
+</div>
diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl
index 9e92207466..9067f44296 100644
--- a/templates/package/view.tmpl
+++ b/templates/package/view.tmpl
@@ -1,114 +1,24 @@
 {{template "base/head" .}}
-<div role="main" aria-label="{{.Title}}" class="page-content repository packages">
-	{{template "shared/user/org_profile_avatar" .}}
+{{if .ContextUser.IsOrganization}}
+<div role="main" aria-label="{{.Title}}" class="page-content organization packages">
+	{{template "org/header" .}}
 	<div class="ui container">
-		{{template "user/overview/header" .}}
-		<div class="issue-title-header">
-			<h1>{{.PackageDescriptor.Package.Name}} ({{.PackageDescriptor.Version.Version}})</h1>
-			<div>
-				{{$timeStr := DateUtils.TimeSince .PackageDescriptor.Version.CreatedUnix}}
-				{{if .HasRepositoryAccess}}
-					{{ctx.Locale.Tr "packages.published_by_in" $timeStr .PackageDescriptor.Creator.HomeLink .PackageDescriptor.Creator.GetDisplayName .PackageDescriptor.Repository.Link .PackageDescriptor.Repository.FullName}}
-				{{else}}
-					{{ctx.Locale.Tr "packages.published_by" $timeStr .PackageDescriptor.Creator.HomeLink .PackageDescriptor.Creator.GetDisplayName}}
-				{{end}}
+		{{template "package/shared/view" .}}
+	</div>
+</div>
+{{else}}
+<div role="main" aria-label="{{.Title}}" class="page-content user profile packages">
+	<div class="ui container">
+		<div class="ui stackable grid">
+			<div class="ui four wide column">
+				{{template "shared/user/profile_big_avatar" .}}
 			</div>
-		</div>
-		<div class="issue-content">
-			<div class="issue-content-left">
-				{{template "package/content/alpine" .}}
-				{{template "package/content/arch" .}}
-				{{template "package/content/cargo" .}}
-				{{template "package/content/chef" .}}
-				{{template "package/content/composer" .}}
-				{{template "package/content/conan" .}}
-				{{template "package/content/conda" .}}
-				{{template "package/content/container" .}}
-				{{template "package/content/cran" .}}
-				{{template "package/content/debian" .}}
-				{{template "package/content/generic" .}}
-				{{template "package/content/go" .}}
-				{{template "package/content/helm" .}}
-				{{template "package/content/maven" .}}
-				{{template "package/content/npm" .}}
-				{{template "package/content/nuget" .}}
-				{{template "package/content/pub" .}}
-				{{template "package/content/pypi" .}}
-				{{template "package/content/rpm" .}}
-				{{template "package/content/rubygems" .}}
-				{{template "package/content/swift" .}}
-				{{template "package/content/vagrant" .}}
-			</div>
-			<div class="issue-content-right ui segment">
-				<strong>{{ctx.Locale.Tr "packages.details"}}</strong>
-				<div class="ui relaxed list flex-items-block">
-					<div class="item">{{svg .PackageDescriptor.Package.Type.SVGName}} {{.PackageDescriptor.Package.Type.Name}}</div>
-					{{if .HasRepositoryAccess}}
-					<div class="item">{{svg "octicon-repo"}} <a href="{{.PackageDescriptor.Repository.Link}}">{{.PackageDescriptor.Repository.FullName}}</a></div>
-					{{end}}
-					<div class="item">{{svg "octicon-calendar"}} {{DateUtils.TimeSince .PackageDescriptor.Version.CreatedUnix}}</div>
-					<div class="item">{{svg "octicon-download"}} {{.PackageDescriptor.Version.DownloadCount}}</div>
-					{{template "package/metadata/alpine" .}}
-					{{template "package/metadata/arch" .}}
-					{{template "package/metadata/cargo" .}}
-					{{template "package/metadata/chef" .}}
-					{{template "package/metadata/composer" .}}
-					{{template "package/metadata/conan" .}}
-					{{template "package/metadata/conda" .}}
-					{{template "package/metadata/container" .}}
-					{{template "package/metadata/cran" .}}
-					{{template "package/metadata/debian" .}}
-					{{template "package/metadata/generic" .}}
-					{{template "package/metadata/helm" .}}
-					{{template "package/metadata/maven" .}}
-					{{template "package/metadata/npm" .}}
-					{{template "package/metadata/nuget" .}}
-					{{template "package/metadata/pub" .}}
-					{{template "package/metadata/pypi" .}}
-					{{template "package/metadata/rpm" .}}
-					{{template "package/metadata/rubygems" .}}
-					{{template "package/metadata/swift" .}}
-					{{template "package/metadata/vagrant" .}}
-					{{if not (and (eq .PackageDescriptor.Package.Type "container") .PackageDescriptor.Metadata.Manifests)}}
-					<div class="item">{{svg "octicon-database"}} {{FileSize .PackageDescriptor.CalculateBlobSize}}</div>
-					{{end}}
-				</div>
-				{{if not (eq .PackageDescriptor.Package.Type "container")}}
-					<div class="divider"></div>
-					<strong>{{ctx.Locale.Tr "packages.assets"}} ({{len .PackageDescriptor.Files}})</strong>
-					<div class="ui relaxed list">
-					{{range .PackageDescriptor.Files}}
-						<div class="item">
-							<a href="{{$.Link}}/files/{{.File.ID}}">{{.File.Name}}</a>
-							<span class="text small file-size">{{FileSize .Blob.Size}}</span>
-						</div>
-					{{end}}
-					</div>
-				{{end}}
-				<div class="divider"></div>
-				<strong>{{ctx.Locale.Tr "packages.versions"}} ({{.TotalVersionCount}})</strong>
-				<a class="tw-float-right" href="{{$.PackageDescriptor.PackageWebLink}}/versions">{{ctx.Locale.Tr "packages.versions.view_all"}}</a>
-				<div class="ui relaxed list">
-				{{range .LatestVersions}}
-					<div class="item tw-flex">
-						<a class="tw-flex-1 gt-ellipsis" title="{{.Version}}" href="{{$.PackageDescriptor.PackageWebLink}}/{{PathEscape .LowerVersion}}">{{.Version}}</a>
-						<span class="text small">{{DateUtils.AbsoluteShort .CreatedUnix}}</span>
-					</div>
-				{{end}}
-				</div>
-				{{if or .CanWritePackages .HasRepositoryAccess}}
-					<div class="divider"></div>
-					<div class="ui relaxed list flex-items-block">
-						{{if .HasRepositoryAccess}}
-						<div class="item">{{svg "octicon-issue-opened"}} <a href="{{.PackageDescriptor.Repository.Link}}/issues">{{ctx.Locale.Tr "repo.issues"}}</a></div>
-						{{end}}
-						{{if .CanWritePackages}}
-						<div class="item">{{svg "octicon-tools"}} <a href="{{.Link}}/settings">{{ctx.Locale.Tr "repo.settings"}}</a></div>
-						{{end}}
-					</div>
-				{{end}}
+			<div class="ui twelve wide column tw-mb-4">
+				{{template "user/overview/header" .}}
+				{{template "package/shared/view" .}}
 			</div>
 		</div>
 	</div>
 </div>
+{{end}}
 {{template "base/footer" .}}

From 4a3ab5a2cdb15a80e1b17da8541903a62f0bf407 Mon Sep 17 00:00:00 2001
From: Allen Conlon <software@conlon.dev>
Date: Thu, 10 Apr 2025 15:39:37 -0400
Subject: [PATCH 10/14] fix(#33711): cross-publish docker images to ghcr.io
 (#34148)

This PR will cross-publish the release, rc, and nightly images from
`docker.io` to `ghcr.io` as docker hub has imposed rate-limiting

Signed-off-by: Allen Conlon <software@conlon.dev>
---
 .github/workflows/release-nightly.yml     | 24 +++++++++++++++++++++--
 .github/workflows/release-tag-rc.yml      | 24 +++++++++++++++++++++--
 .github/workflows/release-tag-version.yml | 24 +++++++++++++++++++++--
 3 files changed, 66 insertions(+), 6 deletions(-)

diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml
index 2264c9e822..f459e3910d 100644
--- a/.github/workflows/release-nightly.yml
+++ b/.github/workflows/release-nightly.yml
@@ -59,6 +59,8 @@ jobs:
           aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress
   nightly-docker-rootful:
     runs-on: namespace-profile-gitea-release-docker
+    permissions:
+      packages: write # to publish to ghcr.io
     steps:
       - uses: actions/checkout@v4
       # fetch all commits instead of only the last as some branches are long lived and could have many between versions
@@ -85,6 +87,12 @@ jobs:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
+      - name: Login to GHCR using PAT
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
       - name: fetch go modules
         run: make vendor
       - name: build rootful docker image
@@ -93,9 +101,13 @@ jobs:
           context: .
           platforms: linux/amd64,linux/arm64
           push: true
-          tags: gitea/gitea:${{ steps.clean_name.outputs.branch }}
+          tags: |-
+            gitea/gitea:${{ steps.clean_name.outputs.branch }}
+            ghcr.io/go-gitea/gitea:${{ steps.clean_name.outputs.branch }}
   nightly-docker-rootless:
     runs-on: namespace-profile-gitea-release-docker
+    permissions:
+      packages: write # to publish to ghcr.io
     steps:
       - uses: actions/checkout@v4
       # fetch all commits instead of only the last as some branches are long lived and could have many between versions
@@ -122,6 +134,12 @@ jobs:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
+      - name: Login to GHCR using PAT
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
       - name: fetch go modules
         run: make vendor
       - name: build rootless docker image
@@ -131,4 +149,6 @@ jobs:
           platforms: linux/amd64,linux/arm64
           push: true
           file: Dockerfile.rootless
-          tags: gitea/gitea:${{ steps.clean_name.outputs.branch }}-rootless
+          tags: |-
+            gitea/gitea:${{ steps.clean_name.outputs.branch }}-rootless
+            ghcr.io/go-gitea/gitea:${{ steps.clean_name.outputs.branch }}-rootless
diff --git a/.github/workflows/release-tag-rc.yml b/.github/workflows/release-tag-rc.yml
index a406602dc0..02da6d1eab 100644
--- a/.github/workflows/release-tag-rc.yml
+++ b/.github/workflows/release-tag-rc.yml
@@ -69,6 +69,8 @@ jobs:
           GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
   docker-rootful:
     runs-on: namespace-profile-gitea-release-docker
+    permissions:
+      packages: write # to publish to ghcr.io
     steps:
       - uses: actions/checkout@v4
       # fetch all commits instead of only the last as some branches are long lived and could have many between versions
@@ -79,7 +81,9 @@ jobs:
       - uses: docker/metadata-action@v5
         id: meta
         with:
-          images: gitea/gitea
+          images: |-
+            gitea/gitea
+            ghcr.io/go-gitea/gitea
           flavor: |
             latest=false
           # 1.2.3-rc0
@@ -90,6 +94,12 @@ jobs:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
+      - name: Login to GHCR using PAT
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
       - name: build rootful docker image
         uses: docker/build-push-action@v5
         with:
@@ -100,6 +110,8 @@ jobs:
           labels: ${{ steps.meta.outputs.labels }}
   docker-rootless:
     runs-on: namespace-profile-gitea-release-docker
+    permissions:
+      packages: write # to publish to ghcr.io
     steps:
       - uses: actions/checkout@v4
       # fetch all commits instead of only the last as some branches are long lived and could have many between versions
@@ -110,7 +122,9 @@ jobs:
       - uses: docker/metadata-action@v5
         id: meta
         with:
-          images: gitea/gitea
+          images: |-
+            gitea/gitea
+            ghcr.io/go-gitea/gitea
           # each tag below will have the suffix of -rootless
           flavor: |
             latest=false
@@ -123,6 +137,12 @@ jobs:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
+      - name: Login to GHCR using PAT
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
       - name: build rootless docker image
         uses: docker/build-push-action@v5
         with:
diff --git a/.github/workflows/release-tag-version.yml b/.github/workflows/release-tag-version.yml
index 08bb9baecf..158945b615 100644
--- a/.github/workflows/release-tag-version.yml
+++ b/.github/workflows/release-tag-version.yml
@@ -14,6 +14,8 @@ concurrency:
 jobs:
   binary:
     runs-on: namespace-profile-gitea-release-binary
+    permissions:
+      packages: write # to publish to ghcr.io
     steps:
       - uses: actions/checkout@v4
       # fetch all commits instead of only the last as some branches are long lived and could have many between versions
@@ -71,6 +73,8 @@ jobs:
           GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
   docker-rootful:
     runs-on: namespace-profile-gitea-release-docker
+    permissions:
+      packages: write # to publish to ghcr.io
     steps:
       - uses: actions/checkout@v4
       # fetch all commits instead of only the last as some branches are long lived and could have many between versions
@@ -81,7 +85,9 @@ jobs:
       - uses: docker/metadata-action@v5
         id: meta
         with:
-          images: gitea/gitea
+          images: |-
+            gitea/gitea
+            ghcr.io/go-gitea/gitea
           # this will generate tags in the following format:
           # latest
           # 1
@@ -96,6 +102,12 @@ jobs:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
+      - name: Login to GHCR using PAT
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
       - name: build rootful docker image
         uses: docker/build-push-action@v5
         with:
@@ -116,7 +128,9 @@ jobs:
       - uses: docker/metadata-action@v5
         id: meta
         with:
-          images: gitea/gitea
+          images: |-
+            gitea/gitea
+            ghcr.io/go-gitea/gitea
           # each tag below will have the suffix of -rootless
           flavor: |
             suffix=-rootless,onlatest=true
@@ -134,6 +148,12 @@ jobs:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
+      - name: Login to GHCR using PAT
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
       - name: build rootless docker image
         uses: docker/build-push-action@v5
         with:

From d725b78824a6e83bc5f6db3c83f742810241d1ee Mon Sep 17 00:00:00 2001
From: GiteaBot <teabot@gitea.io>
Date: Fri, 11 Apr 2025 00:34:55 +0000
Subject: [PATCH 11/14] [skip ci] Updated translations via Crowdin

---
 options/locale/locale_zh-CN.ini | 82 ++++++++++++++++++++++++++++-----
 1 file changed, 70 insertions(+), 12 deletions(-)

diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini
index 85f5b08a42..1540a97f4c 100644
--- a/options/locale/locale_zh-CN.ini
+++ b/options/locale/locale_zh-CN.ini
@@ -113,6 +113,7 @@ copy_type_unsupported=无法复制此类型的文件内容
 write=撰写
 preview=预览
 loading=正在加载...
+files=文件
 
 error=错误
 error404=您正尝试访问的页面 <strong>不存在</strong> 或 <strong>您尚未被授权</strong> 查看该页面。
@@ -169,6 +170,10 @@ search=搜索...
 type_tooltip=搜索类型
 fuzzy=模糊
 fuzzy_tooltip=包含近似匹配搜索词的结果
+words=词
+words_tooltip=仅包含匹配搜索词的结果
+regexp=正则表达式
+regexp_tooltip=仅包含匹配正则表达式搜索词的结果
 exact=精确
 exact_tooltip=仅包含精确匹配搜索词的结果
 repo_kind=搜索仓库...
@@ -385,6 +390,12 @@ show_only_public=只显示公开的
 
 issues.in_your_repos=在您的仓库中
 
+guide_title=无活动
+guide_desc=您目前没有关注任何仓库或用户,所以没有要显示的内容。 您可以从下面的链接中探索感兴趣的仓库或用户。
+explore_repos=探索仓库
+explore_users=探索用户
+empty_org=目前还没有组织。
+empty_repo=目前还没有仓库。
 
 [explore]
 repos=仓库
@@ -446,6 +457,7 @@ oauth_signup_submit=完成账号
 oauth_signin_tab=绑定到现有帐号
 oauth_signin_title=登录以授权绑定帐户
 oauth_signin_submit=绑定账号
+oauth.signin.error.general=处理授权请求时出错:%s。如果此错误仍然存在,请与站点管理员联系。
 oauth.signin.error.access_denied=授权请求被拒绝。
 oauth.signin.error.temporarily_unavailable=授权失败,因为认证服务器暂时不可用。请稍后再试。
 oauth_callback_unable_auto_reg=自动注册已启用,但OAuth2 提供商 %[1]s 返回缺失的字段:%[2]s,无法自动创建帐户,请创建或链接到一个帐户,或联系站点管理员。
@@ -718,6 +730,8 @@ public_profile=公开信息
 biography_placeholder=告诉我们一点您自己! (您可以使用Markdown)
 location_placeholder=与他人分享你的大概位置
 profile_desc=控制您的个人资料对其他用户的显示方式。您的主要电子邮件地址将用于通知、密码恢复和基于网页界面的 Git 操作
+password_username_disabled=您不被允许更改你的用户名。更多详情请联系您的系统管理员。
+password_full_name_disabled=您不被允许更改你的全名。请联系您的站点管理员了解更多详情。
 full_name=自定义名称
 website=个人网站
 location=所在地区
@@ -912,6 +926,7 @@ permission_not_set=未设置
 permission_no_access=无访问权限
 permission_read=可读
 permission_write=读写
+permission_anonymous_read=匿名读
 access_token_desc=所选令牌权限仅限于对应的 <a %s>API</a> 路由的授权。阅读 <a %s>文档</a> 以获取更多信息。
 at_least_one_permission=你需要选择至少一个权限才能创建令牌
 permissions_list=权限:
@@ -1014,6 +1029,9 @@ new_repo_helper=代码仓库包含了所有的项目文件,包括版本历史
 owner=拥有者
 owner_helper=由于最大仓库数量限制,一些组织可能不会显示在下拉列表中。
 repo_name=仓库名称
+repo_name_profile_public_hint=.profile 是一个特殊的存储库,您可以使用它将 README.md 添加到您的公共组织资料中,任何人都可以看到。请确保它是公开的,并使用个人资料目录中的 README 对其进行初始化以开始使用。
+repo_name_profile_private_hint=.profile-private 是一个特殊的存储库,您可以使用它向您的组织成员个人资料添加 README.md,仅对组织成员可见。请确保它是私有的,并使用个人资料目录中的 README 对其进行初始化以开始使用。
+repo_name_helper=理想的仓库名称应由简短、有意义和独特的关键词组成。".profile" 和 ".profile-private" 可用于为用户/组织添加 README.md。
 repo_size=仓库大小
 template=模板
 template_select=选择模板
@@ -1110,6 +1128,7 @@ blame.ignore_revs=忽略 <a href="%s">.git-blame-ignore-revs</a> 的修订。点
 blame.ignore_revs.failed=忽略 <a href="%s">.git-blame-ignore-revs</a> 版本失败。
 user_search_tooltip=最多显示30名用户
 
+tree_path_not_found=%[2]s 中不存在路径 %[1]s
 
 transfer.accept=接受转移
 transfer.accept_desc=`转移到 "%s"`
@@ -1120,6 +1139,7 @@ transfer.no_permission_to_reject=您没有权限拒绝此转让。
 
 desc.private=私有库
 desc.public=公开
+desc.public_access=公开访问
 desc.template=模板
 desc.internal=内部
 desc.archived=已存档
@@ -1227,6 +1247,7 @@ create_new_repo_command=从命令行创建一个新的仓库
 push_exist_repo=从命令行推送已经创建的仓库
 empty_message=这个家伙很懒,什么都没有推送。
 broken_message=无法读取此仓库下的 Git 数据。 联系此实例的管理员或删除此仓库。
+no_branch=该仓库没有任何分支。
 
 code=代码
 code.desc=查看源码、文件、提交和分支。
@@ -1339,6 +1360,8 @@ editor.new_branch_name_desc=新的分支名称...
 editor.cancel=取消
 editor.filename_cannot_be_empty=文件名不能为空。
 editor.filename_is_invalid=文件名 %s 无效
+editor.commit_email=提交邮箱地址
+editor.invalid_commit_email=提交的邮箱地址无效。
 editor.branch_does_not_exist=此仓库中不存在名为 %s 的分支。
 editor.branch_already_exists=此仓库已存在名为 %s 的分支。
 editor.directory_is_a_file=%s 已经作为文件名在此仓库中存在。
@@ -1387,6 +1410,7 @@ commits.signed_by_untrusted_user_unmatched=由与提交者不匹配的未授信
 commits.gpg_key_id=GPG 密钥 ID
 commits.ssh_key_fingerprint=SSH 密钥指纹
 commits.view_path=在历史记录中的此处查看
+commits.view_file_diff=查看提交中的文件更改
 
 commit.operations=操作
 commit.revert=还原
@@ -1398,7 +1422,7 @@ commit.cherry-pick-content=选择 cherry-pick 的目标分支:
 
 commitstatus.error=错误
 commitstatus.failure=失败
-commitstatus.pending=待定
+commitstatus.pending=队列
 commitstatus.success=成功
 
 ext_issues=访问外部工单
@@ -1433,7 +1457,7 @@ projects.column.set_default=设为默认
 projects.column.set_default_desc=设置此列为未分类问题和合并请求的默认值
 projects.column.delete=删除列
 projects.column.deletion_desc=删除项目列会将所有相关问题移到“未分类”。是否继续?
-projects.column.color=彩色
+projects.column.color=颜色
 projects.open=开启
 projects.close=关闭
 projects.column.assigned_to=指派给
@@ -1447,6 +1471,8 @@ issues.filter_milestones=筛选里程碑
 issues.filter_projects=筛选项目
 issues.filter_labels=筛选标签
 issues.filter_reviewers=筛选审核者
+issues.filter_no_results=没有结果
+issues.filter_no_results_placeholder=请尝试调整您的搜索过滤器。
 issues.new=创建工单
 issues.new.title_empty=标题不能为空
 issues.new.labels=标签
@@ -1520,8 +1546,10 @@ issues.filter_milestone_open=进行中的里程碑
 issues.filter_milestone_closed=已关闭的里程碑
 issues.filter_project=项目
 issues.filter_project_all=所有项目
-issues.filter_project_none=暂无项目
+issues.filter_project_none=未加项目
 issues.filter_assignee=指派人筛选
+issues.filter_assignee_no_assignee=未指派给任何人
+issues.filter_assignee_any_assignee=已有指派
 issues.filter_poster=作者
 issues.filter_user_placeholder=搜索用户
 issues.filter_user_no_select=所有用户
@@ -1595,9 +1623,9 @@ issues.ref_reopened_from=`<a href="%[3]s">重新打开这个工单 %[4]s</a> <a
 issues.ref_from=`来自 %[1]s`
 issues.author=作者
 issues.author_helper=此用户是作者。
-issues.role.owner=管理员
+issues.role.owner=所有者
 issues.role.owner_helper=该用户是该仓库的所有者。
-issues.role.member=普通成员
+issues.role.member=成员
 issues.role.member_helper=该用户是拥有该仓库的组织成员。
 issues.role.collaborator=协作者
 issues.role.collaborator_helper=该用户已被邀请在仓库上进行协作。
@@ -1676,11 +1704,13 @@ issues.timetracker_timer_manually_add=添加时间
 
 issues.time_estimate_set=设置预计时间
 issues.time_estimate_display=预计: %s
+issues.change_time_estimate_at=预估时间已修改为 <b>%[1]s</b> %[2]s
 issues.remove_time_estimate_at=删除预计时间 %s
 issues.time_estimate_invalid=预计时间格式无效
 issues.start_tracking_history=`开始工作 %s`
 issues.tracker_auto_close=当此工单关闭时,自动停止计时器
 issues.tracking_already_started=`你已经开始对 <a href="%s">另一个工单</a> 进行时间跟踪!`
+issues.stop_tracking=停止计时器
 issues.cancel_tracking_history=`取消时间跟踪 %s`
 issues.del_time=删除此时间跟踪日志
 issues.del_time_history=`已删除时间 %s`
@@ -1912,6 +1942,7 @@ pulls.outdated_with_base_branch=此分支相比基础分支已过期
 pulls.close=关闭合并请求
 pulls.closed_at=`于 <a id="%[1]s" href="#%[1]s">%[2]s</a> 关闭此合并请求 `
 pulls.reopened_at=`重新打开此合并请求 <a id="%[1]s" href="#%[1]s">%[2]s</a>`
+pulls.cmd_instruction_hint=查看命令行提示
 pulls.cmd_instruction_checkout_title=检出
 pulls.cmd_instruction_checkout_desc=从你的仓库中检出一个新的分支并测试变更。
 pulls.cmd_instruction_merge_title=合并
@@ -2099,6 +2130,7 @@ contributors.contribution_type.deletions=删除
 settings=设置
 settings.desc=设置是你可以管理仓库设置的地方
 settings.options=仓库
+settings.public_access=公开访问
 settings.collaboration=协作者
 settings.collaboration.admin=管理员
 settings.collaboration.write=可写权限
@@ -2312,6 +2344,8 @@ settings.event_fork=派生
 settings.event_fork_desc=仓库被派生。
 settings.event_wiki=百科
 settings.event_wiki_desc=创建、重命名、编辑或删除了百科页面。
+settings.event_statuses=状态
+settings.event_statuses_desc=已从 API 更新提交状态。
 settings.event_release=版本发布
 settings.event_release_desc=发布、更新或删除版本时。
 settings.event_push=推送
@@ -2349,6 +2383,8 @@ settings.event_pull_request_review_request=发起合并请求评审
 settings.event_pull_request_review_request_desc=合并请求评审已请求或已取消
 settings.event_pull_request_approvals=合并请求批准
 settings.event_pull_request_merge=合并请求合并
+settings.event_header_workflow=工作流程事件
+settings.event_workflow_job=工作流任务
 settings.event_package=软件包
 settings.event_package_desc=软件包已在仓库中被创建或删除。
 settings.branch_filter=分支过滤
@@ -2611,6 +2647,9 @@ diff.image.overlay=叠加
 diff.has_escaped=这一行有隐藏的 Unicode 字符
 diff.show_file_tree=显示文件树
 diff.hide_file_tree=隐藏文件树
+diff.submodule_added=子模块 %[1]s 已添加到 %[2]s
+diff.submodule_deleted=子模块 %[1]s 已从 %[2]s 中删除
+diff.submodule_updated=子模块 %[1]s 已更新:%[2]s
 
 releases.desc=跟踪项目版本和下载。
 release.releases=版本发布
@@ -2681,6 +2720,7 @@ branch.restore_success=分支 "%s"已还原。
 branch.restore_failed=还原分支 "%s"失败。
 branch.protected_deletion_failed=不能删除受保护的分支 "%s"。
 branch.default_deletion_failed=不能删除默认分支"%s"。
+branch.default_branch_not_exist=默认分支 %s 不存在。
 branch.restore=`还原分支 "%s"`
 branch.download=`下载分支 "%s"`
 branch.rename=`重命名分支 "%s"`
@@ -2695,6 +2735,8 @@ branch.create_branch_operation=创建分支
 branch.new_branch=创建新分支
 branch.new_branch_from=基于"%s"创建新分支
 branch.renamed=分支 %s 被重命名为 %s。
+branch.rename_default_or_protected_branch_error=只有管理员能重命名默认分支和受保护的分支。
+branch.rename_protected_branch_failed=此分支受到 glob 语法规则的保护。
 
 tag.create_tag=创建标签 %s
 tag.create_tag_operation=创建标签
@@ -2849,7 +2891,15 @@ teams.invite.title=您已被邀请加入组织 <strong>%s</strong> 中的团队
 teams.invite.by=邀请人 %s
 teams.invite.description=请点击下面的按钮加入团队。
 
+view_as_role=以 %s 身份查看
+view_as_public_hint=您正在以公开用户的身份查看 README
+view_as_member_hint=您正在以组织成员的身份查看 README
 
+worktime=工作时间
+worktime.date_range_start=起始日期
+worktime.date_range_end=结束日期
+worktime.query=查询
+worktime.time=时间
 
 [admin]
 maintenance=维护
@@ -3343,6 +3393,7 @@ monitor.previous=上次执行时间
 monitor.execute_times=执行次数
 monitor.process=运行中进程
 monitor.stacktrace=调用栈踪迹
+monitor.trace=追踪
 monitor.performance_logs=性能日志
 monitor.processes_count=%d 个进程
 monitor.download_diagnosis_report=下载诊断报告
@@ -3518,10 +3569,11 @@ versions=版本
 versions.view_all=查看全部
 dependency.id=ID
 dependency.version=版本
+search_in_external_registry=在 %s 中搜索
 alpine.registry=通过在您的 <code>/etc/apk/repositories</code> 文件中添加 URL 来设置此注册中心:
 alpine.registry.key=下载注册中心公开的 RSA 密钥到 <code>/etc/apk/keys/</code> 文件夹来验证索引签名:
 alpine.registry.info=从下面的列表中选择 $branch 和 $repository。
-alpine.install=要安装包,请运行以下命令:
+alpine.install=要安装软件包,请运行以下命令:
 alpine.repository=仓库信息
 alpine.repository.branches=分支
 alpine.repository.repositories=仓库
@@ -3534,7 +3586,7 @@ arch.repository.architectures=架构
 cargo.registry=在 Cargo 配置文件中设置此注册中心(例如:<code>~/.cargo/config.toml</code>):
 cargo.install=要使用 Cargo 安装软件包,请运行以下命令:
 chef.registry=在您的 <code>~/.chef/config.rb</code> 文件中设置此注册中心:
-chef.install=要安装包,请运行以下命令:
+chef.install=要安装软件包,请运行以下命令:
 composer.registry=在您的 <code>~/.composer/config.json</code> 文件中设置此注册中心:
 composer.install=要使用 Composer 安装软件包,请运行以下命令:
 composer.dependencies=依赖
@@ -3548,16 +3600,17 @@ container.details.type=镜像类型
 container.details.platform=平台
 container.pull=从命令行拉取镜像:
 container.images=镜像
+container.digest=摘要
 container.multi_arch=OS / Arch
 container.layers=镜像层
 container.labels=标签
 container.labels.key=键
 container.labels.value=值
 cran.registry=在您的 <code>Rprofile.site</code> 文件中设置此注册中心:
-cran.install=要安装包,请运行以下命令:
+cran.install=要安装软件包,请运行以下命令:
 debian.registry=从命令行设置此注册中心:
 debian.registry.info=从下面的列表中选择 $distribution 和 $component。
-debian.install=要安装包,请运行以下命令:
+debian.install=要安装软件包,请运行以下命令:
 debian.repository=仓库信息
 debian.repository.distributions=发行版
 debian.repository.components=组件
@@ -3588,7 +3641,7 @@ pypi.install=要使用 pip 安装软件包,请运行以下命令:
 rpm.registry=从命令行设置此注册中心:
 rpm.distros.redhat=在基于 RedHat 的发行版
 rpm.distros.suse=在基于 SUSE 的发行版
-rpm.install=要安装包,请运行以下命令:
+rpm.install=要安装软件包,请运行以下命令:
 rpm.repository=仓库信息
 rpm.repository.architectures=架构
 rpm.repository.multiple_groups=此软件包可在多个组中使用。
@@ -3654,6 +3707,7 @@ creation=添加密钥
 creation.description=组织描述
 creation.name_placeholder=不区分大小写,字母数字或下划线不能以GITEA_ 或 GITHUB_ 开头。
 creation.value_placeholder=输入任何内容,开头和结尾的空白都会被省略
+creation.description_placeholder=输入简短描述(可选)。
 creation.success=您的密钥 '%s' 添加成功。
 creation.failed=添加密钥失败。
 deletion=删除密钥
@@ -3684,7 +3738,7 @@ runners.status=状态
 runners.id=ID
 runners.name=名称
 runners.owner_type=类型
-runners.description=组织描述
+runners.description=描述
 runners.labels=标签
 runners.last_online=上次在线时间
 runners.runner_title=Runner
@@ -3707,10 +3761,11 @@ runners.delete_runner_notice=如果一个任务正在运行在此运行器上,
 runners.none=无可用的 Runner
 runners.status.unspecified=未知
 runners.status.idle=空闲
-runners.status.active=激活
+runners.status.active=启用
 runners.status.offline=离线
 runners.version=版本
 runners.reset_registration_token=重置注册令牌
+runners.reset_registration_token_confirm=是否吊销当前令牌并生成一个新令牌?
 runners.reset_registration_token_success=成功重置运行器注册令牌
 
 runs.all_workflows=所有工作流
@@ -3743,6 +3798,7 @@ workflow.not_found=工作流 %s 未找到。
 workflow.run_success=工作流 %s 已成功运行。
 workflow.from_ref=使用工作流从
 workflow.has_workflow_dispatch=此 Workflow 有一个 Workflow_dispatch 事件触发器。
+workflow.has_no_workflow_dispatch=工作流 %s 没有 workflow_dispatch 事件的触发器。
 
 need_approval_desc=该工作流由派生仓库的合并请求所触发,需要批准方可运行。
 
@@ -3762,6 +3818,8 @@ variables.creation.success=变量 “%s” 添加成功。
 variables.update.failed=编辑变量失败。
 variables.update.success=该变量已被编辑。
 
+logs.always_auto_scroll=总是自动滚动日志
+logs.always_expand_running=总是展开运行日志
 
 [projects]
 deleted.display_name=已删除项目

From ae0af8ea5b2de99a49add2b7f7b76dde62a8a617 Mon Sep 17 00:00:00 2001
From: Lunny Xiao <xiaolunwen@gmail.com>
Date: Fri, 11 Apr 2025 06:41:29 -0700
Subject: [PATCH 12/14] Refactor Git Attribute & performance optimization
 (#34154)

This PR moved git attributes related code to `modules/git/attribute` sub
package and moved language stats related code to
`modules/git/languagestats` sub package to make it easier to maintain.

And it also introduced a performance improvement which use the `git
check-attr --source` which can be run in a bare git repository so that
we don't need to create a git index file. The new parameter need a git
version >= 2.40 . If git version less than 2.40, it will fall back to
previous implementation.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: yp05327 <576951401@qq.com>
---
 modules/git/attribute.go                      |  35 --
 modules/git/attribute/attribute.go            | 114 ++++++
 modules/git/attribute/attribute_test.go       |  37 ++
 modules/git/attribute/batch.go                | 216 +++++++++++
 .../batch_test.go}                            |  87 ++++-
 modules/git/attribute/checker.go              |  96 +++++
 modules/git/attribute/checker_test.go         |  74 ++++
 modules/git/attribute/main_test.go            |  41 +++
 modules/git/command.go                        |   7 +
 modules/git/git.go                            |   2 +
 .../language_stats.go}                        |  30 +-
 .../language_stats_gogit.go}                  |  81 +++--
 .../language_stats_nogogit.go}                |  90 +++--
 .../language_stats_test.go}                   |  11 +-
 modules/git/languagestats/main_test.go        |  41 +++
 modules/git/repo_attribute.go                 | 341 ------------------
 .../tests/repos/language_stats_repo/config    |   2 +-
 modules/git/tests/repos/repo3_notes/config    |   2 +-
 .../tests/repos/repo4_commitsbetween/config   |   2 +-
 modules/indexer/stats/db.go                   |   3 +-
 routers/web/repo/blame.go                     |   4 +-
 routers/web/repo/setting/lfs.go               |  32 +-
 routers/web/repo/view_file.go                 |  37 +-
 services/gitdiff/gitdiff.go                   |  26 +-
 services/markup/renderhelper_codepreview.go   |   4 +-
 services/repository/files/content.go          |  25 --
 services/repository/files/update.go           |   8 +-
 services/repository/files/upload.go           |  14 +-
 28 files changed, 875 insertions(+), 587 deletions(-)
 delete mode 100644 modules/git/attribute.go
 create mode 100644 modules/git/attribute/attribute.go
 create mode 100644 modules/git/attribute/attribute_test.go
 create mode 100644 modules/git/attribute/batch.go
 rename modules/git/{repo_attribute_test.go => attribute/batch_test.go} (50%)
 create mode 100644 modules/git/attribute/checker.go
 create mode 100644 modules/git/attribute/checker_test.go
 create mode 100644 modules/git/attribute/main_test.go
 rename modules/git/{repo_language_stats.go => languagestats/language_stats.go} (59%)
 rename modules/git/{repo_language_stats_gogit.go => languagestats/language_stats_gogit.go} (73%)
 rename modules/git/{repo_language_stats_nogogit.go => languagestats/language_stats_nogogit.go} (73%)
 rename modules/git/{repo_language_stats_test.go => languagestats/language_stats_test.go} (75%)
 create mode 100644 modules/git/languagestats/main_test.go
 delete mode 100644 modules/git/repo_attribute.go

diff --git a/modules/git/attribute.go b/modules/git/attribute.go
deleted file mode 100644
index 4dfa510369..0000000000
--- a/modules/git/attribute.go
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright 2024 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package git
-
-import (
-	"code.gitea.io/gitea/modules/optional"
-)
-
-const (
-	AttributeLinguistVendored      = "linguist-vendored"
-	AttributeLinguistGenerated     = "linguist-generated"
-	AttributeLinguistDocumentation = "linguist-documentation"
-	AttributeLinguistDetectable    = "linguist-detectable"
-	AttributeLinguistLanguage      = "linguist-language"
-	AttributeGitlabLanguage        = "gitlab-language"
-)
-
-// true if "set"/"true", false if "unset"/"false", none otherwise
-func AttributeToBool(attr map[string]string, name string) optional.Option[bool] {
-	switch attr[name] {
-	case "set", "true":
-		return optional.Some(true)
-	case "unset", "false":
-		return optional.Some(false)
-	}
-	return optional.None[bool]()
-}
-
-func AttributeToString(attr map[string]string, name string) optional.Option[string] {
-	if value, has := attr[name]; has && value != "unspecified" {
-		return optional.Some(value)
-	}
-	return optional.None[string]()
-}
diff --git a/modules/git/attribute/attribute.go b/modules/git/attribute/attribute.go
new file mode 100644
index 0000000000..adf323ef41
--- /dev/null
+++ b/modules/git/attribute/attribute.go
@@ -0,0 +1,114 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package attribute
+
+import (
+	"strings"
+
+	"code.gitea.io/gitea/modules/optional"
+)
+
+type Attribute string
+
+const (
+	LinguistVendored      = "linguist-vendored"
+	LinguistGenerated     = "linguist-generated"
+	LinguistDocumentation = "linguist-documentation"
+	LinguistDetectable    = "linguist-detectable"
+	LinguistLanguage      = "linguist-language"
+	GitlabLanguage        = "gitlab-language"
+	Lockable              = "lockable"
+	Filter                = "filter"
+)
+
+var LinguistAttributes = []string{
+	LinguistVendored,
+	LinguistGenerated,
+	LinguistDocumentation,
+	LinguistDetectable,
+	LinguistLanguage,
+	GitlabLanguage,
+}
+
+func (a Attribute) IsUnspecified() bool {
+	return a == "" || a == "unspecified"
+}
+
+func (a Attribute) ToString() optional.Option[string] {
+	if !a.IsUnspecified() {
+		return optional.Some(string(a))
+	}
+	return optional.None[string]()
+}
+
+// ToBool converts the attribute value to optional boolean: true if "set"/"true", false if "unset"/"false", none otherwise
+func (a Attribute) ToBool() optional.Option[bool] {
+	switch a {
+	case "set", "true":
+		return optional.Some(true)
+	case "unset", "false":
+		return optional.Some(false)
+	}
+	return optional.None[bool]()
+}
+
+type Attributes struct {
+	m map[string]Attribute
+}
+
+func NewAttributes() *Attributes {
+	return &Attributes{m: make(map[string]Attribute)}
+}
+
+func (attrs *Attributes) Get(name string) Attribute {
+	if value, has := attrs.m[name]; has {
+		return value
+	}
+	return ""
+}
+
+func (attrs *Attributes) GetVendored() optional.Option[bool] {
+	return attrs.Get(LinguistVendored).ToBool()
+}
+
+func (attrs *Attributes) GetGenerated() optional.Option[bool] {
+	return attrs.Get(LinguistGenerated).ToBool()
+}
+
+func (attrs *Attributes) GetDocumentation() optional.Option[bool] {
+	return attrs.Get(LinguistDocumentation).ToBool()
+}
+
+func (attrs *Attributes) GetDetectable() optional.Option[bool] {
+	return attrs.Get(LinguistDetectable).ToBool()
+}
+
+func (attrs *Attributes) GetLinguistLanguage() optional.Option[string] {
+	return attrs.Get(LinguistLanguage).ToString()
+}
+
+func (attrs *Attributes) GetGitlabLanguage() optional.Option[string] {
+	attrStr := attrs.Get(GitlabLanguage).ToString()
+	if attrStr.Has() {
+		raw := attrStr.Value()
+		// gitlab-language may have additional parameters after the language
+		// ignore them and just use the main language
+		// https://docs.gitlab.com/ee/user/project/highlighting.html#override-syntax-highlighting-for-a-file-type
+		if idx := strings.IndexByte(raw, '?'); idx >= 0 {
+			return optional.Some(raw[:idx])
+		}
+	}
+	return attrStr
+}
+
+func (attrs *Attributes) GetLanguage() optional.Option[string] {
+	// prefer linguist-language over gitlab-language
+	// if linguist-language is not set, use gitlab-language
+	// if both are not set, return none
+	language := attrs.GetLinguistLanguage()
+	if language.Value() == "" {
+		language = attrs.GetGitlabLanguage()
+	}
+	return language
+}
diff --git a/modules/git/attribute/attribute_test.go b/modules/git/attribute/attribute_test.go
new file mode 100644
index 0000000000..dadb5582a3
--- /dev/null
+++ b/modules/git/attribute/attribute_test.go
@@ -0,0 +1,37 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package attribute
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_Attribute(t *testing.T) {
+	assert.Empty(t, Attribute("").ToString().Value())
+	assert.Empty(t, Attribute("unspecified").ToString().Value())
+	assert.Equal(t, "python", Attribute("python").ToString().Value())
+	assert.Equal(t, "Java", Attribute("Java").ToString().Value())
+
+	attributes := Attributes{
+		m: map[string]Attribute{
+			LinguistGenerated:     "true",
+			LinguistDocumentation: "false",
+			LinguistDetectable:    "set",
+			LinguistLanguage:      "Python",
+			GitlabLanguage:        "Java",
+			"filter":              "unspecified",
+			"test":                "",
+		},
+	}
+
+	assert.Empty(t, attributes.Get("test").ToString().Value())
+	assert.Empty(t, attributes.Get("filter").ToString().Value())
+	assert.Equal(t, "Python", attributes.Get(LinguistLanguage).ToString().Value())
+	assert.Equal(t, "Java", attributes.Get(GitlabLanguage).ToString().Value())
+	assert.True(t, attributes.Get(LinguistGenerated).ToBool().Value())
+	assert.False(t, attributes.Get(LinguistDocumentation).ToBool().Value())
+	assert.True(t, attributes.Get(LinguistDetectable).ToBool().Value())
+}
diff --git a/modules/git/attribute/batch.go b/modules/git/attribute/batch.go
new file mode 100644
index 0000000000..4e31fda575
--- /dev/null
+++ b/modules/git/attribute/batch.go
@@ -0,0 +1,216 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package attribute
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/log"
+)
+
+// BatchChecker provides a reader for check-attribute content that can be long running
+type BatchChecker struct {
+	attributesNum int
+	repo          *git.Repository
+	stdinWriter   *os.File
+	stdOut        *nulSeparatedAttributeWriter
+	ctx           context.Context
+	cancel        context.CancelFunc
+	cmd           *git.Command
+}
+
+// NewBatchChecker creates a check attribute reader for the current repository and provided commit ID
+// If treeish is empty, then it will use current working directory, otherwise it will use the provided treeish on the bare repo
+func NewBatchChecker(repo *git.Repository, treeish string, attributes []string) (checker *BatchChecker, returnedErr error) {
+	ctx, cancel := context.WithCancel(repo.Ctx)
+	defer func() {
+		if returnedErr != nil {
+			cancel()
+		}
+	}()
+
+	cmd, envs, cleanup, err := checkAttrCommand(repo, treeish, nil, attributes)
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		if returnedErr != nil {
+			cleanup()
+		}
+	}()
+
+	cmd.AddArguments("--stdin")
+
+	checker = &BatchChecker{
+		attributesNum: len(attributes),
+		repo:          repo,
+		ctx:           ctx,
+		cmd:           cmd,
+		cancel: func() {
+			cancel()
+			cleanup()
+		},
+	}
+
+	stdinReader, stdinWriter, err := os.Pipe()
+	if err != nil {
+		return nil, err
+	}
+	checker.stdinWriter = stdinWriter
+
+	lw := new(nulSeparatedAttributeWriter)
+	lw.attributes = make(chan attributeTriple, len(attributes))
+	lw.closed = make(chan struct{})
+	checker.stdOut = lw
+
+	go func() {
+		defer func() {
+			_ = stdinReader.Close()
+			_ = lw.Close()
+		}()
+		stdErr := new(bytes.Buffer)
+		err := cmd.Run(ctx, &git.RunOpts{
+			Env:    envs,
+			Dir:    repo.Path,
+			Stdin:  stdinReader,
+			Stdout: lw,
+			Stderr: stdErr,
+		})
+
+		if err != nil && !git.IsErrCanceledOrKilled(err) {
+			log.Error("Attribute checker for commit %s exits with error: %v", treeish, err)
+		}
+		checker.cancel()
+	}()
+
+	return checker, nil
+}
+
+// CheckPath check attr for given path
+func (c *BatchChecker) CheckPath(path string) (rs *Attributes, err error) {
+	defer func() {
+		if err != nil && err != c.ctx.Err() {
+			log.Error("Unexpected error when checking path %s in %s, error: %v", path, filepath.Base(c.repo.Path), err)
+		}
+	}()
+
+	select {
+	case <-c.ctx.Done():
+		return nil, c.ctx.Err()
+	default:
+	}
+
+	if _, err = c.stdinWriter.Write([]byte(path + "\x00")); err != nil {
+		defer c.Close()
+		return nil, err
+	}
+
+	reportTimeout := func() error {
+		stdOutClosed := false
+		select {
+		case <-c.stdOut.closed:
+			stdOutClosed = true
+		default:
+		}
+		debugMsg := fmt.Sprintf("check path %q in repo %q", path, filepath.Base(c.repo.Path))
+		debugMsg += fmt.Sprintf(", stdOut: tmp=%q, pos=%d, closed=%v", string(c.stdOut.tmp), c.stdOut.pos, stdOutClosed)
+		if c.cmd != nil {
+			debugMsg += fmt.Sprintf(", process state: %q", c.cmd.ProcessState())
+		}
+		_ = c.Close()
+		return fmt.Errorf("CheckPath timeout: %s", debugMsg)
+	}
+
+	rs = NewAttributes()
+	for i := 0; i < c.attributesNum; i++ {
+		select {
+		case <-time.After(5 * time.Second):
+			// there is no "hang" problem now. This code is just used to catch other potential problems.
+			return nil, reportTimeout()
+		case attr, ok := <-c.stdOut.ReadAttribute():
+			if !ok {
+				return nil, c.ctx.Err()
+			}
+			rs.m[attr.Attribute] = Attribute(attr.Value)
+		case <-c.ctx.Done():
+			return nil, c.ctx.Err()
+		}
+	}
+	return rs, nil
+}
+
+func (c *BatchChecker) Close() error {
+	c.cancel()
+	err := c.stdinWriter.Close()
+	return err
+}
+
+type attributeTriple struct {
+	Filename  string
+	Attribute string
+	Value     string
+}
+
+type nulSeparatedAttributeWriter struct {
+	tmp        []byte
+	attributes chan attributeTriple
+	closed     chan struct{}
+	working    attributeTriple
+	pos        int
+}
+
+func (wr *nulSeparatedAttributeWriter) Write(p []byte) (n int, err error) {
+	l, read := len(p), 0
+
+	nulIdx := bytes.IndexByte(p, '\x00')
+	for nulIdx >= 0 {
+		wr.tmp = append(wr.tmp, p[:nulIdx]...)
+		switch wr.pos {
+		case 0:
+			wr.working = attributeTriple{
+				Filename: string(wr.tmp),
+			}
+		case 1:
+			wr.working.Attribute = string(wr.tmp)
+		case 2:
+			wr.working.Value = string(wr.tmp)
+		}
+		wr.tmp = wr.tmp[:0]
+		wr.pos++
+		if wr.pos > 2 {
+			wr.attributes <- wr.working
+			wr.pos = 0
+		}
+		read += nulIdx + 1
+		if l > read {
+			p = p[nulIdx+1:]
+			nulIdx = bytes.IndexByte(p, '\x00')
+		} else {
+			return l, nil
+		}
+	}
+	wr.tmp = append(wr.tmp, p...)
+	return l, nil
+}
+
+func (wr *nulSeparatedAttributeWriter) ReadAttribute() <-chan attributeTriple {
+	return wr.attributes
+}
+
+func (wr *nulSeparatedAttributeWriter) Close() error {
+	select {
+	case <-wr.closed:
+		return nil
+	default:
+	}
+	close(wr.attributes)
+	close(wr.closed)
+	return nil
+}
diff --git a/modules/git/repo_attribute_test.go b/modules/git/attribute/batch_test.go
similarity index 50%
rename from modules/git/repo_attribute_test.go
rename to modules/git/attribute/batch_test.go
index d8fd9f0e8d..30a3d805fe 100644
--- a/modules/git/repo_attribute_test.go
+++ b/modules/git/attribute/batch_test.go
@@ -1,13 +1,19 @@
 // Copyright 2021 The Gitea Authors. All rights reserved.
 // SPDX-License-Identifier: MIT
 
-package git
+package attribute
 
 import (
+	"path/filepath"
 	"testing"
 	"time"
 
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/test"
+
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func Test_nulSeparatedAttributeWriter_ReadAttribute(t *testing.T) {
@@ -24,7 +30,7 @@ func Test_nulSeparatedAttributeWriter_ReadAttribute(t *testing.T) {
 	select {
 	case attr := <-wr.ReadAttribute():
 		assert.Equal(t, ".gitignore\"\n", attr.Filename)
-		assert.Equal(t, AttributeLinguistVendored, attr.Attribute)
+		assert.Equal(t, LinguistVendored, attr.Attribute)
 		assert.Equal(t, "unspecified", attr.Value)
 	case <-time.After(100 * time.Millisecond):
 		assert.FailNow(t, "took too long to read an attribute from the list")
@@ -38,7 +44,7 @@ func Test_nulSeparatedAttributeWriter_ReadAttribute(t *testing.T) {
 	select {
 	case attr := <-wr.ReadAttribute():
 		assert.Equal(t, ".gitignore\"\n", attr.Filename)
-		assert.Equal(t, AttributeLinguistVendored, attr.Attribute)
+		assert.Equal(t, LinguistVendored, attr.Attribute)
 		assert.Equal(t, "unspecified", attr.Value)
 	case <-time.After(100 * time.Millisecond):
 		assert.FailNow(t, "took too long to read an attribute from the list")
@@ -77,21 +83,90 @@ func Test_nulSeparatedAttributeWriter_ReadAttribute(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(t, attributeTriple{
 		Filename:  "shouldbe.vendor",
-		Attribute: AttributeLinguistVendored,
+		Attribute: LinguistVendored,
 		Value:     "set",
 	}, attr)
 	attr = <-wr.ReadAttribute()
 	assert.NoError(t, err)
 	assert.Equal(t, attributeTriple{
 		Filename:  "shouldbe.vendor",
-		Attribute: AttributeLinguistGenerated,
+		Attribute: LinguistGenerated,
 		Value:     "unspecified",
 	}, attr)
 	attr = <-wr.ReadAttribute()
 	assert.NoError(t, err)
 	assert.Equal(t, attributeTriple{
 		Filename:  "shouldbe.vendor",
-		Attribute: AttributeLinguistLanguage,
+		Attribute: LinguistLanguage,
 		Value:     "unspecified",
 	}, attr)
 }
+
+func expectedAttrs() *Attributes {
+	return &Attributes{
+		m: map[string]Attribute{
+			LinguistGenerated:     "unspecified",
+			LinguistDetectable:    "unspecified",
+			LinguistDocumentation: "unspecified",
+			LinguistVendored:      "unspecified",
+			LinguistLanguage:      "Python",
+			GitlabLanguage:        "unspecified",
+		},
+	}
+}
+
+func Test_BatchChecker(t *testing.T) {
+	setting.AppDataPath = t.TempDir()
+	repoPath := "../tests/repos/language_stats_repo"
+	gitRepo, err := git.OpenRepository(t.Context(), repoPath)
+	require.NoError(t, err)
+	defer gitRepo.Close()
+
+	commitID := "8fee858da5796dfb37704761701bb8e800ad9ef3"
+
+	t.Run("Create index file to run git check-attr", func(t *testing.T) {
+		defer test.MockVariableValue(&git.DefaultFeatures().SupportCheckAttrOnBare, false)()
+		checker, err := NewBatchChecker(gitRepo, commitID, LinguistAttributes)
+		assert.NoError(t, err)
+		defer checker.Close()
+		attributes, err := checker.CheckPath("i-am-a-python.p")
+		assert.NoError(t, err)
+		assert.Equal(t, expectedAttrs(), attributes)
+	})
+
+	// run git check-attr on work tree
+	t.Run("Run git check-attr on git work tree", func(t *testing.T) {
+		dir := filepath.Join(t.TempDir(), "test-repo")
+		err := git.Clone(t.Context(), repoPath, dir, git.CloneRepoOptions{
+			Shared: true,
+			Branch: "master",
+		})
+		assert.NoError(t, err)
+
+		tempRepo, err := git.OpenRepository(t.Context(), dir)
+		assert.NoError(t, err)
+		defer tempRepo.Close()
+
+		checker, err := NewBatchChecker(tempRepo, "", LinguistAttributes)
+		assert.NoError(t, err)
+		defer checker.Close()
+		attributes, err := checker.CheckPath("i-am-a-python.p")
+		assert.NoError(t, err)
+		assert.Equal(t, expectedAttrs(), attributes)
+	})
+
+	if !git.DefaultFeatures().SupportCheckAttrOnBare {
+		t.Skip("git version 2.40 is required to support run check-attr on bare repo")
+		return
+	}
+
+	t.Run("Run git check-attr in bare repository", func(t *testing.T) {
+		checker, err := NewBatchChecker(gitRepo, commitID, LinguistAttributes)
+		assert.NoError(t, err)
+		defer checker.Close()
+
+		attributes, err := checker.CheckPath("i-am-a-python.p")
+		assert.NoError(t, err)
+		assert.Equal(t, expectedAttrs(), attributes)
+	})
+}
diff --git a/modules/git/attribute/checker.go b/modules/git/attribute/checker.go
new file mode 100644
index 0000000000..c17006a154
--- /dev/null
+++ b/modules/git/attribute/checker.go
@@ -0,0 +1,96 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package attribute
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"os"
+
+	"code.gitea.io/gitea/modules/git"
+)
+
+func checkAttrCommand(gitRepo *git.Repository, treeish string, filenames, attributes []string) (*git.Command, []string, func(), error) {
+	cancel := func() {}
+	envs := []string{"GIT_FLUSH=1"}
+	cmd := git.NewCommand("check-attr", "-z")
+	if len(attributes) == 0 {
+		cmd.AddArguments("--all")
+	}
+
+	// there is treeish, read from bare repo or temp index created by "read-tree"
+	if treeish != "" {
+		if git.DefaultFeatures().SupportCheckAttrOnBare {
+			cmd.AddArguments("--source")
+			cmd.AddDynamicArguments(treeish)
+		} else {
+			indexFilename, worktree, deleteTemporaryFile, err := gitRepo.ReadTreeToTemporaryIndex(treeish)
+			if err != nil {
+				return nil, nil, nil, err
+			}
+
+			cmd.AddArguments("--cached")
+			envs = append(envs,
+				"GIT_INDEX_FILE="+indexFilename,
+				"GIT_WORK_TREE="+worktree,
+			)
+			cancel = deleteTemporaryFile
+		}
+	} // else: no treeish, assume it is a not a bare repo, read from working directory
+
+	cmd.AddDynamicArguments(attributes...)
+	if len(filenames) > 0 {
+		cmd.AddDashesAndList(filenames...)
+	}
+	return cmd, envs, cancel, nil
+}
+
+type CheckAttributeOpts struct {
+	Filenames  []string
+	Attributes []string
+}
+
+// CheckAttributes return the attributes of the given filenames and attributes in the given treeish.
+// If treeish is empty, then it will use current working directory, otherwise it will use the provided treeish on the bare repo
+func CheckAttributes(ctx context.Context, gitRepo *git.Repository, treeish string, opts CheckAttributeOpts) (map[string]*Attributes, error) {
+	cmd, envs, cancel, err := checkAttrCommand(gitRepo, treeish, opts.Filenames, opts.Attributes)
+	if err != nil {
+		return nil, err
+	}
+	defer cancel()
+
+	stdOut := new(bytes.Buffer)
+	stdErr := new(bytes.Buffer)
+
+	if err := cmd.Run(ctx, &git.RunOpts{
+		Env:    append(os.Environ(), envs...),
+		Dir:    gitRepo.Path,
+		Stdout: stdOut,
+		Stderr: stdErr,
+	}); err != nil {
+		return nil, fmt.Errorf("failed to run check-attr: %w\n%s\n%s", err, stdOut.String(), stdErr.String())
+	}
+
+	fields := bytes.Split(stdOut.Bytes(), []byte{'\000'})
+	if len(fields)%3 != 1 {
+		return nil, errors.New("wrong number of fields in return from check-attr")
+	}
+
+	attributesMap := make(map[string]*Attributes)
+	for i := 0; i < (len(fields) / 3); i++ {
+		filename := string(fields[3*i])
+		attribute := string(fields[3*i+1])
+		info := string(fields[3*i+2])
+		attribute2info, ok := attributesMap[filename]
+		if !ok {
+			attribute2info = NewAttributes()
+			attributesMap[filename] = attribute2info
+		}
+		attribute2info.m[attribute] = Attribute(info)
+	}
+
+	return attributesMap, nil
+}
diff --git a/modules/git/attribute/checker_test.go b/modules/git/attribute/checker_test.go
new file mode 100644
index 0000000000..97db43460b
--- /dev/null
+++ b/modules/git/attribute/checker_test.go
@@ -0,0 +1,74 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package attribute
+
+import (
+	"path/filepath"
+	"testing"
+
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/test"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_Checker(t *testing.T) {
+	setting.AppDataPath = t.TempDir()
+	repoPath := "../tests/repos/language_stats_repo"
+	gitRepo, err := git.OpenRepository(t.Context(), repoPath)
+	require.NoError(t, err)
+	defer gitRepo.Close()
+
+	commitID := "8fee858da5796dfb37704761701bb8e800ad9ef3"
+
+	t.Run("Create index file to run git check-attr", func(t *testing.T) {
+		defer test.MockVariableValue(&git.DefaultFeatures().SupportCheckAttrOnBare, false)()
+		attrs, err := CheckAttributes(t.Context(), gitRepo, commitID, CheckAttributeOpts{
+			Filenames:  []string{"i-am-a-python.p"},
+			Attributes: LinguistAttributes,
+		})
+		assert.NoError(t, err)
+		assert.Len(t, attrs, 1)
+		assert.Equal(t, expectedAttrs(), attrs["i-am-a-python.p"])
+	})
+
+	// run git check-attr on work tree
+	t.Run("Run git check-attr on git work tree", func(t *testing.T) {
+		dir := filepath.Join(t.TempDir(), "test-repo")
+		err := git.Clone(t.Context(), repoPath, dir, git.CloneRepoOptions{
+			Shared: true,
+			Branch: "master",
+		})
+		assert.NoError(t, err)
+
+		tempRepo, err := git.OpenRepository(t.Context(), dir)
+		assert.NoError(t, err)
+		defer tempRepo.Close()
+
+		attrs, err := CheckAttributes(t.Context(), tempRepo, "", CheckAttributeOpts{
+			Filenames:  []string{"i-am-a-python.p"},
+			Attributes: LinguistAttributes,
+		})
+		assert.NoError(t, err)
+		assert.Len(t, attrs, 1)
+		assert.Equal(t, expectedAttrs(), attrs["i-am-a-python.p"])
+	})
+
+	if !git.DefaultFeatures().SupportCheckAttrOnBare {
+		t.Skip("git version 2.40 is required to support run check-attr on bare repo")
+		return
+	}
+
+	t.Run("Run git check-attr in bare repository", func(t *testing.T) {
+		attrs, err := CheckAttributes(t.Context(), gitRepo, commitID, CheckAttributeOpts{
+			Filenames:  []string{"i-am-a-python.p"},
+			Attributes: LinguistAttributes,
+		})
+		assert.NoError(t, err)
+		assert.Len(t, attrs, 1)
+		assert.Equal(t, expectedAttrs(), attrs["i-am-a-python.p"])
+	})
+}
diff --git a/modules/git/attribute/main_test.go b/modules/git/attribute/main_test.go
new file mode 100644
index 0000000000..df8241bfb0
--- /dev/null
+++ b/modules/git/attribute/main_test.go
@@ -0,0 +1,41 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package attribute
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"testing"
+
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/util"
+)
+
+func testRun(m *testing.M) error {
+	gitHomePath, err := os.MkdirTemp(os.TempDir(), "git-home")
+	if err != nil {
+		return fmt.Errorf("unable to create temp dir: %w", err)
+	}
+	defer util.RemoveAll(gitHomePath)
+	setting.Git.HomePath = gitHomePath
+
+	if err = git.InitFull(context.Background()); err != nil {
+		return fmt.Errorf("failed to call Init: %w", err)
+	}
+
+	exitCode := m.Run()
+	if exitCode != 0 {
+		return fmt.Errorf("run test failed, ExitCode=%d", exitCode)
+	}
+	return nil
+}
+
+func TestMain(m *testing.M) {
+	if err := testRun(m); err != nil {
+		_, _ = fmt.Fprintf(os.Stderr, "Test failed: %v", err)
+		os.Exit(1)
+	}
+}
diff --git a/modules/git/command.go b/modules/git/command.go
index f449f1ff0e..eaaa4969d0 100644
--- a/modules/git/command.go
+++ b/modules/git/command.go
@@ -80,6 +80,13 @@ func (c *Command) LogString() string {
 	return strings.Join(a, " ")
 }
 
+func (c *Command) ProcessState() string {
+	if c.cmd == nil {
+		return ""
+	}
+	return c.cmd.ProcessState.String()
+}
+
 // NewCommand creates and returns a new Git Command based on given command and arguments.
 // Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead.
 func NewCommand(args ...internal.CmdArg) *Command {
diff --git a/modules/git/git.go b/modules/git/git.go
index 2b593910a2..a2ffd6d289 100644
--- a/modules/git/git.go
+++ b/modules/git/git.go
@@ -30,6 +30,7 @@ type Features struct {
 	SupportProcReceive     bool           // >= 2.29
 	SupportHashSha256      bool           // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’
 	SupportedObjectFormats []ObjectFormat // sha1, sha256
+	SupportCheckAttrOnBare bool           // >= 2.40
 }
 
 var (
@@ -77,6 +78,7 @@ func loadGitVersionFeatures() (*Features, error) {
 	if features.SupportHashSha256 {
 		features.SupportedObjectFormats = append(features.SupportedObjectFormats, Sha256ObjectFormat)
 	}
+	features.SupportCheckAttrOnBare = features.CheckVersionAtLeast("2.40")
 	return features, nil
 }
 
diff --git a/modules/git/repo_language_stats.go b/modules/git/languagestats/language_stats.go
similarity index 59%
rename from modules/git/repo_language_stats.go
rename to modules/git/languagestats/language_stats.go
index 8551ea9d24..a71284c3e4 100644
--- a/modules/git/repo_language_stats.go
+++ b/modules/git/languagestats/language_stats.go
@@ -1,13 +1,15 @@
 // Copyright 2020 The Gitea Authors. All rights reserved.
 // SPDX-License-Identifier: MIT
 
-package git
+package languagestats
 
 import (
+	"context"
 	"strings"
 	"unicode"
 
-	"code.gitea.io/gitea/modules/optional"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/git/attribute"
 )
 
 const (
@@ -49,19 +51,15 @@ func mergeLanguageStats(stats map[string]int64) map[string]int64 {
 	return res
 }
 
-func TryReadLanguageAttribute(attrs map[string]string) optional.Option[string] {
-	language := AttributeToString(attrs, AttributeLinguistLanguage)
-	if language.Value() == "" {
-		language = AttributeToString(attrs, AttributeGitlabLanguage)
-		if language.Has() {
-			raw := language.Value()
-			// gitlab-language may have additional parameters after the language
-			// ignore them and just use the main language
-			// https://docs.gitlab.com/ee/user/project/highlighting.html#override-syntax-highlighting-for-a-file-type
-			if idx := strings.IndexByte(raw, '?'); idx >= 0 {
-				language = optional.Some(raw[:idx])
-			}
-		}
+// GetFileLanguage tries to get the (linguist) language of the file content
+func GetFileLanguage(ctx context.Context, gitRepo *git.Repository, treeish, treePath string) (string, error) {
+	attributesMap, err := attribute.CheckAttributes(ctx, gitRepo, treeish, attribute.CheckAttributeOpts{
+		Attributes: []string{attribute.LinguistLanguage, attribute.GitlabLanguage},
+		Filenames:  []string{treePath},
+	})
+	if err != nil {
+		return "", err
 	}
-	return language
+
+	return attributesMap[treePath].GetLanguage().Value(), nil
 }
diff --git a/modules/git/repo_language_stats_gogit.go b/modules/git/languagestats/language_stats_gogit.go
similarity index 73%
rename from modules/git/repo_language_stats_gogit.go
rename to modules/git/languagestats/language_stats_gogit.go
index a34c03c781..418c05b157 100644
--- a/modules/git/repo_language_stats_gogit.go
+++ b/modules/git/languagestats/language_stats_gogit.go
@@ -3,13 +3,15 @@
 
 //go:build gogit
 
-package git
+package languagestats
 
 import (
 	"bytes"
 	"io"
 
 	"code.gitea.io/gitea/modules/analyze"
+	git_module "code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/git/attribute"
 	"code.gitea.io/gitea/modules/optional"
 
 	"github.com/go-enry/go-enry/v2"
@@ -19,7 +21,7 @@ import (
 )
 
 // GetLanguageStats calculates language stats for git repository at specified commit
-func (repo *Repository) GetLanguageStats(commitID string) (map[string]int64, error) {
+func GetLanguageStats(repo *git_module.Repository, commitID string) (map[string]int64, error) {
 	r, err := git.PlainOpen(repo.Path)
 	if err != nil {
 		return nil, err
@@ -40,8 +42,11 @@ func (repo *Repository) GetLanguageStats(commitID string) (map[string]int64, err
 		return nil, err
 	}
 
-	checker, deferable := repo.CheckAttributeReader(commitID)
-	defer deferable()
+	checker, err := attribute.NewBatchChecker(repo, commitID, attribute.LinguistAttributes)
+	if err != nil {
+		return nil, err
+	}
+	defer checker.Close()
 
 	// sizes contains the current calculated size of all files by language
 	sizes := make(map[string]int64)
@@ -62,43 +67,41 @@ func (repo *Repository) GetLanguageStats(commitID string) (map[string]int64, err
 		isDocumentation := optional.None[bool]()
 		isDetectable := optional.None[bool]()
 
-		if checker != nil {
-			attrs, err := checker.CheckPath(f.Name)
-			if err == nil {
-				isVendored = AttributeToBool(attrs, AttributeLinguistVendored)
-				if isVendored.ValueOrDefault(false) {
-					return nil
+		attrs, err := checker.CheckPath(f.Name)
+		if err == nil {
+			isVendored = attrs.GetVendored()
+			if isVendored.ValueOrDefault(false) {
+				return nil
+			}
+
+			isGenerated = attrs.GetGenerated()
+			if isGenerated.ValueOrDefault(false) {
+				return nil
+			}
+
+			isDocumentation = attrs.GetDocumentation()
+			if isDocumentation.ValueOrDefault(false) {
+				return nil
+			}
+
+			isDetectable = attrs.GetDetectable()
+			if !isDetectable.ValueOrDefault(true) {
+				return nil
+			}
+
+			hasLanguage := attrs.GetLanguage()
+			if hasLanguage.Value() != "" {
+				language := hasLanguage.Value()
+
+				// group languages, such as Pug -> HTML; SCSS -> CSS
+				group := enry.GetLanguageGroup(language)
+				if len(group) != 0 {
+					language = group
 				}
 
-				isGenerated = AttributeToBool(attrs, AttributeLinguistGenerated)
-				if isGenerated.ValueOrDefault(false) {
-					return nil
-				}
-
-				isDocumentation = AttributeToBool(attrs, AttributeLinguistDocumentation)
-				if isDocumentation.ValueOrDefault(false) {
-					return nil
-				}
-
-				isDetectable = AttributeToBool(attrs, AttributeLinguistDetectable)
-				if !isDetectable.ValueOrDefault(true) {
-					return nil
-				}
-
-				hasLanguage := TryReadLanguageAttribute(attrs)
-				if hasLanguage.Value() != "" {
-					language := hasLanguage.Value()
-
-					// group languages, such as Pug -> HTML; SCSS -> CSS
-					group := enry.GetLanguageGroup(language)
-					if len(group) != 0 {
-						language = group
-					}
-
-					// this language will always be added to the size
-					sizes[language] += f.Size
-					return nil
-				}
+				// this language will always be added to the size
+				sizes[language] += f.Size
+				return nil
 			}
 		}
 
diff --git a/modules/git/repo_language_stats_nogogit.go b/modules/git/languagestats/language_stats_nogogit.go
similarity index 73%
rename from modules/git/repo_language_stats_nogogit.go
rename to modules/git/languagestats/language_stats_nogogit.go
index de7707bd6c..34797263a6 100644
--- a/modules/git/repo_language_stats_nogogit.go
+++ b/modules/git/languagestats/language_stats_nogogit.go
@@ -3,13 +3,15 @@
 
 //go:build !gogit
 
-package git
+package languagestats
 
 import (
 	"bytes"
 	"io"
 
 	"code.gitea.io/gitea/modules/analyze"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/git/attribute"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/optional"
 
@@ -17,7 +19,7 @@ import (
 )
 
 // GetLanguageStats calculates language stats for git repository at specified commit
-func (repo *Repository) GetLanguageStats(commitID string) (map[string]int64, error) {
+func GetLanguageStats(repo *git.Repository, commitID string) (map[string]int64, error) {
 	// We will feed the commit IDs in order into cat-file --batch, followed by blobs as necessary.
 	// so let's create a batch stdin and stdout
 	batchStdinWriter, batchReader, cancel, err := repo.CatFileBatch(repo.Ctx)
@@ -34,19 +36,19 @@ func (repo *Repository) GetLanguageStats(commitID string) (map[string]int64, err
 	if err := writeID(commitID); err != nil {
 		return nil, err
 	}
-	shaBytes, typ, size, err := ReadBatchLine(batchReader)
+	shaBytes, typ, size, err := git.ReadBatchLine(batchReader)
 	if typ != "commit" {
 		log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
-		return nil, ErrNotExist{commitID, ""}
+		return nil, git.ErrNotExist{ID: commitID}
 	}
 
-	sha, err := NewIDFromString(string(shaBytes))
+	sha, err := git.NewIDFromString(string(shaBytes))
 	if err != nil {
 		log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
-		return nil, ErrNotExist{commitID, ""}
+		return nil, git.ErrNotExist{ID: commitID}
 	}
 
-	commit, err := CommitFromReader(repo, sha, io.LimitReader(batchReader, size))
+	commit, err := git.CommitFromReader(repo, sha, io.LimitReader(batchReader, size))
 	if err != nil {
 		log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
 		return nil, err
@@ -62,8 +64,11 @@ func (repo *Repository) GetLanguageStats(commitID string) (map[string]int64, err
 		return nil, err
 	}
 
-	checker, deferable := repo.CheckAttributeReader(commitID)
-	defer deferable()
+	checker, err := attribute.NewBatchChecker(repo, commitID, attribute.LinguistAttributes)
+	if err != nil {
+		return nil, err
+	}
+	defer checker.Close()
 
 	contentBuf := bytes.Buffer{}
 	var content []byte
@@ -96,43 +101,36 @@ func (repo *Repository) GetLanguageStats(commitID string) (map[string]int64, err
 		isDocumentation := optional.None[bool]()
 		isDetectable := optional.None[bool]()
 
-		if checker != nil {
-			attrs, err := checker.CheckPath(f.Name())
-			if err == nil {
-				isVendored = AttributeToBool(attrs, AttributeLinguistVendored)
-				if isVendored.ValueOrDefault(false) {
-					continue
+		attrs, err := checker.CheckPath(f.Name())
+		if err == nil {
+			if isVendored = attrs.GetVendored(); isVendored.ValueOrDefault(false) {
+				continue
+			}
+
+			if isGenerated = attrs.GetGenerated(); isGenerated.ValueOrDefault(false) {
+				continue
+			}
+
+			if isDocumentation = attrs.GetDocumentation(); isDocumentation.ValueOrDefault(false) {
+				continue
+			}
+
+			if isDetectable = attrs.GetDetectable(); !isDetectable.ValueOrDefault(true) {
+				continue
+			}
+
+			if hasLanguage := attrs.GetLanguage(); hasLanguage.Value() != "" {
+				language := hasLanguage.Value()
+
+				// group languages, such as Pug -> HTML; SCSS -> CSS
+				group := enry.GetLanguageGroup(language)
+				if len(group) != 0 {
+					language = group
 				}
 
-				isGenerated = AttributeToBool(attrs, AttributeLinguistGenerated)
-				if isGenerated.ValueOrDefault(false) {
-					continue
-				}
-
-				isDocumentation = AttributeToBool(attrs, AttributeLinguistDocumentation)
-				if isDocumentation.ValueOrDefault(false) {
-					continue
-				}
-
-				isDetectable = AttributeToBool(attrs, AttributeLinguistDetectable)
-				if !isDetectable.ValueOrDefault(true) {
-					continue
-				}
-
-				hasLanguage := TryReadLanguageAttribute(attrs)
-				if hasLanguage.Value() != "" {
-					language := hasLanguage.Value()
-
-					// group languages, such as Pug -> HTML; SCSS -> CSS
-					group := enry.GetLanguageGroup(language)
-					if len(group) != 0 {
-						language = group
-					}
-
-					// this language will always be added to the size
-					sizes[language] += f.Size()
-					continue
-				}
+				// this language will always be added to the size
+				sizes[language] += f.Size()
+				continue
 			}
 		}
 
@@ -149,7 +147,7 @@ func (repo *Repository) GetLanguageStats(commitID string) (map[string]int64, err
 			if err := writeID(f.ID.String()); err != nil {
 				return nil, err
 			}
-			_, _, size, err := ReadBatchLine(batchReader)
+			_, _, size, err := git.ReadBatchLine(batchReader)
 			if err != nil {
 				log.Debug("Error reading blob: %s Err: %v", f.ID.String(), err)
 				return nil, err
@@ -167,7 +165,7 @@ func (repo *Repository) GetLanguageStats(commitID string) (map[string]int64, err
 				return nil, err
 			}
 			content = contentBuf.Bytes()
-			if err := DiscardFull(batchReader, discard); err != nil {
+			if err := git.DiscardFull(batchReader, discard); err != nil {
 				return nil, err
 			}
 		}
diff --git a/modules/git/repo_language_stats_test.go b/modules/git/languagestats/language_stats_test.go
similarity index 75%
rename from modules/git/repo_language_stats_test.go
rename to modules/git/languagestats/language_stats_test.go
index 12ce958c6e..b908ae6413 100644
--- a/modules/git/repo_language_stats_test.go
+++ b/modules/git/languagestats/language_stats_test.go
@@ -3,12 +3,12 @@
 
 //go:build !gogit
 
-package git
+package languagestats
 
 import (
-	"path/filepath"
 	"testing"
 
+	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/setting"
 
 	"github.com/stretchr/testify/assert"
@@ -17,13 +17,12 @@ import (
 
 func TestRepository_GetLanguageStats(t *testing.T) {
 	setting.AppDataPath = t.TempDir()
-	repoPath := filepath.Join(testReposDir, "language_stats_repo")
-	gitRepo, err := openRepositoryWithDefaultContext(repoPath)
+	repoPath := "../tests/repos/language_stats_repo"
+	gitRepo, err := git.OpenRepository(t.Context(), repoPath)
 	require.NoError(t, err)
-
 	defer gitRepo.Close()
 
-	stats, err := gitRepo.GetLanguageStats("8fee858da5796dfb37704761701bb8e800ad9ef3")
+	stats, err := GetLanguageStats(gitRepo, "8fee858da5796dfb37704761701bb8e800ad9ef3")
 	require.NoError(t, err)
 
 	assert.Equal(t, map[string]int64{
diff --git a/modules/git/languagestats/main_test.go b/modules/git/languagestats/main_test.go
new file mode 100644
index 0000000000..707d268c81
--- /dev/null
+++ b/modules/git/languagestats/main_test.go
@@ -0,0 +1,41 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package languagestats
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"testing"
+
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/util"
+)
+
+func testRun(m *testing.M) error {
+	gitHomePath, err := os.MkdirTemp(os.TempDir(), "git-home")
+	if err != nil {
+		return fmt.Errorf("unable to create temp dir: %w", err)
+	}
+	defer util.RemoveAll(gitHomePath)
+	setting.Git.HomePath = gitHomePath
+
+	if err = git.InitFull(context.Background()); err != nil {
+		return fmt.Errorf("failed to call Init: %w", err)
+	}
+
+	exitCode := m.Run()
+	if exitCode != 0 {
+		return fmt.Errorf("run test failed, ExitCode=%d", exitCode)
+	}
+	return nil
+}
+
+func TestMain(m *testing.M) {
+	if err := testRun(m); err != nil {
+		_, _ = fmt.Fprintf(os.Stderr, "Test failed: %v", err)
+		os.Exit(1)
+	}
+}
diff --git a/modules/git/repo_attribute.go b/modules/git/repo_attribute.go
deleted file mode 100644
index fde42d4730..0000000000
--- a/modules/git/repo_attribute.go
+++ /dev/null
@@ -1,341 +0,0 @@
-// Copyright 2019 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package git
-
-import (
-	"bytes"
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"os"
-	"path/filepath"
-	"time"
-
-	"code.gitea.io/gitea/modules/log"
-)
-
-// CheckAttributeOpts represents the possible options to CheckAttribute
-type CheckAttributeOpts struct {
-	CachedOnly    bool
-	AllAttributes bool
-	Attributes    []string
-	Filenames     []string
-	IndexFile     string
-	WorkTree      string
-}
-
-// CheckAttribute return the Blame object of file
-func (repo *Repository) CheckAttribute(opts CheckAttributeOpts) (map[string]map[string]string, error) {
-	env := []string{}
-
-	if len(opts.IndexFile) > 0 {
-		env = append(env, "GIT_INDEX_FILE="+opts.IndexFile)
-	}
-	if len(opts.WorkTree) > 0 {
-		env = append(env, "GIT_WORK_TREE="+opts.WorkTree)
-	}
-
-	if len(env) > 0 {
-		env = append(os.Environ(), env...)
-	}
-
-	stdOut := new(bytes.Buffer)
-	stdErr := new(bytes.Buffer)
-
-	cmd := NewCommand("check-attr", "-z")
-
-	if opts.AllAttributes {
-		cmd.AddArguments("-a")
-	} else {
-		for _, attribute := range opts.Attributes {
-			if attribute != "" {
-				cmd.AddDynamicArguments(attribute)
-			}
-		}
-	}
-
-	if opts.CachedOnly {
-		cmd.AddArguments("--cached")
-	}
-
-	cmd.AddDashesAndList(opts.Filenames...)
-
-	if err := cmd.Run(repo.Ctx, &RunOpts{
-		Env:    env,
-		Dir:    repo.Path,
-		Stdout: stdOut,
-		Stderr: stdErr,
-	}); err != nil {
-		return nil, fmt.Errorf("failed to run check-attr: %w\n%s\n%s", err, stdOut.String(), stdErr.String())
-	}
-
-	// FIXME: This is incorrect on versions < 1.8.5
-	fields := bytes.Split(stdOut.Bytes(), []byte{'\000'})
-
-	if len(fields)%3 != 1 {
-		return nil, errors.New("wrong number of fields in return from check-attr")
-	}
-
-	name2attribute2info := make(map[string]map[string]string)
-
-	for i := 0; i < (len(fields) / 3); i++ {
-		filename := string(fields[3*i])
-		attribute := string(fields[3*i+1])
-		info := string(fields[3*i+2])
-		attribute2info := name2attribute2info[filename]
-		if attribute2info == nil {
-			attribute2info = make(map[string]string)
-		}
-		attribute2info[attribute] = info
-		name2attribute2info[filename] = attribute2info
-	}
-
-	return name2attribute2info, nil
-}
-
-// CheckAttributeReader provides a reader for check-attribute content that can be long running
-type CheckAttributeReader struct {
-	// params
-	Attributes []string
-	Repo       *Repository
-	IndexFile  string
-	WorkTree   string
-
-	stdinReader io.ReadCloser
-	stdinWriter *os.File
-	stdOut      *nulSeparatedAttributeWriter
-	cmd         *Command
-	env         []string
-	ctx         context.Context
-	cancel      context.CancelFunc
-}
-
-// Init initializes the CheckAttributeReader
-func (c *CheckAttributeReader) Init(ctx context.Context) error {
-	if len(c.Attributes) == 0 {
-		lw := new(nulSeparatedAttributeWriter)
-		lw.attributes = make(chan attributeTriple)
-		lw.closed = make(chan struct{})
-
-		c.stdOut = lw
-		c.stdOut.Close()
-		return errors.New("no provided Attributes to check")
-	}
-
-	c.ctx, c.cancel = context.WithCancel(ctx)
-	c.cmd = NewCommand("check-attr", "--stdin", "-z")
-
-	if len(c.IndexFile) > 0 {
-		c.cmd.AddArguments("--cached")
-		c.env = append(c.env, "GIT_INDEX_FILE="+c.IndexFile)
-	}
-
-	if len(c.WorkTree) > 0 {
-		c.env = append(c.env, "GIT_WORK_TREE="+c.WorkTree)
-	}
-
-	c.env = append(c.env, "GIT_FLUSH=1")
-
-	c.cmd.AddDynamicArguments(c.Attributes...)
-
-	var err error
-
-	c.stdinReader, c.stdinWriter, err = os.Pipe()
-	if err != nil {
-		c.cancel()
-		return err
-	}
-
-	lw := new(nulSeparatedAttributeWriter)
-	lw.attributes = make(chan attributeTriple, 5)
-	lw.closed = make(chan struct{})
-	c.stdOut = lw
-	return nil
-}
-
-func (c *CheckAttributeReader) Run() error {
-	defer func() {
-		_ = c.stdinReader.Close()
-		_ = c.stdOut.Close()
-	}()
-	stdErr := new(bytes.Buffer)
-	err := c.cmd.Run(c.ctx, &RunOpts{
-		Env:    c.env,
-		Dir:    c.Repo.Path,
-		Stdin:  c.stdinReader,
-		Stdout: c.stdOut,
-		Stderr: stdErr,
-	})
-	if err != nil && !IsErrCanceledOrKilled(err) {
-		return fmt.Errorf("failed to run attr-check. Error: %w\nStderr: %s", err, stdErr.String())
-	}
-	return nil
-}
-
-// CheckPath check attr for given path
-func (c *CheckAttributeReader) CheckPath(path string) (rs map[string]string, err error) {
-	defer func() {
-		if err != nil && err != c.ctx.Err() {
-			log.Error("Unexpected error when checking path %s in %s, error: %v", path, filepath.Base(c.Repo.Path), err)
-		}
-	}()
-
-	select {
-	case <-c.ctx.Done():
-		return nil, c.ctx.Err()
-	default:
-	}
-
-	if _, err = c.stdinWriter.Write([]byte(path + "\x00")); err != nil {
-		defer c.Close()
-		return nil, err
-	}
-
-	reportTimeout := func() error {
-		stdOutClosed := false
-		select {
-		case <-c.stdOut.closed:
-			stdOutClosed = true
-		default:
-		}
-		debugMsg := fmt.Sprintf("check path %q in repo %q", path, filepath.Base(c.Repo.Path))
-		debugMsg += fmt.Sprintf(", stdOut: tmp=%q, pos=%d, closed=%v", string(c.stdOut.tmp), c.stdOut.pos, stdOutClosed)
-		if c.cmd.cmd != nil {
-			debugMsg += fmt.Sprintf(", process state: %q", c.cmd.cmd.ProcessState.String())
-		}
-		_ = c.Close()
-		return fmt.Errorf("CheckPath timeout: %s", debugMsg)
-	}
-
-	rs = make(map[string]string)
-	for range c.Attributes {
-		select {
-		case <-time.After(5 * time.Second):
-			// There is a strange "hang" problem in gitdiff.GetDiff -> CheckPath
-			// So add a timeout here to mitigate the problem, and output more logs for debug purpose
-			// In real world, if CheckPath runs long than seconds, it blocks the end user's operation,
-			// and at the moment the CheckPath result is not so important, so we can just ignore it.
-			return nil, reportTimeout()
-		case attr, ok := <-c.stdOut.ReadAttribute():
-			if !ok {
-				return nil, c.ctx.Err()
-			}
-			rs[attr.Attribute] = attr.Value
-		case <-c.ctx.Done():
-			return nil, c.ctx.Err()
-		}
-	}
-	return rs, nil
-}
-
-func (c *CheckAttributeReader) Close() error {
-	c.cancel()
-	err := c.stdinWriter.Close()
-	return err
-}
-
-type attributeTriple struct {
-	Filename  string
-	Attribute string
-	Value     string
-}
-
-type nulSeparatedAttributeWriter struct {
-	tmp        []byte
-	attributes chan attributeTriple
-	closed     chan struct{}
-	working    attributeTriple
-	pos        int
-}
-
-func (wr *nulSeparatedAttributeWriter) Write(p []byte) (n int, err error) {
-	l, read := len(p), 0
-
-	nulIdx := bytes.IndexByte(p, '\x00')
-	for nulIdx >= 0 {
-		wr.tmp = append(wr.tmp, p[:nulIdx]...)
-		switch wr.pos {
-		case 0:
-			wr.working = attributeTriple{
-				Filename: string(wr.tmp),
-			}
-		case 1:
-			wr.working.Attribute = string(wr.tmp)
-		case 2:
-			wr.working.Value = string(wr.tmp)
-		}
-		wr.tmp = wr.tmp[:0]
-		wr.pos++
-		if wr.pos > 2 {
-			wr.attributes <- wr.working
-			wr.pos = 0
-		}
-		read += nulIdx + 1
-		if l > read {
-			p = p[nulIdx+1:]
-			nulIdx = bytes.IndexByte(p, '\x00')
-		} else {
-			return l, nil
-		}
-	}
-	wr.tmp = append(wr.tmp, p...)
-	return l, nil
-}
-
-func (wr *nulSeparatedAttributeWriter) ReadAttribute() <-chan attributeTriple {
-	return wr.attributes
-}
-
-func (wr *nulSeparatedAttributeWriter) Close() error {
-	select {
-	case <-wr.closed:
-		return nil
-	default:
-	}
-	close(wr.attributes)
-	close(wr.closed)
-	return nil
-}
-
-// CheckAttributeReader creates a check attribute reader for the current repository and provided commit ID
-func (repo *Repository) CheckAttributeReader(commitID string) (*CheckAttributeReader, context.CancelFunc) {
-	indexFilename, worktree, deleteTemporaryFile, err := repo.ReadTreeToTemporaryIndex(commitID)
-	if err != nil {
-		return nil, func() {}
-	}
-
-	checker := &CheckAttributeReader{
-		Attributes: []string{
-			AttributeLinguistVendored,
-			AttributeLinguistGenerated,
-			AttributeLinguistDocumentation,
-			AttributeLinguistDetectable,
-			AttributeLinguistLanguage,
-			AttributeGitlabLanguage,
-		},
-		Repo:      repo,
-		IndexFile: indexFilename,
-		WorkTree:  worktree,
-	}
-	ctx, cancel := context.WithCancel(repo.Ctx)
-	if err := checker.Init(ctx); err != nil {
-		log.Error("Unable to open attribute checker for commit %s, error: %v", commitID, err)
-	} else {
-		go func() {
-			err := checker.Run()
-			if err != nil && !IsErrCanceledOrKilled(err) {
-				log.Error("Attribute checker for commit %s exits with error: %v", commitID, err)
-			}
-			cancel()
-		}()
-	}
-	deferrable := func() {
-		_ = checker.Close()
-		cancel()
-		deleteTemporaryFile()
-	}
-
-	return checker, deferrable
-}
diff --git a/modules/git/tests/repos/language_stats_repo/config b/modules/git/tests/repos/language_stats_repo/config
index 515f483629..a4ef456cbc 100644
--- a/modules/git/tests/repos/language_stats_repo/config
+++ b/modules/git/tests/repos/language_stats_repo/config
@@ -1,5 +1,5 @@
 [core]
 	repositoryformatversion = 0
 	filemode = true
-	bare = false
+	bare = true
 	logallrefupdates = true
diff --git a/modules/git/tests/repos/repo3_notes/config b/modules/git/tests/repos/repo3_notes/config
index d545cdabdb..5ed22e23d1 100644
--- a/modules/git/tests/repos/repo3_notes/config
+++ b/modules/git/tests/repos/repo3_notes/config
@@ -1,7 +1,7 @@
 [core]
 	repositoryformatversion = 0
 	filemode = false
-	bare = false
+	bare = true
 	logallrefupdates = true
 	symlinks = false
 	ignorecase = true
diff --git a/modules/git/tests/repos/repo4_commitsbetween/config b/modules/git/tests/repos/repo4_commitsbetween/config
index d545cdabdb..5ed22e23d1 100644
--- a/modules/git/tests/repos/repo4_commitsbetween/config
+++ b/modules/git/tests/repos/repo4_commitsbetween/config
@@ -1,7 +1,7 @@
 [core]
 	repositoryformatversion = 0
 	filemode = false
-	bare = false
+	bare = true
 	logallrefupdates = true
 	symlinks = false
 	ignorecase = true
diff --git a/modules/indexer/stats/db.go b/modules/indexer/stats/db.go
index 067a6f609b..199d493e97 100644
--- a/modules/indexer/stats/db.go
+++ b/modules/indexer/stats/db.go
@@ -8,6 +8,7 @@ import (
 
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/git/languagestats"
 	"code.gitea.io/gitea/modules/gitrepo"
 	"code.gitea.io/gitea/modules/graceful"
 	"code.gitea.io/gitea/modules/log"
@@ -62,7 +63,7 @@ func (db *DBIndexer) Index(id int64) error {
 	}
 
 	// Calculate and save language statistics to database
-	stats, err := gitRepo.GetLanguageStats(commitID)
+	stats, err := languagestats.GetLanguageStats(gitRepo, commitID)
 	if err != nil {
 		if !setting.IsInTesting {
 			log.Error("Unable to get language stats for ID %s for default branch %s in %s. Error: %v", commitID, repo.DefaultBranch, repo.FullName(), err)
diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go
index efd85b9452..e125267524 100644
--- a/routers/web/repo/blame.go
+++ b/routers/web/repo/blame.go
@@ -15,13 +15,13 @@ import (
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/charset"
 	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/git/languagestats"
 	"code.gitea.io/gitea/modules/highlight"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/services/context"
-	files_service "code.gitea.io/gitea/services/repository/files"
 )
 
 type blameRow struct {
@@ -234,7 +234,7 @@ func processBlameParts(ctx *context.Context, blameParts []*git.BlamePart) map[st
 func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames map[string]*user_model.UserCommit) {
 	repoLink := ctx.Repo.RepoLink
 
-	language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath)
+	language, err := languagestats.GetFileLanguage(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath)
 	if err != nil {
 		log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err)
 	}
diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go
index efda9bda58..a065620b2b 100644
--- a/routers/web/repo/setting/lfs.go
+++ b/routers/web/repo/setting/lfs.go
@@ -18,6 +18,7 @@ import (
 	"code.gitea.io/gitea/modules/charset"
 	"code.gitea.io/gitea/modules/container"
 	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/git/attribute"
 	"code.gitea.io/gitea/modules/git/pipeline"
 	"code.gitea.io/gitea/modules/lfs"
 	"code.gitea.io/gitea/modules/log"
@@ -134,39 +135,24 @@ func LFSLocks(ctx *context.Context) {
 	}
 	defer gitRepo.Close()
 
-	filenames := make([]string, len(lfsLocks))
-
-	for i, lock := range lfsLocks {
-		filenames[i] = lock.Path
-	}
-
-	if err := gitRepo.ReadTreeToIndex(ctx.Repo.Repository.DefaultBranch); err != nil {
-		log.Error("Unable to read the default branch to the index: %s (%v)", ctx.Repo.Repository.DefaultBranch, err)
-		ctx.ServerError("LFSLocks", fmt.Errorf("unable to read the default branch to the index: %s (%w)", ctx.Repo.Repository.DefaultBranch, err))
-		return
-	}
-
-	name2attribute2info, err := gitRepo.CheckAttribute(git.CheckAttributeOpts{
-		Attributes: []string{"lockable"},
-		Filenames:  filenames,
-		CachedOnly: true,
-	})
+	checker, err := attribute.NewBatchChecker(gitRepo, ctx.Repo.Repository.DefaultBranch, []string{attribute.Lockable})
 	if err != nil {
 		log.Error("Unable to check attributes in %s (%v)", tmpBasePath, err)
 		ctx.ServerError("LFSLocks", err)
 		return
 	}
+	defer checker.Close()
 
 	lockables := make([]bool, len(lfsLocks))
+	filenames := make([]string, len(lfsLocks))
 	for i, lock := range lfsLocks {
-		attribute2info, has := name2attribute2info[lock.Path]
-		if !has {
+		filenames[i] = lock.Path
+		attrs, err := checker.CheckPath(lock.Path)
+		if err != nil {
+			log.Error("Unable to check attributes in %s: %s (%v)", tmpBasePath, lock.Path, err)
 			continue
 		}
-		if attribute2info["lockable"] != "set" {
-			continue
-		}
-		lockables[i] = true
+		lockables[i] = attrs.Get(attribute.Lockable).ToBool().Value()
 	}
 	ctx.Data["Lockables"] = lockables
 
diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go
index 12083a1ced..ff0e1b4d54 100644
--- a/routers/web/repo/view_file.go
+++ b/routers/web/repo/view_file.go
@@ -18,6 +18,7 @@ import (
 	"code.gitea.io/gitea/modules/actions"
 	"code.gitea.io/gitea/modules/charset"
 	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/git/attribute"
 	"code.gitea.io/gitea/modules/highlight"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/markup"
@@ -25,7 +26,6 @@ import (
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/services/context"
 	issue_service "code.gitea.io/gitea/services/issue"
-	files_service "code.gitea.io/gitea/services/repository/files"
 
 	"github.com/nektos/act/pkg/model"
 )
@@ -147,6 +147,23 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
 		ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_non_text_files")
 	}
 
+	// read all needed attributes which will be used later
+	// there should be no performance different between reading 2 or 4 here
+	attrsMap, err := attribute.CheckAttributes(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, attribute.CheckAttributeOpts{
+		Filenames:  []string{ctx.Repo.TreePath},
+		Attributes: []string{attribute.LinguistGenerated, attribute.LinguistVendored, attribute.LinguistLanguage, attribute.GitlabLanguage},
+	})
+	if err != nil {
+		ctx.ServerError("attribute.CheckAttributes", err)
+		return
+	}
+	attrs := attrsMap[ctx.Repo.TreePath]
+	if attrs == nil {
+		// this case shouldn't happen, just in case.
+		setting.PanicInDevOrTesting("no attributes found for %s", ctx.Repo.TreePath)
+		attrs = attribute.NewAttributes()
+	}
+
 	switch {
 	case isRepresentableAsText:
 		if fInfo.fileSize >= setting.UI.MaxDisplayFileSize {
@@ -209,11 +226,7 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
 				ctx.Data["NumLines"] = bytes.Count(buf, []byte{'\n'}) + 1
 			}
 
-			language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath)
-			if err != nil {
-				log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err)
-			}
-
+			language := attrs.GetLanguage().Value()
 			fileContent, lexerName, err := highlight.File(blob.Name(), language, buf)
 			ctx.Data["LexerName"] = lexerName
 			if err != nil {
@@ -283,17 +296,7 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
 		}
 	}
 
-	if ctx.Repo.GitRepo != nil {
-		checker, deferable := ctx.Repo.GitRepo.CheckAttributeReader(ctx.Repo.CommitID)
-		if checker != nil {
-			defer deferable()
-			attrs, err := checker.CheckPath(ctx.Repo.TreePath)
-			if err == nil {
-				ctx.Data["IsVendored"] = git.AttributeToBool(attrs, git.AttributeLinguistVendored).Value()
-				ctx.Data["IsGenerated"] = git.AttributeToBool(attrs, git.AttributeLinguistGenerated).Value()
-			}
-		}
-	}
+	ctx.Data["IsVendored"], ctx.Data["IsGenerated"] = attrs.GetVendored().Value(), attrs.GetGenerated().Value()
 
 	if fInfo.st.IsImage() && !fInfo.st.IsSvgImage() {
 		img, _, err := image.DecodeConfig(bytes.NewReader(buf))
diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go
index b9781cf8d0..9ee86d9dfc 100644
--- a/services/gitdiff/gitdiff.go
+++ b/services/gitdiff/gitdiff.go
@@ -25,6 +25,7 @@ import (
 	"code.gitea.io/gitea/modules/analyze"
 	"code.gitea.io/gitea/modules/charset"
 	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/git/attribute"
 	"code.gitea.io/gitea/modules/highlight"
 	"code.gitea.io/gitea/modules/lfs"
 	"code.gitea.io/gitea/modules/log"
@@ -1237,24 +1238,21 @@ func GetDiffForRender(ctx context.Context, gitRepo *git.Repository, opts *DiffOp
 		return nil, err
 	}
 
-	checker, deferrable := gitRepo.CheckAttributeReader(opts.AfterCommitID)
-	defer deferrable()
+	checker, err := attribute.NewBatchChecker(gitRepo, opts.AfterCommitID, []string{attribute.LinguistVendored, attribute.LinguistGenerated, attribute.LinguistLanguage, attribute.GitlabLanguage})
+	if err != nil {
+		return nil, err
+	}
+	defer checker.Close()
 
 	for _, diffFile := range diff.Files {
 		isVendored := optional.None[bool]()
 		isGenerated := optional.None[bool]()
-		if checker != nil {
-			attrs, err := checker.CheckPath(diffFile.Name)
-			if err == nil {
-				isVendored = git.AttributeToBool(attrs, git.AttributeLinguistVendored)
-				isGenerated = git.AttributeToBool(attrs, git.AttributeLinguistGenerated)
-
-				language := git.TryReadLanguageAttribute(attrs)
-				if language.Has() {
-					diffFile.Language = language.Value()
-				}
-			} else {
-				checker = nil // CheckPath fails, it's not impossible to "check" anymore
+		attrs, err := checker.CheckPath(diffFile.Name)
+		if err == nil {
+			isVendored, isGenerated = attrs.GetVendored(), attrs.GetGenerated()
+			language := attrs.GetLanguage()
+			if language.Has() {
+				diffFile.Language = language.Value()
 			}
 		}
 
diff --git a/services/markup/renderhelper_codepreview.go b/services/markup/renderhelper_codepreview.go
index 28d1120984..fa1eb824a2 100644
--- a/services/markup/renderhelper_codepreview.go
+++ b/services/markup/renderhelper_codepreview.go
@@ -14,13 +14,13 @@ import (
 	"code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unit"
 	"code.gitea.io/gitea/modules/charset"
+	"code.gitea.io/gitea/modules/git/languagestats"
 	"code.gitea.io/gitea/modules/gitrepo"
 	"code.gitea.io/gitea/modules/indexer/code"
 	"code.gitea.io/gitea/modules/markup"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/util"
 	gitea_context "code.gitea.io/gitea/services/context"
-	"code.gitea.io/gitea/services/repository/files"
 )
 
 func renderRepoFileCodePreview(ctx context.Context, opts markup.RenderCodePreviewOptions) (template.HTML, error) {
@@ -61,7 +61,7 @@ func renderRepoFileCodePreview(ctx context.Context, opts markup.RenderCodePrevie
 		return "", err
 	}
 
-	language, _ := files.TryGetContentLanguage(gitRepo, opts.CommitID, opts.FilePath)
+	language, _ := languagestats.GetFileLanguage(ctx, gitRepo, opts.CommitID, opts.FilePath)
 	blob, err := commit.GetBlobByPath(opts.FilePath)
 	if err != nil {
 		return "", err
diff --git a/services/repository/files/content.go b/services/repository/files/content.go
index e23cd1abce..0327e7f2ce 100644
--- a/services/repository/files/content.go
+++ b/services/repository/files/content.go
@@ -277,28 +277,3 @@ func GetBlobBySHA(ctx context.Context, repo *repo_model.Repository, gitRepo *git
 		Content:  content,
 	}, nil
 }
-
-// TryGetContentLanguage tries to get the (linguist) language of the file content
-func TryGetContentLanguage(gitRepo *git.Repository, commitID, treePath string) (string, error) {
-	indexFilename, worktree, deleteTemporaryFile, err := gitRepo.ReadTreeToTemporaryIndex(commitID)
-	if err != nil {
-		return "", err
-	}
-
-	defer deleteTemporaryFile()
-
-	filename2attribute2info, err := gitRepo.CheckAttribute(git.CheckAttributeOpts{
-		CachedOnly: true,
-		Attributes: []string{git.AttributeLinguistLanguage, git.AttributeGitlabLanguage},
-		Filenames:  []string{treePath},
-		IndexFile:  indexFilename,
-		WorkTree:   worktree,
-	})
-	if err != nil {
-		return "", err
-	}
-
-	language := git.TryReadLanguageAttribute(filename2attribute2info[treePath])
-
-	return language.Value(), nil
-}
diff --git a/services/repository/files/update.go b/services/repository/files/update.go
index 3f6255e77a..75ede4976f 100644
--- a/services/repository/files/update.go
+++ b/services/repository/files/update.go
@@ -15,6 +15,7 @@ import (
 	repo_model "code.gitea.io/gitea/models/repo"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/git/attribute"
 	"code.gitea.io/gitea/modules/gitrepo"
 	"code.gitea.io/gitea/modules/lfs"
 	"code.gitea.io/gitea/modules/log"
@@ -488,16 +489,15 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file
 	var lfsMetaObject *git_model.LFSMetaObject
 	if setting.LFS.StartServer && hasOldBranch {
 		// Check there is no way this can return multiple infos
-		filename2attribute2info, err := t.gitRepo.CheckAttribute(git.CheckAttributeOpts{
-			Attributes: []string{"filter"},
+		attributesMap, err := attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{
+			Attributes: []string{attribute.Filter},
 			Filenames:  []string{file.Options.treePath},
-			CachedOnly: true,
 		})
 		if err != nil {
 			return err
 		}
 
-		if filename2attribute2info[file.Options.treePath] != nil && filename2attribute2info[file.Options.treePath]["filter"] == "lfs" {
+		if attributesMap[file.Options.treePath] != nil && attributesMap[file.Options.treePath].Get(attribute.Filter).ToString().Value() == "lfs" {
 			// OK so we are supposed to LFS this data!
 			pointer, err := lfs.GeneratePointer(treeObjectContentReader)
 			if err != nil {
diff --git a/services/repository/files/upload.go b/services/repository/files/upload.go
index 2e4ed1744e..f348cb68ab 100644
--- a/services/repository/files/upload.go
+++ b/services/repository/files/upload.go
@@ -14,6 +14,7 @@ import (
 	repo_model "code.gitea.io/gitea/models/repo"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/git/attribute"
 	"code.gitea.io/gitea/modules/lfs"
 	"code.gitea.io/gitea/modules/setting"
 )
@@ -105,12 +106,11 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
 		}
 	}
 
-	var filename2attribute2info map[string]map[string]string
+	var attributesMap map[string]*attribute.Attributes
 	if setting.LFS.StartServer {
-		filename2attribute2info, err = t.gitRepo.CheckAttribute(git.CheckAttributeOpts{
-			Attributes: []string{"filter"},
+		attributesMap, err = attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{
+			Attributes: []string{attribute.Filter},
 			Filenames:  names,
-			CachedOnly: true,
 		})
 		if err != nil {
 			return err
@@ -119,7 +119,7 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
 
 	// Copy uploaded files into repository.
 	for i := range infos {
-		if err := copyUploadedLFSFileIntoRepository(ctx, &infos[i], filename2attribute2info, t, opts.TreePath); err != nil {
+		if err := copyUploadedLFSFileIntoRepository(ctx, &infos[i], attributesMap, t, opts.TreePath); err != nil {
 			return err
 		}
 	}
@@ -176,7 +176,7 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
 	return repo_model.DeleteUploads(ctx, uploads...)
 }
 
-func copyUploadedLFSFileIntoRepository(ctx context.Context, info *uploadInfo, filename2attribute2info map[string]map[string]string, t *TemporaryUploadRepository, treePath string) error {
+func copyUploadedLFSFileIntoRepository(ctx context.Context, info *uploadInfo, attributesMap map[string]*attribute.Attributes, t *TemporaryUploadRepository, treePath string) error {
 	file, err := os.Open(info.upload.LocalPath())
 	if err != nil {
 		return err
@@ -184,7 +184,7 @@ func copyUploadedLFSFileIntoRepository(ctx context.Context, info *uploadInfo, fi
 	defer file.Close()
 
 	var objectHash string
-	if setting.LFS.StartServer && filename2attribute2info[info.upload.Name] != nil && filename2attribute2info[info.upload.Name]["filter"] == "lfs" {
+	if setting.LFS.StartServer && attributesMap[info.upload.Name] != nil && attributesMap[info.upload.Name].Get(attribute.Filter).ToString().Value() == "lfs" {
 		// Handle LFS
 		// FIXME: Inefficient! this should probably happen in models.Upload
 		pointer, err := lfs.GeneratePointer(file)

From 7a587bc2d3c1e03fa36ffb9b7b962b330bf02345 Mon Sep 17 00:00:00 2001
From: GiteaBot <teabot@gitea.io>
Date: Sat, 12 Apr 2025 00:33:41 +0000
Subject: [PATCH 13/14] [skip ci] Updated translations via Crowdin

---
 options/locale/locale_fr-FR.ini | 14 ++++++++++++++
 options/locale/locale_pt-PT.ini |  2 ++
 options/locale/locale_zh-CN.ini | 19 ++++++++++++++++++-
 3 files changed, 34 insertions(+), 1 deletion(-)

diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini
index 6e0f0aab46..466a89bdfa 100644
--- a/options/locale/locale_fr-FR.ini
+++ b/options/locale/locale_fr-FR.ini
@@ -730,6 +730,8 @@ public_profile=Profil public
 biography_placeholder=Parlez-nous un peu de vous ! (Vous pouvez utiliser Markdown)
 location_placeholder=Partagez votre position approximative avec d'autres personnes
 profile_desc=Contrôlez comment votre profil est affiché aux autres utilisateurs. Votre adresse courriel principale sera utilisée pour les notifications, la récupération de mot de passe et les opérations Git basées sur le Web.
+password_username_disabled=Vous n’êtes pas autorisé à modifier votre nom d’utilisateur. Veuillez contacter l’administrateur de votre site pour plus de détails.
+password_full_name_disabled=Vous n’êtes pas autorisé à modifier votre nom complet. Veuillez contacter l’administrateur du site pour plus de détails.
 full_name=Nom complet
 website=Site Web
 location=Localisation
@@ -924,6 +926,9 @@ permission_not_set=Non défini
 permission_no_access=Aucun accès
 permission_read=Lecture
 permission_write=Lecture et écriture
+permission_anonymous_read=Consultation anonyme
+permission_everyone_read=Consultation collective
+permission_everyone_write=Participation collective
 access_token_desc=Les autorisations des jetons sélectionnées se limitent aux <a %s>routes API</a> correspondantes. Lisez la <a %s>documentation</a> pour plus d’informations.
 at_least_one_permission=Vous devez sélectionner au moins une permission pour créer un jeton.
 permissions_list=Autorisations :
@@ -1136,6 +1141,7 @@ transfer.no_permission_to_reject=Vous n’êtes pas autorisé à rejeter ce tran
 
 desc.private=Privé
 desc.public=Publique
+desc.public_access=Accès public
 desc.template=Modèle
 desc.internal=Interne
 desc.archived=Archivé
@@ -1648,6 +1654,8 @@ issues.label_archived_filter=Afficher les labels archivés
 issues.label_archive_tooltip=Les labels archivés sont par défaut exclus des suggestions lors de la recherche par label.
 issues.label_exclusive_desc=Remarque : pour rendre des labels mutuellement exclusifs, préfixez leur nom d’une portée au format <code>portée/label</code>.
 issues.label_exclusive_warning=Tout label d'une portée en conflit sera retiré lors de la modification des labels d’un ticket ou d’une demande d’ajout.
+issues.label_exclusive_order=Ordre de tri
+issues.label_exclusive_order_tooltip=Les labels exclusifs partageant la même portée seront triées selon cet ordre numérique.
 issues.label_count=%d labels
 issues.label_open_issues=%d tickets ouverts
 issues.label_edit=Éditer
@@ -2130,6 +2138,12 @@ contributors.contribution_type.deletions=Suppressions
 settings=Paramètres
 settings.desc=Les paramètres sont l'endroit où gérer les options du dépôt
 settings.options=Dépôt
+settings.public_access=Accès public
+settings.public_access_desc=Configurer les permissions des visiteurs publics remplaçant les valeurs par défaut de ce dépôt.
+settings.public_access.docs.not_set=Non défini : ne donne aucune permission supplémentaire. Les règles du dépôt et les permissions des utilisateurs font foi.
+settings.public_access.docs.anonymous_read=Lecture anonyme : les utilisateurs qui ne sont pas connectés peuvent consulter la ressource.
+settings.public_access.docs.everyone_read=Consultation publique : tous les utilisateurs connectés peuvent consulter la ressource. Mettre les tickets et demandes d’ajouts en accès public signifie que les utilisateurs connectés peuvent en créer.
+settings.public_access.docs.everyone_write=Participation publique : tous les utilisateurs connectés ont la permission d’écrire sur la ressource. Seule le Wiki supporte cette autorisation.
 settings.collaboration=Collaborateurs
 settings.collaboration.admin=Administrateur
 settings.collaboration.write=Écriture
diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini
index fb91a76f02..03ffb1df14 100644
--- a/options/locale/locale_pt-PT.ini
+++ b/options/locale/locale_pt-PT.ini
@@ -1654,6 +1654,8 @@ issues.label_archived_filter=Mostrar rótulos arquivados
 issues.label_archive_tooltip=Os rótulos arquivados são, por norma, excluídos das sugestões ao pesquisar por rótulo.
 issues.label_exclusive_desc=Nomeie o rótulo <code>âmbito/item</code> para torná-lo mutuamente exclusivo com outros rótulos do <code>âmbito/</code>.
 issues.label_exclusive_warning=Quaisquer rótulos com âmbito que estejam em conflito irão ser removidos ao editar os rótulos de uma questão ou de um pedido de integração.
+issues.label_exclusive_order=Ordenação
+issues.label_exclusive_order_tooltip=Rótulos exclusivos no mesmo âmbito serão ordenados de acordo com esta ordem numérica.
 issues.label_count=%d rótulos
 issues.label_open_issues=%d questões abertas
 issues.label_edit=Editar
diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini
index 1540a97f4c..4d2ff7fb78 100644
--- a/options/locale/locale_zh-CN.ini
+++ b/options/locale/locale_zh-CN.ini
@@ -927,6 +927,8 @@ permission_no_access=无访问权限
 permission_read=可读
 permission_write=读写
 permission_anonymous_read=匿名读
+permission_everyone_read=所有人可读
+permission_everyone_write=所有人可写
 access_token_desc=所选令牌权限仅限于对应的 <a %s>API</a> 路由的授权。阅读 <a %s>文档</a> 以获取更多信息。
 at_least_one_permission=你需要选择至少一个权限才能创建令牌
 permissions_list=权限:
@@ -1646,12 +1648,14 @@ issues.save=保存
 issues.label_title=标签名称
 issues.label_description=标签描述
 issues.label_color=标签颜色
-issues.label_exclusive=独有
+issues.label_exclusive=互斥标签
 issues.label_archive=归档标签
 issues.label_archived_filter=显示存档标签
 issues.label_archive_tooltip=在标签搜索时,默认情况下存档标签将被排除在外。
 issues.label_exclusive_desc=命名标签为 <code>scope/item</code> 以使其与其他以 <code>scope/</code> 开头的标签互斥。
 issues.label_exclusive_warning=在编辑工单或合并请求的标签时,任何冲突的范围标签都将被删除。
+issues.label_exclusive_order=排序顺序
+issues.label_exclusive_order_tooltip=在同一个范围内的互斥标签将按照这个数字进行排序
 issues.label_count=%d 个标签
 issues.label_open_issues=%d 个开启的工单
 issues.label_edit=编辑
@@ -1711,8 +1715,11 @@ issues.start_tracking_history=`开始工作 %s`
 issues.tracker_auto_close=当此工单关闭时,自动停止计时器
 issues.tracking_already_started=`你已经开始对 <a href="%s">另一个工单</a> 进行时间跟踪!`
 issues.stop_tracking=停止计时器
+issues.stop_tracking_history=工作 <b>%[1]s</b> 于 %[2]s 停止
+issues.cancel_tracking=取消
 issues.cancel_tracking_history=`取消时间跟踪 %s`
 issues.del_time=删除此时间跟踪日志
+issues.add_time_history=已于 %[2]s 添加计时 <b>%[1]</b>
 issues.del_time_history=`已删除时间 %s`
 issues.add_time_manually=手动添加时间
 issues.add_time_hours=小时
@@ -1971,6 +1978,7 @@ pulls.upstream_diverging_prompt_behind_1=该分支落后于 %[2]s %[1]d 个提
 pulls.upstream_diverging_prompt_behind_n=该分支落后于 %[2]s %[1]d 个提交
 pulls.upstream_diverging_prompt_base_newer=基础分支 %s 有新的更改
 pulls.upstream_diverging_merge=同步派生
+pulls.upstream_diverging_merge_confirm=要将 %[1]s 合并到 %[2]s 吗?
 
 pull.deleted_branch=(已删除): %s
 pull.agit_documentation=查看有关 AGit 的文档
@@ -2131,6 +2139,11 @@ settings=设置
 settings.desc=设置是你可以管理仓库设置的地方
 settings.options=仓库
 settings.public_access=公开访问
+settings.public_access_desc=配置公共访客访问权限以覆盖此存储库的默认值。
+settings.public_access.docs.not_set=未设置:没有额外的公共访问权限。访客权限遵循存储库的可见性和成员权限。
+settings.public_access.docs.anonymous_read=匿名可读:未登录的用户可以通过读取权限访问单元。
+settings.public_access.docs.everyone_read=所有人可读:所有登录用户都可以通过读取权限访问单元。读取问题/拉取请求单元的权限也意味着用户可以创建新的问题/拉取请求。
+settings.public_access.docs.everyone_write=所有人可写:所有登录用户都有写入权限。只有百科支持此权限。
 settings.collaboration=协作者
 settings.collaboration.admin=管理员
 settings.collaboration.write=可写权限
@@ -2385,6 +2398,7 @@ settings.event_pull_request_approvals=合并请求批准
 settings.event_pull_request_merge=合并请求合并
 settings.event_header_workflow=工作流程事件
 settings.event_workflow_job=工作流任务
+settings.event_workflow_job_desc=Gitea Actions 工作流队列中、等待中、正在进行或已完成任务。
 settings.event_package=软件包
 settings.event_package_desc=软件包已在仓库中被创建或删除。
 settings.branch_filter=分支过滤
@@ -2900,6 +2914,9 @@ worktime.date_range_start=起始日期
 worktime.date_range_end=结束日期
 worktime.query=查询
 worktime.time=时间
+worktime.by_repositories=按仓库
+worktime.by_milestones=按里程碑
+worktime.by_members=按成员
 
 [admin]
 maintenance=维护

From 5015992db578659a47b9a0949f1773ebac2b2b4b Mon Sep 17 00:00:00 2001
From: Kerwin Bryant <kerwin612@qq.com>
Date: Sat, 12 Apr 2025 11:34:42 +0800
Subject: [PATCH 14/14] Update milestones.tmpl (#34184)

---
 templates/user/dashboard/milestones.tmpl | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/user/dashboard/milestones.tmpl b/templates/user/dashboard/milestones.tmpl
index d0fe0abbc9..15b1b9e30b 100644
--- a/templates/user/dashboard/milestones.tmpl
+++ b/templates/user/dashboard/milestones.tmpl
@@ -116,7 +116,7 @@
 											{{ctx.Locale.Tr "repo.milestones.closed" $closedDate}}
 										{{else}}
 											{{if .DeadlineString}}
-												<span{{if .IsOverdue}} class="text red"{{end}}>
+												<span class="flex-text-inline {{if .IsOverdue}}text red{{end}}">
 													{{svg "octicon-calendar" 14}}
 													{{DateUtils.AbsoluteShort (.DeadlineString|DateUtils.ParseLegacy)}}
 												</span>