mirror of
https://github.com/go-gitea/gitea.git
synced 2025-04-15 05:37:46 +00:00
Merge branch 'main' into lunny/move_update_repo
This commit is contained in:
commit
896a3a6bca
24
.github/workflows/release-nightly.yml
vendored
24
.github/workflows/release-nightly.yml
vendored
@ -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
|
||||
|
24
.github/workflows/release-tag-rc.yml
vendored
24
.github/workflows/release-tag-rc.yml
vendored
@ -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:
|
||||
|
24
.github/workflows/release-tag-version.yml
vendored
24
.github/workflows/release-tag-version.yml
vendored
@ -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:
|
||||
|
@ -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 {
|
||||
|
@ -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 =
|
||||
;;
|
||||
@ -2473,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
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@ -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`)
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
16
models/migrations/v1_24/v319.go
Normal file
16
models/migrations/v1_24/v319.go
Normal file
@ -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))
|
||||
}
|
@ -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.
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
8
modules/cache/context.go
vendored
8
modules/cache/context.go
vendored
@ -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
|
||||
}
|
||||
|
3
modules/cache/context_test.go
vendored
3
modules/cache/context_test.go
vendored
@ -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)
|
||||
|
12
modules/cachegroup/cachegroup.go
Normal file
12
modules/cachegroup/cachegroup.go
Normal file
@ -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"
|
||||
)
|
@ -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]()
|
||||
}
|
114
modules/git/attribute/attribute.go
Normal file
114
modules/git/attribute/attribute.go
Normal file
@ -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
|
||||
}
|
37
modules/git/attribute/attribute_test.go
Normal file
37
modules/git/attribute/attribute_test.go
Normal file
@ -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())
|
||||
}
|
216
modules/git/attribute/batch.go
Normal file
216
modules/git/attribute/batch.go
Normal file
@ -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
|
||||
}
|
@ -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)
|
||||
})
|
||||
}
|
96
modules/git/attribute/checker.go
Normal file
96
modules/git/attribute/checker.go
Normal file
@ -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
|
||||
}
|
74
modules/git/attribute/checker_test.go
Normal file
74
modules/git/attribute/checker_test.go
Normal file
@ -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"])
|
||||
})
|
||||
}
|
41
modules/git/attribute/main_test.go
Normal file
41
modules/git/attribute/main_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -3,24 +3,26 @@
|
||||
|
||||
//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"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepository_GetLanguageStats(t *testing.T) {
|
||||
repoPath := filepath.Join(testReposDir, "language_stats_repo")
|
||||
gitRepo, err := openRepositoryWithDefaultContext(repoPath)
|
||||
setting.AppDataPath = t.TempDir()
|
||||
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{
|
41
modules/git/languagestats/main_test.go
Normal file
41
modules/git/languagestats/main_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -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})
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
|
@ -1,5 +1,5 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
bare = true
|
||||
logallrefupdates = true
|
||||
|
@ -1,7 +1,7 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = false
|
||||
bare = false
|
||||
bare = true
|
||||
logallrefupdates = true
|
||||
symlinks = false
|
||||
ignorecase = true
|
||||
|
@ -1,7 +1,7 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = false
|
||||
bare = false
|
||||
bare = true
|
||||
logallrefupdates = true
|
||||
symlinks = false
|
||||
ignorecase = true
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
11
modules/markup/external/external.go
vendored
11
modules/markup/external/external.go
vendored
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
112
modules/tempdir/tempdir.go
Normal file
112
modules/tempdir/tempdir.go
Normal file
@ -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)
|
||||
}
|
75
modules/tempdir/tempdir_test.go
Normal file
75
modules/tempdir/tempdir_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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
|
||||
|
@ -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íčů
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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=Ελάχιστα Μεγέθη Κλειδιών
|
||||
|
||||
|
@ -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
|
||||
@ -3287,8 +3289,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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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=حداقل اندازهی کلید ها
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
@ -3274,8 +3288,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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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=最小キー長
|
||||
|
||||
|
@ -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=최소 키 사이즈
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
@ -3286,8 +3288,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
|
||||
|
||||
|
@ -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=Минимальные размеры ключа
|
||||
|
||||
|
@ -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=අවම යතුරෙහි ප්රමාණ
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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ı
|
||||
|
||||
|
@ -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=Мінімальні розміри ключів
|
||||
|
||||
|
@ -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,9 @@ permission_not_set=未设置
|
||||
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=权限:
|
||||
@ -1014,6 +1031,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 +1130,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 +1141,7 @@ transfer.no_permission_to_reject=您没有权限拒绝此转让。
|
||||
|
||||
desc.private=私有库
|
||||
desc.public=公开
|
||||
desc.public_access=公开访问
|
||||
desc.template=模板
|
||||
desc.internal=内部
|
||||
desc.archived=已存档
|
||||
@ -1227,6 +1249,7 @@ create_new_repo_command=从命令行创建一个新的仓库
|
||||
push_exist_repo=从命令行推送已经创建的仓库
|
||||
empty_message=这个家伙很懒,什么都没有推送。
|
||||
broken_message=无法读取此仓库下的 Git 数据。 联系此实例的管理员或删除此仓库。
|
||||
no_branch=该仓库没有任何分支。
|
||||
|
||||
code=代码
|
||||
code.desc=查看源码、文件、提交和分支。
|
||||
@ -1339,6 +1362,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 +1412,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 +1424,7 @@ commit.cherry-pick-content=选择 cherry-pick 的目标分支:
|
||||
|
||||
commitstatus.error=错误
|
||||
commitstatus.failure=失败
|
||||
commitstatus.pending=待定
|
||||
commitstatus.pending=队列
|
||||
commitstatus.success=成功
|
||||
|
||||
ext_issues=访问外部工单
|
||||
@ -1433,7 +1459,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 +1473,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 +1548,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 +1625,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=该用户已被邀请在仓库上进行协作。
|
||||
@ -1618,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=编辑
|
||||
@ -1676,13 +1708,18 @@ 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.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=小时
|
||||
@ -1912,6 +1949,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=合并
|
||||
@ -1940,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 的文档
|
||||
@ -2099,6 +2138,12 @@ contributors.contribution_type.deletions=删除
|
||||
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=可写权限
|
||||
@ -2312,6 +2357,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 +2396,9 @@ 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_workflow_job_desc=Gitea Actions 工作流队列中、等待中、正在进行或已完成任务。
|
||||
settings.event_package=软件包
|
||||
settings.event_package_desc=软件包已在仓库中被创建或删除。
|
||||
settings.branch_filter=分支过滤
|
||||
@ -2611,6 +2661,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 +2734,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 +2749,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 +2905,18 @@ 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=时间
|
||||
worktime.by_repositories=按仓库
|
||||
worktime.by_milestones=按里程碑
|
||||
worktime.by_members=按成员
|
||||
|
||||
[admin]
|
||||
maintenance=维护
|
||||
@ -3221,8 +3288,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=密钥最小长度限制
|
||||
|
||||
@ -3345,6 +3410,7 @@ monitor.previous=上次执行时间
|
||||
monitor.execute_times=执行次数
|
||||
monitor.process=运行中进程
|
||||
monitor.stacktrace=调用栈踪迹
|
||||
monitor.trace=追踪
|
||||
monitor.performance_logs=性能日志
|
||||
monitor.processes_count=%d 个进程
|
||||
monitor.download_diagnosis_report=下载诊断报告
|
||||
@ -3520,10 +3586,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=仓库
|
||||
@ -3536,7 +3603,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=依赖
|
||||
@ -3550,16 +3617,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=组件
|
||||
@ -3590,7 +3658,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=此软件包可在多个组中使用。
|
||||
@ -3656,6 +3724,7 @@ creation=添加密钥
|
||||
creation.description=组织描述
|
||||
creation.name_placeholder=不区分大小写,字母数字或下划线不能以GITEA_ 或 GITHUB_ 开头。
|
||||
creation.value_placeholder=输入任何内容,开头和结尾的空白都会被省略
|
||||
creation.description_placeholder=输入简短描述(可选)。
|
||||
creation.success=您的密钥 '%s' 添加成功。
|
||||
creation.failed=添加密钥失败。
|
||||
deletion=删除密钥
|
||||
@ -3686,7 +3755,7 @@ runners.status=状态
|
||||
runners.id=ID
|
||||
runners.name=名称
|
||||
runners.owner_type=类型
|
||||
runners.description=组织描述
|
||||
runners.description=描述
|
||||
runners.labels=标签
|
||||
runners.last_online=上次在线时间
|
||||
runners.runner_title=Runner
|
||||
@ -3709,10 +3778,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=所有工作流
|
||||
@ -3745,6 +3815,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=该工作流由派生仓库的合并请求所触发,需要批准方可运行。
|
||||
|
||||
@ -3764,6 +3835,8 @@ variables.creation.success=变量 “%s” 添加成功。
|
||||
variables.update.failed=编辑变量失败。
|
||||
variables.update.success=该变量已被编辑。
|
||||
|
||||
logs.always_auto_scroll=总是自动滚动日志
|
||||
logs.always_expand_running=总是展开运行日志
|
||||
|
||||
[projects]
|
||||
deleted.display_name=已删除项目
|
||||
|
@ -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=金鑰最小大小
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user